From c630c046e84ce0c7c94bad905e5ae948891fc142 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 2 Feb 2026 00:00:04 -0500 Subject: [PATCH 1/3] tui: show exit message banner --- packages/opencode/src/cli/cmd/tui/app.tsx | 4 ++ .../opencode/src/cli/cmd/tui/context/exit.tsx | 43 +++++++++++++------ .../opencode/src/cli/cmd/tui/exit-message.ts | 19 ++++++++ .../opencode/src/cli/cmd/tui/routes/home.tsx | 3 ++ .../src/cli/cmd/tui/routes/session/index.tsx | 14 ++++++ packages/opencode/src/cli/cmd/tui/thread.ts | 3 ++ .../test/cli/tui/exit-message.test.ts | 27 ++++++++++++ 7 files changed, 101 insertions(+), 12 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/exit-message.ts create mode 100644 packages/opencode/test/cli/tui/exit-message.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 2e1ffa4f008f..18c5342d82dd 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -199,6 +199,10 @@ function App() { const exit = useExit() const promptRef = usePromptRef() + onMount(() => { + exit.message.set("Goodbye") + }) + // Wire up console copy-to-clipboard via opentui's onCopySelection callback renderer.console.onCopySelection = async (text: string) => { if (!text || text.length === 0) return diff --git a/packages/opencode/src/cli/cmd/tui/context/exit.tsx b/packages/opencode/src/cli/cmd/tui/context/exit.tsx index 414cb1a41d09..64784a9a339c 100644 --- a/packages/opencode/src/cli/cmd/tui/context/exit.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/exit.tsx @@ -1,23 +1,42 @@ import { useRenderer } from "@opentui/solid" import { createSimpleContext } from "./helper" import { FormatError, FormatUnknownError } from "@/cli/error" +import { ExitMessage } from "../exit-message" + +type Exit = ((reason?: unknown) => Promise) & { + message: { + set: (value?: string) => () => void + clear: () => void + get: () => string | undefined + } +} export const { use: useExit, provider: ExitProvider } = createSimpleContext({ name: "Exit", init: (input: { onExit?: () => Promise }) => { const renderer = useRenderer() - return async (reason?: any) => { - // Reset window title before destroying renderer - renderer.setTerminalTitle("") - renderer.destroy() - await input.onExit?.() - if (reason) { - const formatted = FormatError(reason) ?? FormatUnknownError(reason) - if (formatted) { - process.stderr.write(formatted + "\n") + const exit: Exit = Object.assign( + async (reason?: unknown) => { + // Reset window title before destroying renderer + renderer.setTerminalTitle("") + renderer.destroy() + await input.onExit?.() + if (reason) { + const formatted = FormatError(reason) ?? FormatUnknownError(reason) + if (formatted) { + process.stderr.write(formatted + "\n") + } } - } - process.exit(0) - } + process.exit(0) + }, + { + message: { + set: ExitMessage.set, + clear: ExitMessage.clear, + get: ExitMessage.get, + }, + }, + ) + return exit }, }) diff --git a/packages/opencode/src/cli/cmd/tui/exit-message.ts b/packages/opencode/src/cli/cmd/tui/exit-message.ts new file mode 100644 index 000000000000..8c1cc6e936bd --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/exit-message.ts @@ -0,0 +1,19 @@ +export namespace ExitMessage { + let message: string | undefined + + export function set(value?: string) { + const prev = message + message = value + return () => { + message = prev + } + } + + export function get() { + return message + } + + export function clear() { + message = undefined + } +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index 59923c69d94c..8db3c2f7e081 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -14,6 +14,7 @@ import { usePromptRef } from "../context/prompt" import { Installation } from "@/installation" import { useKV } from "../context/kv" import { useCommandDialog } from "../component/dialog-command" +import { useExit } from "../context/exit" // TODO: what is the best way to do this? let once = false @@ -25,6 +26,7 @@ export function Home() { const route = useRouteData("home") const promptRef = usePromptRef() const command = useCommandDialog() + const exit = useExit() const mcp = createMemo(() => Object.keys(sync.data.mcp).length > 0) const mcpError = createMemo(() => { return Object.values(sync.data.mcp).some((x) => x.status === "failed") @@ -77,6 +79,7 @@ export function Home() { let prompt: PromptRef const args = useArgs() onMount(() => { + // exit.message.clear() if (once) return if (route.initialPrompt) { prompt.set(route.initialPrompt) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 8316d112c9db..cb8b599d41a3 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -7,6 +7,7 @@ import { For, Match, on, + onMount, Show, Switch, useContext, @@ -77,6 +78,7 @@ import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" import { formatTranscript } from "../../util/transcript" +import { UI } from "@/cli/ui.ts" addDefaultParsers(parsers.parsers) @@ -222,6 +224,18 @@ export function Session() { // Allow exit when in child session (prompt is hidden) const exit = useExit() + + createEffect(() => { + return exit.message.set( + [ + ``, + ` █▀▀█ ${UI.Style.TEXT_DIM}${session()?.title}${UI.Style.TEXT_NORMAL}`, + ` █ █ ${UI.Style.TEXT_DIM}opencode -s ${session()?.id}${UI.Style.TEXT_NORMAL}`, + ` ▀▀▀▀ `, + ].join("\n"), + ) + }) + useKeyboard((evt) => { if (!session()?.parentID) return if (keybind.match("app_exit", evt)) { diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 05714268545b..f5235dc13e7d 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -9,6 +9,7 @@ import { Log } from "@/util/log" import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network" import type { Event } from "@opencode-ai/sdk/v2" import type { EventSource } from "./context/sdk" +import { ExitMessage } from "./exit-message" declare global { const OPENCODE_WORKER_PATH: string @@ -161,5 +162,7 @@ export const TuiThreadCommand = cmd({ }, 1000) await tuiPromise + const message = ExitMessage.get() + if (message) process.stdout.write(message + "\n") }, }) diff --git a/packages/opencode/test/cli/tui/exit-message.test.ts b/packages/opencode/test/cli/tui/exit-message.test.ts new file mode 100644 index 000000000000..7e158a0d31d7 --- /dev/null +++ b/packages/opencode/test/cli/tui/exit-message.test.ts @@ -0,0 +1,27 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { ExitMessage } from "../../../src/cli/cmd/tui/exit-message" + +afterEach(() => { + ExitMessage.clear() +}) + +describe("exit-message", () => { + test("registers and returns the current message", () => { + ExitMessage.set("See you soon") + expect(ExitMessage.get()).toBe("See you soon") + }) + + test("restores previous message on cleanup", () => { + ExitMessage.set("First") + const undo = ExitMessage.set("Second") + expect(ExitMessage.get()).toBe("Second") + undo() + expect(ExitMessage.get()).toBe("First") + }) + + test("overwrites message with whitespace", () => { + ExitMessage.set("Goodbye") + ExitMessage.set(" ") + expect(ExitMessage.get()).toBe(" ") + }) +}) From 4bd2ef6ac523df9a26f97397b5d2e2e6bb19d21c Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 2 Feb 2026 00:05:12 -0500 Subject: [PATCH 2/3] tui: remove hardcoded exit message to allow contextual goodbye banners --- packages/opencode/src/cli/cmd/tui/app.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 18c5342d82dd..2e1ffa4f008f 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -199,10 +199,6 @@ function App() { const exit = useExit() const promptRef = usePromptRef() - onMount(() => { - exit.message.set("Goodbye") - }) - // Wire up console copy-to-clipboard via opentui's onCopySelection callback renderer.console.onCopySelection = async (text: string) => { if (!text || text.length === 0) return From 8f1086691bd62c239d5357b7c91fc692ea0fc97c Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 2 Feb 2026 00:07:18 -0500 Subject: [PATCH 3/3] tui: keep exit banner tied to TUI shutdown for clean quit output --- .../opencode/src/cli/cmd/tui/context/exit.tsx | 24 ++++++++++++----- .../opencode/src/cli/cmd/tui/exit-message.ts | 19 ------------- .../opencode/src/cli/cmd/tui/routes/home.tsx | 3 --- .../src/cli/cmd/tui/routes/session/index.tsx | 1 - packages/opencode/src/cli/cmd/tui/thread.ts | 3 --- .../test/cli/tui/exit-message.test.ts | 27 ------------------- 6 files changed, 17 insertions(+), 60 deletions(-) delete mode 100644 packages/opencode/src/cli/cmd/tui/exit-message.ts delete mode 100644 packages/opencode/test/cli/tui/exit-message.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/context/exit.tsx b/packages/opencode/src/cli/cmd/tui/context/exit.tsx index 64784a9a339c..2aac152204d8 100644 --- a/packages/opencode/src/cli/cmd/tui/context/exit.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/exit.tsx @@ -1,8 +1,6 @@ import { useRenderer } from "@opentui/solid" import { createSimpleContext } from "./helper" import { FormatError, FormatUnknownError } from "@/cli/error" -import { ExitMessage } from "../exit-message" - type Exit = ((reason?: unknown) => Promise) & { message: { set: (value?: string) => () => void @@ -15,6 +13,20 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({ name: "Exit", init: (input: { onExit?: () => Promise }) => { const renderer = useRenderer() + let message: string | undefined + const store = { + set: (value?: string) => { + const prev = message + message = value + return () => { + message = prev + } + }, + clear: () => { + message = undefined + }, + get: () => message, + } const exit: Exit = Object.assign( async (reason?: unknown) => { // Reset window title before destroying renderer @@ -27,14 +39,12 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({ process.stderr.write(formatted + "\n") } } + const text = store.get() + if (text) process.stdout.write(text + "\n") process.exit(0) }, { - message: { - set: ExitMessage.set, - clear: ExitMessage.clear, - get: ExitMessage.get, - }, + message: store, }, ) return exit diff --git a/packages/opencode/src/cli/cmd/tui/exit-message.ts b/packages/opencode/src/cli/cmd/tui/exit-message.ts deleted file mode 100644 index 8c1cc6e936bd..000000000000 --- a/packages/opencode/src/cli/cmd/tui/exit-message.ts +++ /dev/null @@ -1,19 +0,0 @@ -export namespace ExitMessage { - let message: string | undefined - - export function set(value?: string) { - const prev = message - message = value - return () => { - message = prev - } - } - - export function get() { - return message - } - - export function clear() { - message = undefined - } -} diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index 8db3c2f7e081..59923c69d94c 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -14,7 +14,6 @@ import { usePromptRef } from "../context/prompt" import { Installation } from "@/installation" import { useKV } from "../context/kv" import { useCommandDialog } from "../component/dialog-command" -import { useExit } from "../context/exit" // TODO: what is the best way to do this? let once = false @@ -26,7 +25,6 @@ export function Home() { const route = useRouteData("home") const promptRef = usePromptRef() const command = useCommandDialog() - const exit = useExit() const mcp = createMemo(() => Object.keys(sync.data.mcp).length > 0) const mcpError = createMemo(() => { return Object.values(sync.data.mcp).some((x) => x.status === "failed") @@ -79,7 +77,6 @@ export function Home() { let prompt: PromptRef const args = useArgs() onMount(() => { - // exit.message.clear() if (once) return if (route.initialPrompt) { prompt.set(route.initialPrompt) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index cb8b599d41a3..f7d83b05554a 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -7,7 +7,6 @@ import { For, Match, on, - onMount, Show, Switch, useContext, diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index f5235dc13e7d..05714268545b 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -9,7 +9,6 @@ import { Log } from "@/util/log" import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network" import type { Event } from "@opencode-ai/sdk/v2" import type { EventSource } from "./context/sdk" -import { ExitMessage } from "./exit-message" declare global { const OPENCODE_WORKER_PATH: string @@ -162,7 +161,5 @@ export const TuiThreadCommand = cmd({ }, 1000) await tuiPromise - const message = ExitMessage.get() - if (message) process.stdout.write(message + "\n") }, }) diff --git a/packages/opencode/test/cli/tui/exit-message.test.ts b/packages/opencode/test/cli/tui/exit-message.test.ts deleted file mode 100644 index 7e158a0d31d7..000000000000 --- a/packages/opencode/test/cli/tui/exit-message.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test" -import { ExitMessage } from "../../../src/cli/cmd/tui/exit-message" - -afterEach(() => { - ExitMessage.clear() -}) - -describe("exit-message", () => { - test("registers and returns the current message", () => { - ExitMessage.set("See you soon") - expect(ExitMessage.get()).toBe("See you soon") - }) - - test("restores previous message on cleanup", () => { - ExitMessage.set("First") - const undo = ExitMessage.set("Second") - expect(ExitMessage.get()).toBe("Second") - undo() - expect(ExitMessage.get()).toBe("First") - }) - - test("overwrites message with whitespace", () => { - ExitMessage.set("Goodbye") - ExitMessage.set(" ") - expect(ExitMessage.get()).toBe(" ") - }) -})