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 { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Global } from "@opencode-ai/core/global" import { createTuiResolvedConfig } from "./fixture/tui-runtime" import { createApi, createClient, createEventStream, 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 = createEventStream() const calls = createFetch(undefined, events) let started!: () => void const ready = new Promise((resolve) => { started = resolve }) let disposes = 0 try { const { run } = await import("../src/app") const task = Effect.runPromise( run({ client: createClient(calls.fetch), api: createApi(calls.fetch), config: createTuiResolvedConfig({ plugin_enabled: {} }), args: {}, pluginHost: { async start() { started() }, async dispose() { disposes++ }, }, }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))), ) 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("session lifecycle updates the terminal title and prints the epilogue after 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 })) let initialTitle!: () => void const initialTitleSet = new Promise((resolve) => { initialTitle = resolve }) let renamedTitle!: () => void const renamedTitleSet = new Promise((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() const calls = createFetch((url) => { if (url.pathname === "/api/session") return json({ data: [ { 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 }, }, ], cursor: {}, }) }, events) const originalWrite = process.stdout.write.bind(process.stdout) let stdout = "" let api: TuiPluginApi | undefined let started!: () => void const ready = new Promise((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({ client: createClient(calls.fetch), api: createApi(calls.fetch), config: createTuiResolvedConfig({ plugin_enabled: {} }), args: { sessionID: "dummy" }, pluginHost: { async start(input) { api = input.api started() }, async dispose() {}, }, }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))), ) await ready 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 api?.keymap.dispatchCommand("app.exit") await task expect(stdout).toContain("Renamed session") expect(stdout).toContain("opencode -s dummy") } finally { process.stdout.write = originalWrite if (!setup.renderer.isDestroyed) setup.renderer.destroy() mock.restore() } })