Przeglądaj źródła

fix(tui): preserve exit epilogue during scoped shutdown (#31805)

tobwen 2 miesięcy temu
rodzic
commit
92c70c9c3d

+ 5 - 4
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")
   })
 })
 

+ 3 - 4
packages/tui/src/util/renderer.ts

@@ -1,8 +1,7 @@
 import type { CliRenderer } from "@opentui/core"
 
 export function destroyRenderer(renderer: Pick<CliRenderer, "isDestroyed" | "setTerminalTitle" | "destroy">) {
-  if (!renderer.isDestroyed) {
-    renderer.setTerminalTitle("")
-    renderer.destroy()
-  }
+  renderer.setTerminalTitle("")
+  if (renderer.isDestroyed) return
+  renderer.destroy()
 }

+ 69 - 1
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<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()
+  }
+})