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