diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index a05dda1b4b..17a9a554c2 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -178,7 +178,7 @@ function isVersionGreater(left: string, right: string) { export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { const global = yield* Global.Service const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown } - yield* Effect.scoped( + const result = yield* Effect.scoped( Effect.gen(function* () { const renderer = yield* Effect.acquireRelease( Effect.tryPromise(() => @@ -337,13 +337,14 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { }, renderer) }) yield* Deferred.await(shutdown) + return { epilogue: exit.epilogue, reason: exit.reason } }), ) yield* Effect.sync(() => { win32FlushInputBuffer() - if (exit.reason !== undefined) - process.stderr.write((cliErrorMessage(exit.reason) ?? errorFormat(exit.reason)) + "\n") - if (exit.epilogue) process.stdout.write(exit.epilogue + "\n") + if (result.reason !== undefined) + process.stderr.write((cliErrorMessage(result.reason) ?? errorFormat(result.reason)) + "\n") + if (result.epilogue) process.stdout.write(result.epilogue + "\n") }) }) diff --git a/packages/tui/src/util/renderer.ts b/packages/tui/src/util/renderer.ts index 66c1318f38..a0c16faa7b 100644 --- a/packages/tui/src/util/renderer.ts +++ b/packages/tui/src/util/renderer.ts @@ -1,8 +1,7 @@ import type { CliRenderer } from "@opentui/core" export function destroyRenderer(renderer: Pick) { - if (!renderer.isDestroyed) { - renderer.setTerminalTitle("") - renderer.destroy() - } + renderer.setTerminalTitle("") + if (renderer.isDestroyed) return + renderer.destroy() } diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index 4c42debc8e..d3983ae8e0 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -1,9 +1,10 @@ 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 } from "./fixture/tui-sdk" +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 }) @@ -57,3 +58,70 @@ test("SIGHUP clears title and disposes scoped resources once", async () => { 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((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() + } +})