Skip to content

Commit 92c70c9

Browse files
authored
fix(tui): preserve exit epilogue during scoped shutdown (anomalyco#31805)
1 parent 318dbe9 commit 92c70c9

3 files changed

Lines changed: 77 additions & 9 deletions

File tree

packages/tui/src/app.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ function isVersionGreater(left: string, right: string) {
178178
export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
179179
const global = yield* Global.Service
180180
const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown }
181-
yield* Effect.scoped(
181+
const result = yield* Effect.scoped(
182182
Effect.gen(function* () {
183183
const renderer = yield* Effect.acquireRelease(
184184
Effect.tryPromise(() =>
@@ -337,13 +337,14 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
337337
}, renderer)
338338
})
339339
yield* Deferred.await(shutdown)
340+
return { epilogue: exit.epilogue, reason: exit.reason }
340341
}),
341342
)
342343
yield* Effect.sync(() => {
343344
win32FlushInputBuffer()
344-
if (exit.reason !== undefined)
345-
process.stderr.write((cliErrorMessage(exit.reason) ?? errorFormat(exit.reason)) + "\n")
346-
if (exit.epilogue) process.stdout.write(exit.epilogue + "\n")
345+
if (result.reason !== undefined)
346+
process.stderr.write((cliErrorMessage(result.reason) ?? errorFormat(result.reason)) + "\n")
347+
if (result.epilogue) process.stdout.write(result.epilogue + "\n")
347348
})
348349
})
349350

packages/tui/src/util/renderer.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import type { CliRenderer } from "@opentui/core"
22

33
export function destroyRenderer(renderer: Pick<CliRenderer, "isDestroyed" | "setTerminalTitle" | "destroy">) {
4-
if (!renderer.isDestroyed) {
5-
renderer.setTerminalTitle("")
6-
renderer.destroy()
7-
}
4+
renderer.setTerminalTitle("")
5+
if (renderer.isDestroyed) return
6+
renderer.destroy()
87
}

packages/tui/test/app-lifecycle.test.tsx

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { expect, mock, test } from "bun:test"
2+
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
23
import { createTestRenderer } from "@opentui/core/testing"
34
import { Effect } from "effect"
45
import { Global } from "@opencode-ai/core/global"
56
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
6-
import { createEventSource, createFetch, directory } from "./fixture/tui-sdk"
7+
import { createEventSource, createFetch, directory, json } from "./fixture/tui-sdk"
78

89
test("SIGHUP clears title and disposes scoped resources once", async () => {
910
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
@@ -57,3 +58,70 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
5758
mock.restore()
5859
}
5960
})
61+
62+
test("app.exit prints the session epilogue after scoped cleanup", async () => {
63+
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
64+
const core = await import("@opentui/core")
65+
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
66+
const events = createEventSource()
67+
const calls = createFetch((url) => {
68+
if (url.pathname === "/session")
69+
return json([
70+
{
71+
id: "dummy",
72+
title: "Demo session",
73+
slug: "dummy",
74+
projectID: "project",
75+
directory,
76+
version: "0.0.0-test",
77+
time: { created: 0, updated: 0 },
78+
},
79+
])
80+
})
81+
const originalWrite = process.stdout.write.bind(process.stdout)
82+
let stdout = ""
83+
let api: TuiPluginApi | undefined
84+
let started!: () => void
85+
const ready = new Promise<void>((resolve) => {
86+
started = resolve
87+
})
88+
89+
process.stdout.write = ((chunk: string | Uint8Array) => {
90+
stdout += String(chunk)
91+
return true
92+
}) as typeof process.stdout.write
93+
94+
try {
95+
const { run } = await import("../src/app")
96+
const task = Effect.runPromise(
97+
run({
98+
url: "http://test",
99+
directory,
100+
config: createTuiResolvedConfig({ plugin_enabled: {} }),
101+
fetch: calls.fetch,
102+
events: events.source,
103+
args: { continue: true },
104+
pluginHost: {
105+
async start(input) {
106+
api = input.api
107+
started()
108+
},
109+
async dispose() {},
110+
},
111+
}).pipe(Effect.provide(Global.defaultLayer)),
112+
)
113+
114+
await ready
115+
await setup.renderOnce()
116+
await setup.renderOnce()
117+
api?.keymap.dispatchCommand("app.exit")
118+
await task
119+
120+
expect(stdout).toContain("Demo session")
121+
expect(stdout).toContain("opencode -s dummy")
122+
} finally {
123+
process.stdout.write = originalWrite
124+
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
125+
mock.restore()
126+
}
127+
})

0 commit comments

Comments
 (0)