opencode/packages/tui/test/app-lifecycle.test.tsx
Kit Langton afd7492018
fix(tui): reduce cached transcript remount work (#46145)
Configure custom Markdown renderers before assigning content and share a reactive message-position index across assistant footers. Preserve completion ordering and historical footer metrics, with regression coverage for prepend, same-length refresh, and revert.
2026-08-31 12:15:39 -04:00

1480 lines
57 KiB
TypeScript

import { expect, test } from "bun:test"
import { EmbeddedTerminalRenderable, type Renderable, ScrollBoxRenderable } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { Effect, FileSystem } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/util/global"
import path from "node:path"
import { createEventStream, createFetch, directory, json, type FetchHandler } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
import type { TuiInput } from "../src/app"
import type { Config } from "../src/config"
test.each([100, 44])("Ctrl-O is immediate, dismissible, and prunes cached deletions at width %s", async (width) => {
await using state = await tmpdir()
const requested = Promise.withResolvers<void>()
const response = Promise.withResolvers<Response>()
const projects = Promise.withResolvers<Response>()
const refresh = Promise.withResolvers<Response>()
const cachedSession = {
id: "ses_cached",
title: "Cached session",
projectID: "proj_fixture",
location: { directory: "/fixture" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
}
let requests = 0
await using setup = await createAppFixture({
width,
state: state.path,
fetch: (url) => {
if (url.pathname === "/api/session") {
requests++
requested.resolve()
if (requests === 1) return response.promise
if (requests === 2 || requests === 4) return refresh.promise.then((response) => response.clone())
if (requests > 4) return new Response("Unavailable", { status: 503 })
return json({ data: [cachedSession], cursor: {} })
}
if (url.pathname === "/api/project") return projects.promise
return undefined
},
})
try {
await setup.ready
await setup.waitForFrame((frame) => frame.includes("commands"))
setup.mockInput.pressKey("o", { ctrl: true })
await requested.promise
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Search sessions")
expect(setup.captureCharFrame()).toContain("Refreshing")
projects.resolve(
json([
{
id: "proj_fixture",
canonical: "/fixture",
name: "Fixture project",
time: { created: 1, updated: 2 },
sandboxes: [],
},
]),
)
await setup.waitForFrame((frame) => frame.includes("Fixture project"))
setup.mockInput.pressKey("o", { ctrl: true })
expect(requests).toBe(1)
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
response.resolve(json({ data: [{ ...cachedSession, id: "ses_disposed", title: "Disposed response" }], cursor: {} }))
await setup.renderOnce()
expect(setup.captureCharFrame()).not.toContain("Fixture project")
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Refreshing"))
expect(setup.captureCharFrame()).not.toContain("Disposed")
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Cached"))
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Cached") && frame.includes("Refreshing"))
setup.events.emit({
id: "evt_deleted",
created: 1,
type: "session.deleted",
durable: { aggregateID: "ses_cached", seq: 1, version: 2 },
data: { sessionID: "ses_cached" },
})
await setup.waitForFrame((frame) => !frame.includes("Cached"))
refresh.resolve(json({ data: [cachedSession], cursor: {} }))
await setup.waitForFrame((frame) => frame.includes("Fixture project") && !frame.includes("Refreshing"))
expect(setup.captureCharFrame()).not.toContain("Cached")
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Could not refresh sessions"))
expect(setup.captureCharFrame()).not.toContain("Cached")
} finally {
response.resolve(json({ data: [], cursor: {} }))
projects.resolve(json([]))
refresh.resolve(json({ data: [], cursor: {} }))
}
})
test.each(["dismissed", "refreshing"])(
"Ctrl-O retains committed movement of a cached-only session while %s",
async (phase) => {
await using state = await tmpdir()
const refresh = Promise.withResolvers<Response>()
const metadata = Promise.withResolvers<Response>()
const destinationRequested = Promise.withResolvers<void>()
const cached = {
id: "ses_cached_move",
title: "Cached movement",
projectID: "proj_old",
location: { directory: "/fixture/old" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
}
let requests = 0
const locations: string[] = []
await using setup = await createAppFixture({
state: state.path,
config: { animations: false, tabs: { enabled: false } },
fetch: (url) => {
if (url.pathname === "/api/session") {
if (url.searchParams.has("parentID")) {
const parent = url.searchParams.get("parentID")
if (parent && parent !== "null") return json({ data: [], cursor: {} })
}
return requests++ === 0
? json({ data: [cached], cursor: {} })
: refresh.promise.then((response) => response.clone())
}
if (url.pathname === `/api/session/${cached.id}`) return metadata.promise
if (url.pathname === `/api/session/${cached.id}/message`) return json({ data: [], cursor: {} })
if (
url.pathname === `/api/session/${cached.id}/inbox` ||
url.pathname === `/api/session/${cached.id}/permission`
)
return json({ data: [] })
if (url.pathname === "/api/project")
return json(
["old", "new"].map((name) => ({
id: `proj_${name}`,
canonical: `/fixture/${name}`,
name: name === "old" ? "Old" : "New",
time: { created: 1, updated: 2 },
sandboxes: [],
})),
)
if (url.pathname === "/api/location") {
const query = url.searchParams.get("location[directory]") ?? ""
locations.push(query)
if (query.includes("/fixture/new")) {
destinationRequested.resolve()
return json({
directory: "/fixture/new",
project: { id: "proj_new", directory: "/fixture/new", canonical: "/fixture/new" },
})
}
}
return undefined
},
})
try {
await setup.ready
await setup.waitForFrame((frame) => frame.includes("commands"))
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes(cached.title) && !frame.includes("Refreshing"))
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Search sessions"))
if (phase === "refreshing") {
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes(cached.title) && frame.includes("Refreshing"))
}
setup.events.emit({
id: "evt_cached_moved",
created: 3,
type: "session.moved",
durable: { aggregateID: cached.id, seq: 1, version: 1 },
data: { sessionID: cached.id, location: { directory: "/fixture/new" }, projectID: "proj_new" },
})
if (phase === "dismissed") {
setup.mockInput.pressKey("o", { ctrl: true })
refresh.resolve(new Response("Unavailable", { status: 503 }))
await setup.waitForFrame((frame) => frame.includes("Could not refresh sessions"))
}
if (phase === "refreshing") {
await setup.waitForFrame((frame) =>
frame.split("\n").some((line) => line.includes(cached.title) && line.includes("New")),
)
refresh.resolve(json({ data: [cached], cursor: {} }))
await setup.waitForFrame((frame) => frame.includes(cached.title) && !frame.includes("Refreshing"))
}
await setup.waitForFrame((frame) =>
frame.split("\n").some((line) => line.includes(cached.title) && line.includes("New")),
)
expect(
setup
.captureCharFrame()
.split("\n")
.find((line) => line.includes(cached.title)),
).toContain("New")
locations.length = 0
setup.mockInput.pressEnter()
await destinationRequested.promise
expect(locations.some((query) => query.includes("/fixture/old"))).toBe(false)
} finally {
refresh.resolve(json({ data: [], cursor: {} }))
metadata.resolve(json({ data: { ...cached, projectID: "proj_new", location: { directory: "/fixture/new" } } }))
}
},
)
test("SIGHUP clears title and disposes scoped resources once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const titles: string[] = []
let started!: () => void
const ready = new Promise<void>((resolve) => {
started = resolve
})
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
setup.renderer.setTerminalTitle = (title) => {
titles.push(title)
if (title === "OpenCode") started()
setTitle(title)
}
const listeners = new Set(process.listeners("SIGHUP"))
const events = createEventStream()
const calls = createFetch(undefined, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
try {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: {},
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
)
await ready
process.emit("SIGHUP")
await task
expect(setup.renderer.isDestroyed).toBe(true)
expect(titles.at(-1)).toBe("")
expect(process.listeners("SIGHUP").every((listener) => listeners.has(listener))).toBe(true)
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
}
})
test("session lifecycle updates the terminal title and prints the epilogue after cleanup", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
let initialTitle!: () => void
const initialTitleSet = new Promise<void>((resolve) => {
initialTitle = resolve
})
let renamedTitle!: () => void
const renamedTitleSet = new Promise<void>((resolve) => {
renamedTitle = resolve
})
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
setup.renderer.setTerminalTitle = (title) => {
if (title === "OC | Demo session") initialTitle()
if (title === "OC | Renamed session") renamedTitle()
setTitle(title)
}
const events = createEventStream()
let promptRequests = 0
const calls = createFetch((url) => {
const session = {
id: "dummy",
title: "Demo session",
projectID: "project",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
if (url.pathname === "/api/session")
return json({
data: [session],
cursor: {},
})
if (url.pathname === "/api/session/dummy") return json({ data: session })
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/dummy/inbox") return json({ data: [] })
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
if (url.pathname === "/api/session/dummy/prompt") {
promptRequests++
return json({ data: {} })
}
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
const originalWrite = process.stdout.write.bind(process.stdout)
let stdout = ""
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({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
)
await initialTitleSet
events.emit({
id: "evt_renamed",
created: 1,
type: "session.renamed",
durable: { aggregateID: "dummy", seq: 1, version: 1 },
data: { sessionID: "dummy", title: "Renamed session" },
})
await renamedTitleSet
setup.renderer.destroy()
await task
expect(stdout).toContain("Renamed session")
expect(stdout).toContain("opencode2 -s dummy")
expect(promptRequests).toBe(0)
} finally {
process.stdout.write = originalWrite
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
}
})
test("session title generated while an untitled session is loading remains visible", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const titles: string[] = []
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
const generatedTitle = Promise.withResolvers<void>()
setup.renderer.setTerminalTitle = (title) => {
titles.push(title)
if (title === "OC | Generated title") generatedTitle.resolve()
setTitle(title)
}
const sessionRequested = Promise.withResolvers<void>()
const renameSyncRequested = Promise.withResolvers<void>()
const releaseSession = Promise.withResolvers<void>()
let sessionRequests = 0
const session = {
id: "dummy",
projectID: "project",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
const events = createEventStream()
const calls = createFetch(async (url) => {
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/dummy") {
sessionRequests++
sessionRequested.resolve()
if (sessionRequests === 2) renameSyncRequested.resolve()
await releaseSession.promise
return json({ data: session })
}
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/dummy/inbox") return json({ data: [] })
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
try {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
)
await sessionRequested.promise
events.emit({
id: "evt_renamed",
created: 1,
type: "session.renamed",
durable: { aggregateID: "dummy", seq: 1, version: 1 },
data: { sessionID: "dummy", title: "Generated title" },
})
await Promise.race([
renameSyncRequested.promise,
Bun.sleep(2_000).then(() => {
throw new Error("rename sync did not start")
}),
])
releaseSession.resolve()
await Promise.race([
generatedTitle.promise,
Bun.sleep(2_000).then(() => {
throw new Error("generated title was not shown")
}),
])
await Bun.sleep(20)
const generated = titles.lastIndexOf("OC | Generated title")
expect(generated).toBeGreaterThan(-1)
expect(titles.slice(generated + 1)).not.toContain("OpenCode")
setup.renderer.destroy()
await task
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
}
})
test("automatic rename refreshes the displayed title before settling, even without a renamed event", async () => {
await using state = await tmpdir()
const response = Promise.withResolvers<Response>()
const bodies: unknown[] = []
const location = { directory, project: { id: "project", directory, canonical: directory } }
const session = {
id: "ses_rename",
title: "Compiler cleanup",
projectID: "project",
location: { directory },
agent: "build",
model: { providerID: "provider", id: "model" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
await using setup = await createAppFixture({
width: 90,
height: 20,
state: state.path,
config: { tabs: { enabled: true, layout: "vertical" }, session: { sidebar: "hide" } },
args: { sessionID: session.id },
fetch: async (url, request) => {
if (url.pathname === "/api/location") return json(location)
if (url.pathname === "/api/agent")
return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] })
if (url.pathname === "/api/model")
return json({ location, data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }] })
if (url.pathname === "/api/provider") return json({ location, data: [{ id: "provider", name: "Provider" }] })
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/ses_rename") return json({ data: session })
if (/^\/api\/session\/ses_rename\/(message|inbox|permission)$/.test(url.pathname))
return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/ses_rename/rename") {
bodies.push(await request.json())
return response.promise
}
return undefined
},
})
try {
await setup.waitForFrame((frame) => frame.includes(session.title) && frame.includes("Build · Model Provider"))
await setup.mockInput.typeText("/rename")
setup.mockInput.pressEscape()
setup.mockInput.pressEnter()
await setup.waitFor(() => bodies.length === 1)
await setup.renderOnce()
expect(bodies[0]).toEqual({ title: "" })
expect(setup.captureCharFrame()).toContain("Compiler cleanup")
session.title = "Simplify compiler parsing"
response.resolve(new Response(null, { status: 204 }))
await setup.waitForFrame((frame) => frame.includes(session.title), { maxPasses: 60 })
expect(setup.captureCharFrame()).not.toContain("Compiler cleanup")
} finally {
response.resolve(new Response(null, { status: 204 }))
}
})
test.each([80, 120])("completes custom Markdown and ordinary fences in a session at width %s", async (width) => {
await using state = await tmpdir()
const session = {
id: "ses_markdown",
title: "Markdown fixture",
projectID: "project",
location: { directory },
agent: "build",
model: { providerID: "fixture", id: "model" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
}
const initial =
"```mermaid\ngraph LR\n A[DiagramStart] --> B[DiagramEnd]\n```\n\n```latex\nx^2\n```\n\n```text\ninitial"
await using setup = await createAppFixture({
width,
height: 55,
state: state.path,
config: { animations: false, tabs: { enabled: false }, session: { sidebar: "hide" } },
args: { sessionID: session.id },
fetch: (url) => {
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
if (url.pathname === `/api/session/${session.id}/message`)
return json({
data: [
{
id: "msg_markdown",
type: "assistant",
agent: session.agent,
model: session.model,
time: { created: 2 },
content: [{ type: "text", text: initial }],
},
{
id: "msg_compaction",
type: "compaction",
time: { created: 1 },
status: "completed",
reason: "manual",
summary: "```latex\ny^2\n```",
recent: "msg_markdown",
},
],
cursor: {},
})
if (
url.pathname === `/api/session/${session.id}/inbox` ||
url.pathname === `/api/session/${session.id}/permission`
)
return json({ data: [] })
return undefined
},
})
const streaming = await setup.waitForFrame(
(frame) =>
frame.includes("initial") &&
frame.includes("DiagramStart") &&
frame.includes("DiagramEnd") &&
frame.includes("x\u00b2") &&
frame.includes("y\u00b2"),
)
expect(streaming).toContain("Compaction")
expect(streaming).not.toContain("initial final")
expect(streaming).not.toContain("MARKDOWN_END")
// Queue final text and completion together to exercise TextPart's reactive property order.
setup.events.emit({
id: "evt_markdown_text_ended",
created: 3,
type: "session.text.ended",
durable: { aggregateID: session.id, seq: 1, version: 1 },
data: {
sessionID: session.id,
assistantMessageID: "msg_markdown",
ordinal: 0,
text: `${initial} final\n\`\`\`\n\nMARKDOWN_END`,
},
})
setup.events.emit({
id: "evt_markdown_step_ended",
created: 4,
type: "session.step.ended",
durable: { aggregateID: session.id, seq: 2, version: 1 },
data: {
sessionID: session.id,
assistantMessageID: "msg_markdown",
finish: "stop",
cost: 0,
tokens: session.tokens,
},
})
const frame = await setup.waitForFrame(
(frame) => frame.includes("MARKDOWN_END") && frame.includes("initial final") && frame.includes("2ms"),
)
expect(frame).toContain("DiagramStart")
expect(frame).toContain("DiagramEnd")
expect(frame).toContain("x\u00b2")
expect(frame).toContain("y\u00b2")
expect(frame).toContain("initial final")
expect(frame).not.toContain("graph LR")
expect(frame).not.toContain("x^2")
expect(frame).not.toContain("y^2")
expect(frame).not.toContain("```")
})
test("keeps assistant footer metrics current after prepend, same-length refresh, and revert", async () => {
await using state = await tmpdir()
const session = {
id: "ses_footer",
title: "Footer fixture",
projectID: "project",
location: { directory },
agent: "build",
model: { providerID: "fixture", id: "model" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 100, updated: 5000 },
}
let refresh = false
await using setup = await createAppFixture({
width: 100,
height: 40,
state: state.path,
config: { animations: false, tabs: { enabled: false }, session: { sidebar: "hide", tps: true } },
args: { sessionID: session.id },
fetch: (url) => {
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
if (url.pathname === `/api/session/${session.id}/message`) {
if (url.searchParams.get("cursor") === "older")
return json({
data: [
{
id: "msg_0001",
type: "system",
text: "Earlier instructions",
description: "Prepended instructions",
time: { created: 200 },
},
{ id: "msg_0000", type: "user", text: "Prepended input", time: { created: 100 } },
],
cursor: {},
})
return json({
data: [
...(refresh
? [
{
id: "msg_0005",
type: "assistant",
agent: session.agent,
model: session.model,
time: { created: 7000, streamed: 8000, completed: 9000 },
finish: "stop",
cost: 0,
tokens: { ...session.tokens, output: 50 },
content: [{ type: "text", text: "Later answer" }],
},
{ id: "msg_0004", type: "user", text: "Later input", time: { created: 6000 } },
]
: []),
{
id: "msg_0003",
type: "assistant",
agent: session.agent,
model: session.model,
time: { created: 2000, streamed: 3000, completed: 5000 },
finish: "stop",
cost: 0,
tokens: { ...session.tokens, output: 20 },
content: [{ type: "text", text: "Original answer" }],
},
{ id: "msg_0002", type: "user", text: "Current input", time: { created: 1000 } },
],
cursor: { next: "older" },
})
}
if (
url.pathname === `/api/session/${session.id}/inbox` ||
url.pathname === `/api/session/${session.id}/permission`
)
return json({ data: [] })
return undefined
},
})
const initial = await setup.waitForFrame((frame) => frame.includes("Original answer") && frame.includes("20.0 tok/s"))
expect(initial).toContain("Current input")
expect(initial).toContain("4.0s \u00b7 20.0 tok/s")
expect(initial).not.toContain("Prepended input")
setup.mockInput.pressKey("g", { ctrl: true })
const prepended = await setup.waitForFrame(
(frame) =>
frame.includes("Prepended input") &&
frame.includes("Prepended instructions") &&
frame.includes("Original answer") &&
frame.includes("20.0 tok/s") &&
!frame.includes("Loading session history..."),
)
expect(prepended).toContain("Current input")
expect(prepended).toContain("Original answer")
expect(prepended).toContain("4.0s \u00b7 20.0 tok/s")
// Refresh the latest page: length stays four, but the retained assistant moves from index three to one.
refresh = true
setup.events.disconnect()
const refreshed = await setup.waitForFrame(
(frame) =>
frame.includes("Later input") &&
frame.includes("Later answer") &&
!frame.includes("Prepended input") &&
!frame.includes("Prepended instructions"),
{ maxPasses: 120 },
)
expect(refreshed).toContain("Current input")
expect(refreshed).toContain("Original answer")
expect(refreshed).toContain("4.0s \u00b7 20.0 tok/s")
expect(refreshed).toContain("3.0s \u00b7 50.0 tok/s")
setup.events.emit({
id: "evt_footer_reverted",
created: 10000,
type: "session.revert.committed",
durable: { aggregateID: session.id, seq: 1, version: 1 },
data: { sessionID: session.id, to: "msg_0004" },
})
const reverted = await setup.waitForFrame(
(frame) => frame.includes("Original answer") && !frame.includes("Later input") && !frame.includes("Later answer"),
)
expect(reverted).toContain("Current input")
expect(reverted).toContain("4.0s \u00b7 20.0 tok/s")
expect(reverted).not.toContain("50.0 tok/s")
})
test("session startup prompt is submitted exactly once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const events = createEventStream()
const cwd = process.cwd()
const location = { directory: cwd, project: { id: "project", directory: cwd } }
const session = {
id: "dummy",
title: "Demo session",
projectID: "project",
location: { directory: cwd },
agent: "build",
model: { providerID: "provider", id: "model" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
const bodies: unknown[] = []
const promptSubmitted = Promise.withResolvers<void>()
const calls = createFetch(async (url, request) => {
if (url.pathname === "/api/location") return json(location)
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === "/api/session/dummy") return json({ data: session })
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/dummy/inbox") return json({ data: [] })
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
if (url.pathname === "/api/agent")
return json({
location,
data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }],
})
if (url.pathname === "/api/model")
return json({
location,
data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }],
})
if (url.pathname === "/api/session/dummy/prompt") {
bodies.push(await request.json())
promptSubmitted.resolve()
return json({ data: {} })
}
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
try {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy", prompt: "RESUME_READY" },
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
)
await Promise.race([
promptSubmitted.promise,
Bun.sleep(2000).then(() => {
throw new Error("startup prompt was not submitted")
}),
])
await Bun.sleep(20)
setup.renderer.destroy()
await task
expect(bodies).toHaveLength(1)
expect(bodies[0]).toMatchObject({ text: "RESUME_READY" })
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
}
})
test.each([false, true])("uses the resolved launch directory for new prompts (fallback: %s)", async (fallback) => {
await using state = await tmpdir()
const target = fallback ? directory : process.cwd()
const location = { directory: target, project: { id: "project", directory: target, canonical: target } }
const requests: URL[] = []
const created = Promise.withResolvers<unknown>()
const submitted = Promise.withResolvers<unknown>()
let session: unknown
await using setup = await createAppFixture({
state: state.path,
config: { animations: false, tabs: { enabled: false }, keybinds: { "session.new": "f6" } },
fetch: async (url, request) => {
requests.push(url)
if (url.searchParams.has("location[directory]") && url.searchParams.get("location[directory]") !== target)
return json({ message: "Directory does not exist on the server" }, { status: 500 })
if (url.pathname === "/api/fs/list") return json({ location, data: [] })
if (url.pathname === "/api/location") return json(location)
if (url.pathname === "/api/agent")
return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] })
if (url.pathname === "/api/model")
return json({ location, data: [{ id: "model", providerID: "provider", name: "Remote Model", variants: [] }] })
if (url.pathname === "/api/provider") return json({ location, data: [{ id: "provider", name: "Provider" }] })
if (url.pathname === "/api/session" && request.method === "POST") {
const input: unknown = await request.json()
if (typeof input !== "object" || input === null) throw new Error("Expected a session input")
created.resolve(input)
session = {
...input,
projectID: "project",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
return json({ data: session })
}
if (/^\/api\/session\/[^/]+\/prompt$/.test(url.pathname)) {
submitted.resolve(await request.json())
return json({ data: {} })
}
if (/^\/api\/session\/[^/]+\/(message|inbox|permission)$/.test(url.pathname))
return json({ data: [], cursor: {} })
if (session && /^\/api\/session\/[^/]+$/.test(url.pathname)) return json({ data: session })
return undefined
},
})
await setup.ready
await setup.waitForFrame((frame) => frame.includes("Build · Remote Model Provider"))
setup.mockInput.pressKey("F6")
await setup.renderOnce()
await setup.mockInput.typeText("REMOTE_READY")
await setup.waitForFrame((frame) => frame.includes("REMOTE_READY"))
setup.mockInput.pressEnter()
expect(
await Promise.race([
submitted.promise,
Bun.sleep(2_000).then(() => {
throw new Error("prompt was not submitted in the resolved server directory")
}),
]),
).toMatchObject({ text: "REMOTE_READY" })
expect(await created.promise).toMatchObject({ location: { directory: target } })
expect(requests[0]?.pathname).toBe("/api/fs/list")
expect(requests[0]?.searchParams.get("location[directory]")).toBe(process.cwd())
expect(
requests.filter((url) => url.pathname === "/api/location" && !url.searchParams.has("location[directory]")),
).toHaveLength(fallback ? 1 : 0)
expect(
requests
.slice(1)
.filter((url) => url.searchParams.has("location[directory]"))
.every((url) => url.searchParams.get("location[directory]") === target),
).toBe(true)
})
test("error investigations repeatedly seed editable home drafts without creating sessions", async () => {
const cwd = process.cwd()
const location = { directory: cwd, project: { id: "project", directory: cwd } }
let created = 0
await using setup = await createAppFixture({
config: { animations: false, keybinds: { "mcp.list": "f6" } },
fetch: (url, request) => {
if (url.pathname === "/api/location") return json(location)
if (url.pathname === "/api/mcp")
return json({
location,
data: [
{ name: "alpha", status: { status: "failed", error: "Alpha connection refused" } },
{ name: "beta", status: { status: "failed", error: "Beta initialization failed" } },
],
})
if (url.pathname === "/api/session" && request.method === "POST") created++
return undefined
},
})
await setup.waitForFrame((frame) => frame.includes("commands"))
setup.mockInput.pressKey("F6")
await setup.waitForFrame((frame) => frame.includes("MCP servers") && frame.includes("alpha"))
setup.mockInput.pressEnter()
await setup.waitForFrame((frame) => frame.includes("Alpha connection refused") && frame.includes("i investigate"))
setup.mockInput.pressKey("i")
await setup.waitForFrame((frame) => frame.includes("Alpha connection refused") && !frame.includes("i investigate"))
setup.mockInput.pressKey("F6")
await setup.waitForFrame((frame) => frame.includes("MCP servers"))
setup.mockInput.pressArrow("down")
setup.mockInput.pressEnter()
await setup.waitForFrame((frame) => frame.includes("Beta initialization failed") && frame.includes("i investigate"))
setup.mockInput.pressKey("i")
const draft = await setup.waitForFrame(
(frame) => frame.includes("Beta initialization failed") && !frame.includes("i investigate"),
)
expect(draft).not.toContain("Alpha connection refused")
expect(created).toBe(0)
setup.mockInput.pressKey("c", { ctrl: true })
await setup.waitForFrame((frame) => !frame.includes("Beta initialization failed"))
})
test("shows jump to latest after scrolling one line above the final message", async () => {
const session = {
id: "dummy",
title: "Demo session",
projectID: "project",
location: { directory },
agent: "build",
model: { providerID: "provider", id: "model" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
const messages = Array.from({ length: 8 }, (_, index) => ({
id: `message-${index}`,
type: "user",
text: index === 7 ? "Final visible message" : `Earlier message ${index}`,
time: { created: index },
}))
await using setup = await createAppFixture({
width: 80,
height: 20,
config: { animations: false, keybinds: { "session.line.up": "f6", "session.line.down": "f7" } },
args: { sessionID: "dummy" },
fetch: (url) => {
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === "/api/session/dummy") return json({ data: session })
if (url.pathname === "/api/session/dummy/message") return json({ data: messages.toReversed(), cursor: {} })
if (url.pathname === "/api/session/dummy/inbox") return json({ data: [] })
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
},
})
await setup.waitForFrame((frame) => frame.includes("Final visible message"))
const findScrollBox = (root: Renderable): ScrollBoxRenderable | undefined =>
root instanceof ScrollBoxRenderable && root.getRenderable("message-7")
? root
: root.getChildren().map(findScrollBox).find(Boolean)
const scroll = findScrollBox(setup.renderer.root)
expect(scroll).toBeDefined()
if (!scroll) throw new Error("session transcript scrollbox was not found")
const maximum = () => Math.max(0, scroll.scrollHeight - scroll.viewport.height)
expect(scroll.scrollTop).toBe(maximum())
const initial = setup.captureCharFrame().split("\n")
expect(initial.find((line) => line.includes("Jump to latest"))).toBeUndefined()
expect(initial[initial.findIndex((line) => line.includes("Final visible message")) + 1]).toContain("┃")
setup.mockInput.pressKey("F6")
const clipped = (await setup.waitForFrame((frame) => frame.includes("Jump to latest"))).split("\n")
expect(scroll.scrollTop).toBe(maximum() - 1)
expect(clipped.find((line) => line.includes("Jump to latest"))).toBeDefined()
expect(clipped[clipped.findIndex((line) => line.includes("Final visible message")) + 1]).not.toContain("┃")
setup.mockInput.pressKey("F7")
const restored = (await setup.waitForFrame((frame) => !frame.includes("Jump to latest"))).split("\n")
expect(scroll.scrollTop).toBe(maximum())
expect(restored.find((line) => line.includes("Jump to latest"))).toBeUndefined()
expect(restored[restored.findIndex((line) => line.includes("Final visible message")) + 1]).toContain("┃")
})
test("completed user shell output replaces a partial live read when the final read fails", async () => {
await using state = await tmpdir()
const session = {
id: "ses_shell_output",
title: "Shell output fixture",
projectID: "proj_test",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
const shell = {
id: "sh_output",
command: "echo shell-output-fixture",
status: "running" as const,
cwd: directory,
shell: "/bin/sh",
file: `${directory}/shell.out`,
metadata: { sessionID: session.id, background: true },
time: { started: 1 },
}
const partial = "first live output\n"
const completed = `${partial}last persisted output\n`
let finished = false
let failedReads = 0
await using setup = await createAppFixture({
state: state.path,
config: { animations: false, tabs: { enabled: false }, session: { sidebar: "hide" } },
args: { sessionID: session.id },
fetch: (url) => {
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
if (url.pathname === `/api/session/${session.id}/message`)
return json({
data: [
{
id: "msg_shell_output",
type: "shell",
shellID: shell.id,
command: shell.command,
status: "running",
metadata: { background: true },
time: { created: 1 },
},
],
cursor: {},
})
if (
url.pathname === `/api/session/${session.id}/inbox` ||
url.pathname === `/api/session/${session.id}/permission`
)
return json({ data: [] })
if (url.pathname === "/api/shell") return json({ location: { directory }, data: finished ? [] : [shell] })
if (url.pathname === `/api/shell/${shell.id}/output`) {
if (finished) {
failedReads++
return json({ message: "Shell output unavailable" }, { status: 404 })
}
return json({
location: { directory },
data: {
output: partial.slice(Number(url.searchParams.get("cursor") ?? 0)),
cursor: partial.length,
size: partial.length,
truncated: false,
},
})
}
return undefined
},
})
const running = await setup.waitForFrame((frame) => frame.includes(partial.trim()))
expect(running).toContain(shell.command)
expect(running).not.toContain("Background")
finished = true
setup.events.emit({
id: "evt_shell_exited",
created: 2,
type: "shell.exited",
location: { directory },
data: { id: shell.id, status: "exited", exit: 0 },
})
await setup.waitFor(() => failedReads > 0)
setup.events.emit({
id: "evt_shell_ended",
created: 3,
type: "session.shell.ended",
durable: { aggregateID: session.id, seq: 1, version: 1 },
data: {
sessionID: session.id,
shell: { ...shell, status: "exited", exit: 0, time: { started: 1, completed: 2 } },
output: { output: completed, cursor: completed.length, size: completed.length, truncated: false },
},
})
const terminal = await setup.waitForFrame((frame) => frame.includes("last persisted output"))
expect(terminal).toContain(partial.trim())
expect(terminal).toContain(`$ ${shell.command}`)
expect(terminal).not.toContain("Background")
})
test("new session inherits the active session model", async () => {
const cwd = process.cwd()
const location = { directory: cwd, project: { id: "project", directory: cwd } }
const session = {
id: "dummy",
title: "Demo session",
projectID: "project",
location: { directory: cwd },
agent: "build",
model: { providerID: "provider", id: "session-model" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
await using setup = await createAppFixture({
width: 80,
height: 24,
args: { sessionID: "dummy" },
fetch: (url) => {
if (url.pathname === "/api/fs/list") return json({ location, data: [] })
if (url.pathname === "/api/location") return json(location)
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === "/api/session/dummy") return json({ data: session })
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/dummy/inbox") return json({ data: [] })
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
if (url.pathname === "/api/agent")
return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] })
if (url.pathname === "/api/provider") return json({ location, data: [{ id: "provider", name: "Provider" }] })
if (url.pathname === "/api/model")
return json({
location,
data: [
{ id: "home-model", providerID: "provider", name: "Home Model", variants: [] },
{ id: "session-model", providerID: "provider", name: "Session Model", variants: [] },
],
})
},
})
await setup.waitForFrame((frame) => frame.includes("Session Model"))
await setup.mockInput.typeText("/new")
setup.mockInput.pressEnter()
await Bun.sleep(50)
await setup.renderOnce()
const frame = setup.captureCharFrame()
expect(frame).toContain("Session Model")
})
test("keeps the prompt display stable while a new location catalog loads", async () => {
const source = process.cwd()
const target = path.join(path.parse(source).root, "opencode-target")
const locationCatalog = Promise.withResolvers<void>()
const catalog = Promise.withResolvers<void>()
const providerCatalog = Promise.withResolvers<void>()
const locationRequested = Promise.withResolvers<void>()
const modelRequested = Promise.withResolvers<void>()
await using setup = await createAppFixture({
fetch: async (url) => {
const requestedDirectory = url.searchParams.get("location[directory]") ?? source
const location = {
directory: requestedDirectory,
project: {
id: requestedDirectory === target ? "target" : "source",
directory: requestedDirectory,
canonical: requestedDirectory,
},
}
if (url.pathname === "/api/location") {
if (requestedDirectory === target) {
locationRequested.resolve()
await locationCatalog.promise
}
return json(location)
}
if (url.pathname === "/api/agent") {
if (requestedDirectory === target) await catalog.promise
return json({
location,
data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }],
})
}
if (url.pathname === "/api/provider") {
if (requestedDirectory === target) await providerCatalog.promise
return json({ location, data: [{ id: "provider", name: "Provider" }] })
}
if (url.pathname === "/api/model") {
if (requestedDirectory === target) {
modelRequested.resolve()
await catalog.promise
}
return json({
location,
data: [
{
id: requestedDirectory === target ? "target-model" : "source-model",
providerID: "provider",
name: requestedDirectory === target ? "Target Model" : "Source Model",
variants: [],
},
],
})
}
return undefined
},
})
try {
await setup.ready
await setup.waitForFrame((frame) => frame.includes("Build · Source Model Provider"))
const agentSpan = () =>
setup
.captureSpans()
.lines.flatMap((line) => line.spans)
.find((span) => span.text.trim() === "Build")
const sourceAgentColor = agentSpan()?.fg.toInts()
expect(sourceAgentColor).toBeDefined()
await setup.mockInput.typeText(`/cd ${target}`)
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain(`/cd ${target}`)
setup.mockInput.pressEscape()
setup.mockInput.pressEnter()
await Promise.race([
locationRequested.promise,
Bun.sleep(2_000).then(() => {
throw new Error("target location was not requested")
}),
])
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain(target)
locationCatalog.resolve()
await Promise.race([
modelRequested.promise,
Bun.sleep(2_000).then(() => {
throw new Error("target model catalog was not requested")
}),
])
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Build · Source Model Provider")
expect(agentSpan()?.fg.toInts()).toEqual(sourceAgentColor)
catalog.resolve()
const resolved = await setup.waitForFrame((frame) => frame.includes("Build · Target Model provider"))
expect(resolved).not.toContain("Source Model")
providerCatalog.resolve()
await setup.waitForFrame((frame) => frame.includes("Build · Target Model Provider"))
} finally {
locationCatalog.resolve()
catalog.resolve()
providerCatalog.resolve()
}
})
test("configured app bindings execute settings and permission commands", async () => {
await using setup = await createAppFixture({
config: { animations: false, keybinds: { "opencode.settings": "f6", "permission.mode": "f7" } },
})
await setup.ready
await setup.waitForFrame((frame) => frame.includes("commands"))
setup.mockInput.pressKey("F6")
const settings = await setup.waitForFrame((frame) => frame.includes("Settings"))
expect(settings).toContain("Color mode")
expect(settings).toContain("Animations")
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Settings"))
setup.mockInput.pressKey("F7")
await setup.renderOnce()
setup.mockInput.pressKey("p", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Commands"))
setup.mockInput.pressKey("END")
const commands = await setup.waitForFrame(
(frame) => {
if (frame.includes("Disable auto-approve permissions")) return true
setup.mockInput.pressArrow("up")
return false
},
{ maxPasses: 100 },
)
expect(commands).not.toContain("Enable auto-approve permissions")
})
test("ctrl+c dismisses autocomplete and shell mode before exiting", async () => {
await using setup = await createAppFixture()
await setup.ready
await setup.waitForFrame((frame) => frame.includes("commands"))
await setup.mockInput.typeText("/theme")
await setup.waitForFrame((frame) => frame.includes("Switch theme"))
setup.mockInput.pressKey("c", { ctrl: true })
await setup.waitForFrame((frame) => !frame.includes("Switch theme"))
expect(setup.renderer.isDestroyed).toBe(false)
await setup.mockInput.typeText("!")
await setup.waitForFrame((frame) => frame.includes("Shell"))
setup.mockInput.pressKey("c", { ctrl: true })
await setup.waitForFrame((frame) => !frame.includes("Shell"))
expect(setup.renderer.isDestroyed).toBe(false)
})
test.each(["manual", "select"] as const)(
"selection copy and dismissal respect %s mode in the prompt and terminal pane",
async (copy) => {
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const ready = Promise.withResolvers<void>()
const session = {
id: "dummy",
title: "Selection fixture",
projectID: "project",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
const pty = {
id: "pty_fixture",
sessionID: session.id,
title: "Terminal",
command: "/bin/sh",
args: [],
cwd: directory,
status: "running",
pid: 1,
foregroundProcess: null,
size: { cols: 48, rows: 24 },
output: { head: 0, tail: 0 },
}
const input: string[] = []
const calls = createFetch((url, request) => {
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === "/api/session/dummy") return json({ data: session })
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/dummy/inbox") return json({ data: [] })
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
if (url.pathname === "/api/experimental/session/dummy/terminal")
return json({ data: request.method === "POST" ? pty : [pty] })
if (url.pathname === "/api/experimental/persistent-pty/pty_fixture/snapshot")
return json({
data: {
info: pty,
text: "alpha beta gamma",
checkpoint: Buffer.from("alpha beta gamma").toString("base64"),
cursor: { x: 16, y: 0 },
},
})
if (url.pathname === "/api/experimental/persistent-pty/pty_fixture/connect-token")
return json({ data: { ticket: "fixture" } })
return undefined
}, createEventStream())
const server = Bun.serve({
port: 0,
fetch(request, server) {
if (new URL(request.url).pathname.endsWith("/connect") && server.upgrade(request)) return undefined
return calls.fetch(request)
},
websocket: {
open(socket) {
socket.send(JSON.stringify({ type: "attached", inputProtocol: 1, role: "controller", info: pty }))
socket.send(JSON.stringify({ type: "replay_complete" }))
},
message(_socket, message) {
const data = Buffer.from(message)
if (data[0] === 1) input.push(data.subarray(5).toString())
},
},
})
try {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: {
get: async () => ({
animations: false,
terminal: { copy },
session: { terminal: true },
}),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
args: { sessionID: session.id },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
)
await ready.promise
await setup.waitForFrame((frame) => frame.includes("commands"))
await setup.mockInput.typeText("selection audit draft")
setup.mockInput.pressKey("a", { ctrl: true, shift: true })
expect(setup.renderer.getSelection()?.getSelectedText()).toBe("selection audit draft")
setup.mockInput.pressEscape()
expect(setup.renderer.hasSelection).toBeFalse()
expect(setup.renderer.currentFocusedEditor?.plainText).toBe("selection audit draft")
setup.mockInput.pressKey("c", { ctrl: true })
await setup.waitForFrame((frame) => !frame.includes("selection audit draft"))
expect(setup.renderer.currentFocusedEditor?.plainText).toBe("")
expect(setup.renderer.hasSelection).toBeFalse()
expect(setup.renderer.isDestroyed).toBeFalse()
await setup.mockInput.typeText("/terminal")
await setup.waitForFrame((frame) => frame.includes("New terminal"))
setup.mockInput.pressEnter()
await setup.waitForFrame((frame) => frame.includes("alpha beta gamma"))
setup.mockInput.pressKey("x", { ctrl: true })
setup.mockInput.pressArrow("right")
const terminal = setup.renderer.currentFocusedRenderable
if (!(terminal instanceof EmbeddedTerminalRenderable)) throw new Error("Terminal was not focused")
setup.renderer.startSelection(terminal, terminal.x + 6, terminal.y)
setup.renderer.updateSelection(terminal, terminal.x + 9, terminal.y, { finishDragging: true })
expect(setup.renderer.getSelection()?.getSelectedText()).toBe("beta")
setup.mockInput.pressEscape()
expect(setup.renderer.hasSelection).toBeFalse()
expect(terminal.hasSelection()).toBeFalse()
await setup.mockMouse.click(terminal.x + 6, terminal.y)
if (copy === "select") {
setup.renderer.updateSelection(terminal, terminal.x + 9, terminal.y, { finishDragging: true })
expect(setup.renderer.getSelection()?.getSelectedText()).toBe("beta")
}
setup.mockInput.pressKey("c", { ctrl: true })
await setup.waitFor(() => input.length > 0)
expect(input).toEqual(["\x03"])
expect(setup.renderer.hasSelection).toBeFalse()
expect(setup.renderer.isDestroyed).toBeFalse()
setup.renderer.destroy()
await task
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
}
},
)
async function createAppFixture(
input: {
width?: number
height?: number
state?: string
config?: Config.Info
args?: TuiInput["args"]
fetch?: FetchHandler
} = {},
) {
const { run } = await import("../src/app")
const setup = await createTestRenderer({
width: input.width ?? 100,
height: input.height ?? 30,
useThread: false,
kittyKeyboard: true,
})
setup.renderer.start()
const ready = Promise.withResolvers<void>()
const events = createEventStream()
const calls = createFetch(input.fetch, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => input.config ?? { animations: false }, update: async () => ({}) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: input.args ?? {},
log: () => {},
}).pipe(
Effect.provide(input.state ? Global.layerWith({ state: input.state }) : AppNodeBuilder.build(Global.node)),
Effect.provide(FileSystem.layerNoop({})),
),
)
return {
...setup,
events,
ready: ready.promise,
async [Symbol.asyncDispose]() {
try {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await task
} finally {
await server.stop()
}
},
}
}