fix: ensure exit message persists by setting it reactively (OpenCode pattern)

The exit message was appearing briefly then disappearing because:
1. The message wasn't pre-computed before exit
2. The timing of when the message was written wasn't synchronized

Changes (matching OpenCode's pattern):
- ExitProvider now has setExitMessage/getExitMessage functions
- ExitProvider.exit() is now async and awaits onExit callback
- Message is written AFTER renderer.destroy() and onExit, before process.exit()
- AppContent uses createEffect to set exit message reactively
- Message is pre-computed and ready when exit is called

This ensures the message is written synchronously after cleanup,
preventing it from being cleared by async operations or shell prompt.
This commit is contained in:
2026-02-15 13:37:15 -05:00
parent 1854c38dfb
commit 6c72ff0212
5 changed files with 65 additions and 33 deletions

View File

@@ -20,11 +20,13 @@ import { matchesAction } from "@services/keybind-resolver";
import { copyToClipboard } from "@services/clipboard/text-clipboard";
import versionData from "@/version.json";
import { ExitProvider, useExit } from "@tui-solid/context/exit";
import { generateSessionSummary } from "@utils/core/session-stats";
import { RouteProvider, useRoute } from "@tui-solid/context/route";
import {
AppStoreProvider,
useAppStore,
setAppStoreRef,
appStore,
} from "@tui-solid/context/app";
import { ThemeProvider, useTheme } from "@tui-solid/context/theme";
import { KeybindProvider } from "@tui-solid/context/keybind";
@@ -116,6 +118,20 @@ function AppContent(props: AppProps) {
setAppStoreRef(app);
// Set exit message reactively (like OpenCode does)
// This ensures the message is pre-computed and ready when exit is called
createEffect(() => {
const state = appStore.getState();
const summary = generateSessionSummary({
sessionId: props.sessionId ?? "unknown",
sessionStats: state.sessionStats,
modifiedFiles: state.modifiedFiles,
modelName: state.model,
providerName: state.provider,
});
exit.setExitMessage(summary);
});
/** Copy selected text to clipboard and clear selection */
const copySelectionToClipboard = async (): Promise<void> => {
const text = renderer.getSelection()?.getSelectedText();
@@ -518,7 +534,6 @@ function App(props: AppProps) {
return (
<ErrorBoundary fallback={(err: Error) => <ErrorFallback error={err} />}>
<ExitProvider
sessionId={props.sessionId}
onExit={() => props.onExit({ exitCode: 0, sessionId: props.sessionId })}
>
<RouteProvider>

View File

@@ -1,12 +1,9 @@
import { createSignal, onCleanup } from "solid-js";
import { useRenderer } from "@opentui/solid";
import { createSimpleContext } from "./helper";
import { generateSessionSummary } from "@utils/core/session-stats";
import { appStore } from "./app";
interface ExitContextInput extends Record<string, unknown> {
sessionId?: string;
onExit?: () => void;
onExit?: () => void | Promise<void>;
}
interface ExitContextValue {
@@ -16,6 +13,8 @@ interface ExitContextValue {
requestExit: () => void;
cancelExit: () => void;
confirmExit: () => void;
setExitMessage: (message: string | undefined) => void;
getExitMessage: () => string | undefined;
}
export const { provider: ExitProvider, use: useExit } = createSimpleContext<
@@ -28,34 +27,43 @@ export const { provider: ExitProvider, use: useExit } = createSimpleContext<
const [exitCode, setExitCode] = createSignal(0);
const [isExiting, setIsExiting] = createSignal(false);
const [exitRequested, setExitRequested] = createSignal(false);
const [exitMessage, setExitMessageState] = createSignal<string | undefined>(undefined);
const exit = (code = 0): void => {
const exit = async (code = 0): Promise<void> => {
setExitCode(code);
setIsExiting(true);
// Generate and store exit message before destroying renderer
const state = appStore.getState();
const summary = generateSessionSummary({
sessionId: props.sessionId ?? "unknown",
sessionStats: state.sessionStats,
modifiedFiles: state.modifiedFiles,
modelName: state.model,
providerName: state.provider,
});
// Reset window title before destroying renderer
try {
renderer.setTerminalTitle("");
} catch {
// Ignore
}
// Destroy renderer first to stop rendering
// Destroy renderer to stop rendering and exit alternate screen
renderer.destroy();
// Call the onExit callback
props.onExit?.();
// Call the onExit callback (may be async)
await props.onExit?.();
// Write the summary after renderer is destroyed
process.stdout.write(summary + "\n");
// Write the stored exit message after renderer is destroyed
const message = exitMessage();
if (message) {
process.stdout.write(message + "\n");
}
// Exit the process
process.exit(code);
};
const setExitMessage = (message: string | undefined): void => {
setExitMessageState(message);
};
const getExitMessage = (): string | undefined => {
return exitMessage();
};
const requestExit = (): void => {
setExitRequested(true);
};
@@ -73,6 +81,7 @@ export const { provider: ExitProvider, use: useExit } = createSimpleContext<
onCleanup(() => {
setIsExiting(false);
setExitRequested(false);
setExitMessageState(undefined);
});
return {
@@ -82,6 +91,8 @@ export const { provider: ExitProvider, use: useExit } = createSimpleContext<
requestExit,
cancelExit,
confirmExit,
setExitMessage,
getExitMessage,
};
},
});