fix: move exit summary to ExitProvider and destroy renderer before display

The session summary wasn't appearing and 997;1n garbage was showing because:
1. The renderer was still running when we wrote the summary, overwriting it
2. The renderer continued sending DECRQM queries after exit

Changes:
- Move summary generation and display into ExitProvider
- Call renderer.destroy() BEFORE writing the summary
- ExitProvider now handles process.exit() after displaying summary
- Simplify tui() handleExit to just resolve the promise
- ExitProvider receives sessionId prop for summary generation
This commit is contained in:
2026-02-15 13:31:46 -05:00
parent b6b89996f3
commit 1854c38dfb
2 changed files with 30 additions and 47 deletions

View File

@@ -17,8 +17,6 @@ import {
getExecutionState,
} from "@services/chat-tui-service";
import { matchesAction } from "@services/keybind-resolver";
import { TERMINAL_RESET } from "@constants/terminal";
import { generateSessionSummary } from "@utils/core/session-stats";
import { copyToClipboard } from "@services/clipboard/text-clipboard";
import versionData from "@/version.json";
import { ExitProvider, useExit } from "@tui-solid/context/exit";
@@ -27,7 +25,6 @@ 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";
@@ -521,6 +518,7 @@ 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>
@@ -575,51 +573,10 @@ export interface TuiRenderOptions extends TuiInput {
export function tui(options: TuiRenderOptions): Promise<TuiOutput> {
return new Promise<TuiOutput>((resolve) => {
const { writeSync } = require("fs");
const handleExit = (output: TuiOutput): void => {
try {
// First, drain any pending terminal responses (e.g. DECRQM 997;1n)
// while still in raw mode, before exiting alternate screen
const drainTimeout = 50; // 50ms is enough for terminal responses
const finishExit = (): void => {
writeSync(1, TERMINAL_RESET);
const state = appStore.getState();
const summary = generateSessionSummary({
sessionId: output.sessionId ?? "unknown",
sessionStats: state.sessionStats,
modifiedFiles: state.modifiedFiles,
modelName: state.model,
providerName: state.provider,
});
writeSync(1, summary + "\n");
resolve(output);
};
// If stdin is TTY and in raw mode, try to drain pending data
if (process.stdin.isTTY && process.stdin.isRaw) {
const sink = (): void => {};
process.stdin.on("data", sink);
const cleanup = (): void => {
process.stdin.removeListener("data", sink);
// Read and discard any buffered data
while (process.stdin.read() !== null) {
// drain
}
finishExit();
};
setTimeout(cleanup, drainTimeout);
} else {
finishExit();
}
} catch {
// Ignore - stdout may already be closed
resolve(output);
}
// ExitProvider handles the summary display and process exit
// We just resolve the promise here for cleanup
resolve(output);
};
render(() => <App {...options} onExit={handleExit} />, {

View File

@@ -1,7 +1,11 @@
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;
}
@@ -20,6 +24,7 @@ export const { provider: ExitProvider, use: useExit } = createSimpleContext<
>({
name: "Exit",
init: (props) => {
const renderer = useRenderer();
const [exitCode, setExitCode] = createSignal(0);
const [isExiting, setIsExiting] = createSignal(false);
const [exitRequested, setExitRequested] = createSignal(false);
@@ -27,7 +32,28 @@ export const { provider: ExitProvider, use: useExit } = createSimpleContext<
const exit = (code = 0): 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,
});
// Destroy renderer first to stop rendering
renderer.destroy();
// Call the onExit callback
props.onExit?.();
// Write the summary after renderer is destroyed
process.stdout.write(summary + "\n");
// Exit the process
process.exit(code);
};
const requestExit = (): void => {