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,7 +20,6 @@ export const BANNER_MINIMAL = [
"╰───────────────────────────────────────╯",
] as const;
// Block-style banner (similar to opencode)
export const BANNER_BLOCKS = [
"█▀▀ █▀█ █▀▄ █▀▀ ▀█▀ █▄█ █▀█ █▀▀ █▀█",
"█ █ █ █ █ █▀▀ █ █ █▀▀ █▀▀ █▀▄",

View File

@@ -264,7 +264,6 @@ export const setWorkingDirectory = async (dir: string): Promise<void> => {
/**
* Create a subagent session (child of a parent session)
* Used by task_agent for proper session-based isolation like opencode
*/
export const createSubagentSession = async (
config: SubagentSessionConfig,
@@ -283,8 +282,9 @@ export const createSubagentSession = async (
};
// Set working directory
(session as SubagentChatSession & { workingDirectory?: string }).workingDirectory =
config.workingDirectory;
(
session as SubagentChatSession & { workingDirectory?: string }
).workingDirectory = config.workingDirectory;
// Save but don't set as current (subagents run independently)
await saveSession(session);

View File

@@ -2,7 +2,6 @@
* Task Agent Tool
*
* Allows spawning specialized sub-agents for complex tasks.
* Implements the agent delegation pattern from claude-code and opencode.
* Supports parallel execution of up to 3 agents simultaneously.
*/
@@ -63,9 +62,7 @@ const taskAgentSchema = z.object({
.enum(["explore", "implement", "test", "review", "refactor", "plan"])
.describe("The type of specialized agent to spawn"),
task: z
.string()
.describe("The task for the agent to perform"),
task: z.string().describe("The task for the agent to perform"),
context_files: z
.array(z.string())
@@ -124,13 +121,21 @@ const agentQueue: QueuedAgent[] = [];
* Process the agent queue
*/
const processQueue = async (): Promise<void> => {
while (agentQueue.length > 0 && runningForegroundAgents < MAX_CONCURRENT_AGENTS) {
while (
agentQueue.length > 0 &&
runningForegroundAgents < MAX_CONCURRENT_AGENTS
) {
const queued = agentQueue.shift();
if (!queued) break;
runningForegroundAgents++;
executeAgentInternal(queued.params, queued.systemPrompt, queued.taskPrompt, queued.ctx)
executeAgentInternal(
queued.params,
queued.systemPrompt,
queued.taskPrompt,
queued.ctx,
)
.then(queued.resolve)
.catch(queued.reject)
.finally(() => {
@@ -167,7 +172,7 @@ export const executeTaskAgent = async (
*/
const buildAgentSystemPrompt = (
agentType: AgentType,
config: typeof AGENT_TYPES[AgentType],
config: (typeof AGENT_TYPES)[AgentType],
): string => {
const prompts: Record<AgentType, string> = {
explore: `You are an EXPLORATION agent. Your task is to quickly understand code.
@@ -267,7 +272,9 @@ const buildTaskPrompt = (params: TaskAgentParams): string => {
const parts = [`## Task\n${params.task}`];
if (params.context_files?.length) {
parts.push(`\n## Context Files\n${params.context_files.map(f => `- ${f}`).join("\n")}`);
parts.push(
`\n## Context Files\n${params.context_files.map((f) => `- ${f}`).join("\n")}`,
);
}
return parts.join("\n");
@@ -429,7 +436,7 @@ export const getBackgroundAgentStatus = async (
// Check if completed (with short timeout)
const result = await Promise.race([
agent.promise.then(r => ({ completed: true as const, result: r })),
agent.promise.then((r) => ({ completed: true as const, result: r })),
new Promise<{ completed: false }>((resolve) =>
setTimeout(() => resolve({ completed: false }), 100),
),

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,
};
},
});