diff --git a/.gitignore b/.gitignore index c32901afa..ba563038b 100644 --- a/.gitignore +++ b/.gitignore @@ -90,6 +90,11 @@ CODE_CHANGES.md README_COMPACT_HERE.md artifacts/ tests/e2e/tmp/ + +# Test temporary directories +src/test-temp-*/ +tests/**/test-temp-*/ + runs/ # Python diff --git a/src/components/AIView.tsx b/src/components/AIView.tsx index e4e94dbb7..18fecf8d6 100644 --- a/src/components/AIView.tsx +++ b/src/components/AIView.tsx @@ -345,6 +345,8 @@ const AIViewInner: React.FC = ({ chatInputAPI, jumpToBottom, handleOpenTerminal, + aggregator, + setEditingMessage, }); // Clear editing state if the message being edited no longer exists @@ -523,7 +525,11 @@ const AIViewInner: React.FC = ({ ? `${getModelName(currentModel)} streaming...` : "streaming..." } - cancelText={`hit ${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} to cancel`} + cancelText={ + isCompacting + ? `${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} cancel | ${formatKeybind(KEYBINDS.ACCEPT_EARLY_COMPACTION)} accept early` + : `hit ${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} to cancel` + } tokenCount={ activeStreamMessageId ? aggregator.getStreamingTokenCount(activeStreamMessageId) diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index a5f0523d7..22e57141d 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -120,6 +120,7 @@ const ModelDisplayWrapper = styled.div` export interface ChatInputAPI { focus: () => void; + restoreText: (text: string) => void; } export interface ChatInputProps { @@ -430,12 +431,24 @@ export const ChatInput: React.FC = ({ }); }, []); + // Method to restore text to input (used by compaction cancel) + const restoreText = useCallback( + (text: string) => { + setInput(text); + focusMessageInput(); + }, + [focusMessageInput] + ); + // Provide API to parent via callback useEffect(() => { if (onReady) { - onReady({ focus: focusMessageInput }); + onReady({ + focus: focusMessageInput, + restoreText, + }); } - }, [onReady, focusMessageInput]); + }, [onReady, focusMessageInput, restoreText]); useEffect(() => { const handleGlobalKeyDown = (event: KeyboardEvent) => { @@ -948,7 +961,7 @@ export const ChatInput: React.FC = ({ return `Edit your message... (${formatKeybind(KEYBINDS.CANCEL_EDIT)} to cancel, ${formatKeybind(KEYBINDS.SEND_MESSAGE)} to send)`; } if (isCompacting) { - return `Compacting... (${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} to cancel)`; + return `Compacting... (${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} cancel | ${formatKeybind(KEYBINDS.ACCEPT_EARLY_COMPACTION)} accept early)`; } // Build hints for normal input diff --git a/src/components/Messages/AssistantMessage.tsx b/src/components/Messages/AssistantMessage.tsx index 7a99e86ae..e0c9071d0 100644 --- a/src/components/Messages/AssistantMessage.tsx +++ b/src/components/Messages/AssistantMessage.tsx @@ -8,6 +8,8 @@ import { MessageWindow } from "./MessageWindow"; import { useStartHere } from "@/hooks/useStartHere"; import { COMPACTED_EMOJI } from "@/constants/ui"; import { ModelDisplay } from "./ModelDisplay"; +import { CompactingMessageContent } from "./CompactingMessageContent"; +import { CompactionBackground } from "./CompactionBackground"; const RawContent = styled.pre` font-family: var(--font-monospace); @@ -49,6 +51,7 @@ interface AssistantMessageProps { message: DisplayedMessage & { type: "assistant" }; className?: string; workspaceId?: string; + isCompacting?: boolean; clipboardWriteText?: (data: string) => Promise; } @@ -56,6 +59,7 @@ export const AssistantMessage: React.FC = ({ message, className, workspaceId, + isCompacting = false, clipboardWriteText = (data: string) => navigator.clipboard.writeText(data), }) => { const [showRaw, setShowRaw] = useState(false); @@ -64,6 +68,7 @@ export const AssistantMessage: React.FC = ({ const content = message.content; const isStreaming = message.isStreaming; const isCompacted = message.isCompacted; + const isStreamingCompaction = isStreaming && isCompacting; // Use Start Here hook for final assistant messages const { @@ -120,7 +125,14 @@ export const AssistantMessage: React.FC = ({ // Streaming text gets typewriter effect if (isStreaming) { - return ; + const contentElement = ; + + // Wrap streaming compaction in special container + if (isStreamingCompaction) { + return {contentElement}; + } + + return contentElement; } // Completed text renders as static content @@ -154,6 +166,7 @@ export const AssistantMessage: React.FC = ({ message={message} buttons={buttons} className={className} + backgroundEffect={isStreamingCompaction ? : undefined} > {renderContent()} diff --git a/src/components/Messages/CompactingMessageContent.tsx b/src/components/Messages/CompactingMessageContent.tsx new file mode 100644 index 000000000..5e349fb69 --- /dev/null +++ b/src/components/Messages/CompactingMessageContent.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import styled from "@emotion/styled"; + +/** + * Wrapper for compaction streaming content + * Provides max-height constraint with fade effect to imply content above + * No scrolling - content stays anchored to bottom, older content fades at top + */ + +const Container = styled.div` + max-height: 300px; + overflow: hidden; + position: relative; + display: flex; + flex-direction: column; + justify-content: flex-end; /* Anchor content to bottom */ + + /* Fade effect: content fades progressively from top to bottom */ + mask-image: linear-gradient( + to bottom, + transparent 0%, + rgba(0, 0, 0, 0.3) 5%, + rgba(0, 0, 0, 0.6) 10%, + rgba(0, 0, 0, 0.85) 15%, + black 20% + ); + -webkit-mask-image: linear-gradient( + to bottom, + transparent 0%, + rgba(0, 0, 0, 0.3) 5%, + rgba(0, 0, 0, 0.6) 10%, + rgba(0, 0, 0, 0.85) 15%, + black 20% + ); +`; + +interface CompactingMessageContentProps { + children: React.ReactNode; +} + +export const CompactingMessageContent: React.FC = ({ children }) => { + return {children}; +}; diff --git a/src/components/Messages/CompactionBackground.tsx b/src/components/Messages/CompactionBackground.tsx new file mode 100644 index 000000000..65b006032 --- /dev/null +++ b/src/components/Messages/CompactionBackground.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import styled from "@emotion/styled"; +import { keyframes } from "@emotion/react"; + +/** + * Animated background for compaction streaming + * Shimmer effect with moving gradient and particles for dynamic appearance + */ + +const shimmer = keyframes` + 0% { + background-position: -1000px 0; + } + 100% { + background-position: 1000px 0; + } +`; + +const gradientMove = keyframes` + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } +`; + +const Container = styled.div` + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow: hidden; + pointer-events: none; + border-radius: 6px; +`; + +const AnimatedGradient = styled.div` + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + -45deg, + var(--color-plan-mode-alpha), + color-mix(in srgb, var(--color-plan-mode) 30%, transparent), + var(--color-plan-mode-alpha), + color-mix(in srgb, var(--color-plan-mode) 25%, transparent) + ); + background-size: 400% 400%; + animation: ${gradientMove} 8s ease infinite; + opacity: 0.4; +`; + +const ShimmerLayer = styled.div` + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + 90deg, + transparent 0%, + transparent 40%, + var(--color-plan-mode-alpha) 50%, + transparent 60%, + transparent 100% + ); + background-size: 1000px 100%; + animation: ${shimmer} 3s infinite linear; +`; + +export const CompactionBackground: React.FC = () => { + return ( + + + + + ); +}; diff --git a/src/components/Messages/MessageRenderer.tsx b/src/components/Messages/MessageRenderer.tsx index 6f8f9f2ca..e8824e750 100644 --- a/src/components/Messages/MessageRenderer.tsx +++ b/src/components/Messages/MessageRenderer.tsx @@ -31,7 +31,12 @@ export const MessageRenderer = React.memo( ); case "assistant": return ( - + ); case "tool": return ; diff --git a/src/components/Messages/MessageWindow.tsx b/src/components/Messages/MessageWindow.tsx index 1e4edf2de..b2e30acf5 100644 --- a/src/components/Messages/MessageWindow.tsx +++ b/src/components/Messages/MessageWindow.tsx @@ -7,6 +7,7 @@ import { formatTimestamp } from "@/utils/ui/dateTime"; import { TooltipWrapper, Tooltip } from "../Tooltip"; const MessageBlock = styled.div<{ borderColor: string; backgroundColor?: string }>` + position: relative; margin-bottom: 15px; margin-top: 15px; background: ${(props) => props.backgroundColor ?? "#1e1e1e"}; @@ -16,6 +17,8 @@ const MessageBlock = styled.div<{ borderColor: string; backgroundColor?: string `; const MessageHeader = styled.div` + position: relative; + z-index: 1; padding: 8px 12px; background: rgba(255, 255, 255, 0.05); border-bottom: 1px solid rgba(255, 255, 255, 0.1); @@ -51,6 +54,8 @@ const ButtonGroup = styled.div` `; const MessageContent = styled.div` + position: relative; + z-index: 1; padding: 12px; `; @@ -85,6 +90,7 @@ interface MessageWindowProps { children: ReactNode; className?: string; rightLabel?: ReactNode; + backgroundEffect?: ReactNode; // Optional background effect (e.g., animation) } export const MessageWindow: React.FC = ({ @@ -96,6 +102,7 @@ export const MessageWindow: React.FC = ({ children, className, rightLabel, + backgroundEffect, }) => { const [showJson, setShowJson] = useState(false); @@ -111,6 +118,7 @@ export const MessageWindow: React.FC = ({ return ( + {backgroundEffect} {label} diff --git a/src/components/Messages/UserMessage.tsx b/src/components/Messages/UserMessage.tsx index c23739987..1307d4597 100644 --- a/src/components/Messages/UserMessage.tsx +++ b/src/components/Messages/UserMessage.tsx @@ -102,7 +102,7 @@ export const UserMessage: React.FC = ({ onClick: handleEdit, disabled: isCompacting, tooltip: isCompacting - ? `Cannot edit while compacting (press ${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} to cancel)` + ? `Cannot edit while compacting (${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} to cancel)` : undefined, }, ] diff --git a/src/constants/storage.ts b/src/constants/storage.ts index 504fd2eeb..537fede58 100644 --- a/src/constants/storage.ts +++ b/src/constants/storage.ts @@ -47,6 +47,14 @@ export function getLastThinkingByModelKey(modelName: string): string { return `lastThinkingByModel:${modelName}`; } +/** + * Get storage key for cancelled compaction tracking. + * Stores compaction-request user message ID to verify freshness across reloads. + */ +export function getCancelledCompactionKey(workspaceId: string): string { + return `workspace:${workspaceId}:cancelled-compaction`; +} + /** * Get the localStorage key for the UI mode for a workspace * Format: "mode:{workspaceId}" diff --git a/src/hooks/useAIViewKeybinds.ts b/src/hooks/useAIViewKeybinds.ts index 5dce224d1..8dced943a 100644 --- a/src/hooks/useAIViewKeybinds.ts +++ b/src/hooks/useAIViewKeybinds.ts @@ -7,6 +7,8 @@ import type { ThinkingLevel, ThinkingLevelOn } from "@/types/thinking"; import { DEFAULT_THINKING_LEVEL } from "@/types/thinking"; import { getThinkingPolicyForModel } from "@/utils/thinking/policy"; import { getDefaultModelFromLRU } from "@/hooks/useModelLRU"; +import type { StreamingMessageAggregator } from "@/utils/messages/StreamingMessageAggregator"; +import { isCompactingStream, cancelCompaction } from "@/utils/compaction/handler"; interface UseAIViewKeybindsParams { workspaceId: string; @@ -19,6 +21,8 @@ interface UseAIViewKeybindsParams { chatInputAPI: React.RefObject; jumpToBottom: () => void; handleOpenTerminal: () => void; + aggregator: StreamingMessageAggregator; // For compaction detection + setEditingMessage: (editing: { id: string; content: string } | undefined) => void; } /** @@ -28,6 +32,8 @@ interface UseAIViewKeybindsParams { * - Ctrl+Shift+T: Toggle thinking level * - Ctrl+G: Jump to bottom * - Ctrl+T: Open terminal + * - Ctrl+C (during compaction): Cancel compaction, restore command (uses localStorage) + * - Ctrl+A (during compaction): Accept early with [truncated] */ export function useAIViewKeybinds({ workspaceId, @@ -40,13 +46,27 @@ export function useAIViewKeybinds({ chatInputAPI, jumpToBottom, handleOpenTerminal, + aggregator, + setEditingMessage, }: UseAIViewKeybindsParams): void { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - // Interrupt stream works anywhere (even in input fields) + // Ctrl+C during compaction: cancel and restore command to input + // (different from Ctrl+A which accepts early with [truncated]) if (matchesKeybind(e, KEYBINDS.INTERRUPT_STREAM)) { e.preventDefault(); - // If there's a stream or auto-retry in progress, stop it and disable auto-retry + + if (canInterrupt && isCompactingStream(aggregator)) { + // Ctrl+C during compaction: restore original state and enter edit mode + // Stores cancellation marker in localStorage (persists across reloads) + void cancelCompaction(workspaceId, aggregator, (messageId, command) => { + setEditingMessage({ id: messageId, content: command }); + }); + setAutoRetry(false); + return; + } + + // Normal stream interrupt (non-compaction) if (canInterrupt || showRetryBarrier) { setAutoRetry(false); // User explicitly stopped - don't auto-retry void window.api.workspace.interruptStream(workspaceId); @@ -54,6 +74,20 @@ export function useAIViewKeybinds({ return; } + // Ctrl+A during compaction: accept early with [truncated] sentinel + // (different from Ctrl+C which cancels and restores original state) + if (matchesKeybind(e, KEYBINDS.ACCEPT_EARLY_COMPACTION)) { + e.preventDefault(); + + if (canInterrupt && isCompactingStream(aggregator)) { + // Ctrl+A during compaction: perform compaction with partial summary + // No flag set - handleCompactionAbort will perform compaction with [truncated] + setAutoRetry(false); + void window.api.workspace.interruptStream(workspaceId); + } + return; + } + // Focus chat input works anywhere (even in input fields) if (matchesKeybind(e, KEYBINDS.FOCUS_CHAT)) { e.preventDefault(); @@ -125,5 +159,7 @@ export function useAIViewKeybinds({ currentWorkspaceThinking, setThinkingLevel, chatInputAPI, + aggregator, + setEditingMessage, ]); } diff --git a/src/preload.ts b/src/preload.ts index 632d7394e..dd4513a91 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -61,8 +61,8 @@ const api: IPCApi = { ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_SEND_MESSAGE, workspaceId, message, options), resumeStream: (workspaceId, options) => ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_RESUME_STREAM, workspaceId, options), - interruptStream: (workspaceId) => - ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, workspaceId), + interruptStream: (workspaceId: string, options?: { abandonPartial?: boolean }) => + ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, workspaceId, options), truncateHistory: (workspaceId, percentage) => ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_TRUNCATE_HISTORY, workspaceId, percentage), replaceChatHistory: (workspaceId, summaryMessage) => diff --git a/src/services/aiService.ts b/src/services/aiService.ts index 6febb7d5d..029f81a81 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -138,12 +138,17 @@ export class AIService extends EventEmitter { this.streamManager.on("stream-end", (data) => this.emit("stream-end", data)); // Handle stream-abort: commit partial to history before forwarding + // Note: If abandonPartial option was used, partial is already deleted by IPC handler this.streamManager.on("stream-abort", (data: StreamAbortEvent) => { void (async () => { - // Commit interrupted message to history with partial:true metadata - // This ensures /clear and /truncate can clean up interrupted messages - await this.partialService.commitToHistory(data.workspaceId); - await this.partialService.deletePartial(data.workspaceId); + // Check if partial still exists (not abandoned) + const partial = await this.partialService.readPartial(data.workspaceId); + if (partial) { + // Commit interrupted message to history with partial:true metadata + // This ensures /clear and /truncate can clean up interrupted messages + await this.partialService.commitToHistory(data.workspaceId); + await this.partialService.deletePartial(data.workspaceId); + } // Forward abort event to consumers this.emit("stream-abort", data); diff --git a/src/services/ipcMain.ts b/src/services/ipcMain.ts index 01fd1b4c1..293fe42ef 100644 --- a/src/services/ipcMain.ts +++ b/src/services/ipcMain.ts @@ -588,22 +588,32 @@ export class IpcMain { } ); - ipcMain.handle(IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, async (_event, workspaceId: string) => { - log.debug("interruptStream handler: Received", { workspaceId }); - try { - const session = this.getOrCreateSession(workspaceId); - const stopResult = await session.interruptStream(); - if (!stopResult.success) { - log.error("Failed to stop stream:", stopResult.error); - return { success: false, error: stopResult.error }; + ipcMain.handle( + IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, + async (_event, workspaceId: string, options?: { abandonPartial?: boolean }) => { + log.debug("interruptStream handler: Received", { workspaceId, options }); + try { + const session = this.getOrCreateSession(workspaceId); + const stopResult = await session.interruptStream(); + if (!stopResult.success) { + log.error("Failed to stop stream:", stopResult.error); + return { success: false, error: stopResult.error }; + } + + // If abandonPartial is true, delete the partial instead of committing it + if (options?.abandonPartial) { + log.debug("Abandoning partial for workspace:", workspaceId); + await this.partialService.deletePartial(workspaceId); + } + + return { success: true, data: undefined }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log.error("Unexpected error in interruptStream handler:", error); + return { success: false, error: `Failed to interrupt stream: ${errorMessage}` }; } - return { success: true, data: undefined }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log.error("Unexpected error in interruptStream handler:", error); - return { success: false, error: `Failed to interrupt stream: ${errorMessage}` }; } - }); + ); ipcMain.handle( IPC_CHANNELS.WORKSPACE_TRUNCATE_HISTORY, diff --git a/src/services/mock/scenarios/slashCommands.ts b/src/services/mock/scenarios/slashCommands.ts index 59d82c726..3dd6ca4ac 100644 --- a/src/services/mock/scenarios/slashCommands.ts +++ b/src/services/mock/scenarios/slashCommands.ts @@ -29,36 +29,11 @@ const compactConversationTurn: ScenarioTurn = { { kind: "stream-delta", delay: STREAM_BASE_DELAY, - text: "Preparing a compact technical summary based on prior tool results…\n", - }, - { - kind: "tool-start", - delay: STREAM_BASE_DELAY * 2, - toolCallId: "tool-compact-summary", - toolName: "compact_summary", - args: { - targetWords: 385, - }, - }, - { - kind: "tool-end", - delay: STREAM_BASE_DELAY * 3, - toolCallId: "tool-compact-summary", - toolName: "compact_summary", - result: { - success: true, - summary: COMPACT_SUMMARY_TEXT, - message: "Summary generated successfully.", - }, - }, - { - kind: "stream-delta", - delay: STREAM_BASE_DELAY * 3 + 100, - text: "Summary ready. Replacing history with the compacted version now.", + text: COMPACT_SUMMARY_TEXT, }, { kind: "stream-end", - delay: STREAM_BASE_DELAY * 4, + delay: STREAM_BASE_DELAY * 2, metadata: { model: "anthropic:claude-sonnet-4-5", inputTokens: 220, @@ -68,19 +43,7 @@ const compactConversationTurn: ScenarioTurn = { parts: [ { type: "text", - text: "Summary ready. Replacing history with the compacted version now.", - }, - { - type: "dynamic-tool", - toolCallId: "tool-compact-summary", - toolName: "compact_summary", - state: "output-available", - input: { - targetWords: 385, - }, - output: { - summary: COMPACT_SUMMARY_TEXT, - }, + text: COMPACT_SUMMARY_TEXT, }, ], }, diff --git a/src/services/tools/compact_summary.ts b/src/services/tools/compact_summary.ts deleted file mode 100644 index e75260794..000000000 --- a/src/services/tools/compact_summary.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { tool } from "ai"; -import type { ToolFactory } from "@/utils/tools/tools"; -import { TOOL_DEFINITIONS } from "@/utils/tools/toolDefinitions"; - -/** - * Compact summary tool factory for context compaction - * Creates a tool that allows the AI to provide a conversation summary - * @param config Required configuration (not used for this tool, but required by interface) - */ -export const createCompactSummaryTool: ToolFactory = () => { - return tool({ - description: TOOL_DEFINITIONS.compact_summary.description, - inputSchema: TOOL_DEFINITIONS.compact_summary.schema, - execute: ({ summary }) => { - // Tool execution is a no-op on the backend - // The summary is intercepted by the frontend and used to replace history - return Promise.resolve({ - success: true, - summary, - message: "Summary generated successfully.", - }); - }, - }); -}; diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index f48171575..be1f0218f 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -28,6 +28,8 @@ import { WorkspaceConsumerManager } from "./WorkspaceConsumerManager"; import type { ChatUsageDisplay } from "@/utils/tokens/usageAggregator"; import type { TokenConsumer } from "@/types/chatStats"; import type { LanguageModelV2Usage } from "@ai-sdk/provider"; +import { getCancelledCompactionKey } from "@/constants/storage"; +import { isCompactingStream, findCompactionRequestMessage } from "@/utils/compaction/handler"; export interface WorkspaceState { messages: DisplayedMessage[]; @@ -415,30 +417,97 @@ export class WorkspaceStore { * Handle compact_summary tool completion. * Returns true if compaction was handled (caller should early return). */ - private handleCompactSummaryCompletion( + private handleCompactionCompletion( workspaceId: string, aggregator: StreamingMessageAggregator, data: WorkspaceChatMessage ): boolean { - // Type guard: only StreamEndEvent has parts - if (!("parts" in data) || !data.parts) return false; - - for (const part of data.parts) { - if (part.type === "dynamic-tool" && part.toolName === "compact_summary") { - const output = part.output as { summary?: string } | undefined; - if (output?.summary) { - this.performCompaction(workspaceId, aggregator, data, output.summary); - return true; + // Type guard: only StreamEndEvent has messageId + if (!("messageId" in data)) return false; + + // Check if this was a compaction stream + if (!isCompactingStream(aggregator)) { + return false; + } + + // Extract the summary text from the assistant's response + const summary = aggregator.getCompactionSummary(data.messageId); + if (!summary) { + console.warn("[WorkspaceStore] Compaction completed but no summary text found"); + return false; + } + + this.performCompaction(workspaceId, aggregator, data, summary); + return true; + } + + /** + * Handle interruption of a compaction stream (StreamAbortEvent). + * + * Two distinct flows trigger this: + * - **Ctrl+A (accept early)**: Perform compaction with [truncated] sentinel + * - **Ctrl+C (cancel)**: Skip compaction, let cancelCompaction handle cleanup + * + * Uses localStorage to distinguish flows: + * - Checks for cancellation marker in localStorage + * - Verifies messageId matches for freshness + * - Reload-safe: localStorage persists across page reloads + */ + private handleCompactionAbort( + workspaceId: string, + aggregator: StreamingMessageAggregator, + data: WorkspaceChatMessage + ): boolean { + // Type guard: only StreamAbortEvent has messageId + if (!("messageId" in data)) return false; + + // Check if this was a compaction stream + if (!isCompactingStream(aggregator)) { + return false; + } + + // Get the compaction request message for ID verification + const compactionRequestMsg = findCompactionRequestMessage(aggregator); + if (!compactionRequestMsg) { + return false; + } + + // Ctrl+C flow: Check localStorage for cancellation marker + // Verify compaction-request user message ID matches (stable across retries) + const storageKey = getCancelledCompactionKey(workspaceId); + const cancelData = localStorage.getItem(storageKey); + if (cancelData) { + try { + const parsed = JSON.parse(cancelData) as { compactionRequestId: string; timestamp: number }; + if (parsed.compactionRequestId === compactionRequestMsg.id) { + // This is a cancelled compaction - clean up marker and skip compaction + localStorage.removeItem(storageKey); + return false; // Skip compaction, cancelCompaction() handles cleanup } - break; + } catch (error) { + console.error("[WorkspaceStore] Failed to parse cancellation data:", error); } + // If compactionRequestId doesn't match or parse failed, clean up stale data + localStorage.removeItem(storageKey); } - return false; + + // Ctrl+A flow: Accept early with [truncated] sentinel + const partialSummary = aggregator.getCompactionSummary(data.messageId); + if (!partialSummary) { + console.warn("[WorkspaceStore] Compaction aborted but no partial summary found"); + return false; + } + + // Append [truncated] sentinel on new line to indicate incomplete summary + const truncatedSummary = partialSummary.trim() + "\n\n[truncated]"; + + this.performCompaction(workspaceId, aggregator, data, truncatedSummary); + return true; } /** * Perform history compaction by replacing chat history with summary message. - * Type-safe: only called when we've verified data has parts (i.e., StreamEndEvent). + * Type-safe: only called when we've verified data is a StreamEndEvent. */ private performCompaction( workspaceId: string, @@ -446,15 +515,11 @@ export class WorkspaceStore { data: WorkspaceChatMessage, summary: string ): void { - // We know data is StreamEndEvent because handleCompactSummaryCompletion verified it has parts // Extract metadata safely with type guard const metadata = "metadata" in data ? data.metadata : undefined; // Extract continueMessage from compaction-request before history gets replaced - const messages = aggregator.getAllMessages(); - const compactRequestMsg = [...messages] - .reverse() - .find((m) => m.role === "user" && m.metadata?.cmuxMetadata?.type === "compaction-request"); + const compactRequestMsg = findCompactionRequestMessage(aggregator); const cmuxMeta = compactRequestMsg?.metadata?.cmuxMetadata; const continueMessage = cmuxMeta?.type === "compaction-request" ? cmuxMeta.parsed.continueMessage : undefined; @@ -781,8 +846,8 @@ export class WorkspaceStore { aggregator.handleStreamEnd(data); aggregator.clearTokenState(data.messageId); - // Early return if compact_summary handled (async replacement in progress) - if (this.handleCompactSummaryCompletion(workspaceId, aggregator, data)) { + // Early return if compaction handled (async replacement in progress) + if (this.handleCompactionCompletion(workspaceId, aggregator, data)) { return; } @@ -800,6 +865,14 @@ export class WorkspaceStore { if (isStreamAbort(data)) { aggregator.clearTokenState(data.messageId); aggregator.handleStreamAbort(data); + + // Check if this was a compaction stream that got interrupted + if (this.handleCompactionAbort(workspaceId, aggregator, data)) { + // Compaction abort handled, don't do normal abort processing + return; + } + + // Normal abort handling this.states.bump(workspaceId); this.dispatchResumeCheck(workspaceId); diff --git a/src/styles/colors.tsx b/src/styles/colors.tsx index 82ef85cae..25de72911 100644 --- a/src/styles/colors.tsx +++ b/src/styles/colors.tsx @@ -17,6 +17,7 @@ export const GlobalColors = () => ( :root { /* Plan Mode Colors (Blue) */ --color-plan-mode: hsl(210 70% 40%); + --color-plan-mode-rgb: 31, 107, 184; /* RGB equivalent for alpha blending */ --color-plan-mode-hover: color-mix(in srgb, var(--color-plan-mode), white 20%); --color-plan-mode-light: color-mix(in srgb, var(--color-plan-mode) 60%, white); --color-plan-mode-alpha: hsl(from var(--color-plan-mode) h s l / 0.1); diff --git a/src/types/ipc.ts b/src/types/ipc.ts index 128875a0a..38828f266 100644 --- a/src/types/ipc.ts +++ b/src/types/ipc.ts @@ -209,7 +209,10 @@ export interface IPCApi { workspaceId: string, options: SendMessageOptions ): Promise>; - interruptStream(workspaceId: string): Promise>; + interruptStream( + workspaceId: string, + options?: { abandonPartial?: boolean } + ): Promise>; truncateHistory(workspaceId: string, percentage?: number): Promise>; replaceChatHistory( workspaceId: string, diff --git a/src/utils/compaction/handler.ts b/src/utils/compaction/handler.ts new file mode 100644 index 000000000..45f2fbfb8 --- /dev/null +++ b/src/utils/compaction/handler.ts @@ -0,0 +1,111 @@ +/** + * Compaction interrupt handling + * + * Two interrupt flows during compaction: + * - Ctrl+C (cancel): Abort compaction, restore original history + command to input + * - Ctrl+A (accept early): Complete compaction with [truncated] sentinel + * + * Uses localStorage to persist cancellation intent across reloads: + * - Before interrupt, store messageId in localStorage + * - handleCompactionAbort checks localStorage and verifies messageId matches + * - Reload-safe: localStorage persists, messageId ensures freshness + */ + +import type { StreamingMessageAggregator } from "@/utils/messages/StreamingMessageAggregator"; +import { getCancelledCompactionKey } from "@/constants/storage"; + +/** + * Check if the workspace is currently in a compaction stream + */ +export function isCompactingStream(aggregator: StreamingMessageAggregator): boolean { + const messages = aggregator.getAllMessages(); + const lastUserMsg = [...messages].reverse().find((m) => m.role === "user"); + return lastUserMsg?.metadata?.cmuxMetadata?.type === "compaction-request"; +} + +/** + * Find the compaction-request user message in message history + */ +export function findCompactionRequestMessage( + aggregator: StreamingMessageAggregator +): ReturnType[number] | null { + const messages = aggregator.getAllMessages(); + return ( + [...messages] + .reverse() + .find((m) => m.role === "user" && m.metadata?.cmuxMetadata?.type === "compaction-request") ?? + null + ); +} + +/** + * Get the original /compact command from the last user message + */ +export function getCompactionCommand(aggregator: StreamingMessageAggregator): string | null { + const compactionMsg = findCompactionRequestMessage(aggregator); + if (!compactionMsg) return null; + + const cmuxMeta = compactionMsg.metadata?.cmuxMetadata; + if (cmuxMeta?.type !== "compaction-request") return null; + + return cmuxMeta.rawCommand ?? null; +} + +/** + * Cancel compaction (Ctrl+C flow) + * + * Aborts the compaction stream and puts user in edit mode for compaction-request: + * - Interrupts stream with abandonPartial flag (deletes partial, doesn't commit) + * - Skips compaction (via localStorage marker checked by handleCompactionAbort) + * - Enters edit mode on compaction-request message + * - Restores original /compact command to input for re-editing + * - Leaves compaction-request message in history (can edit or delete it) + * + * Flow: + * 1. Store cancellation marker in localStorage with compactionRequestId for verification + * 2. Interrupt stream with {abandonPartial: true} - backend deletes partial + * 3. handleCompactionAbort checks localStorage, verifies compactionRequestId, skips compaction + * 4. Enter edit mode on compaction-request message with original command + * + * Reload-safe: localStorage persists across reloads, compactionRequestId ensures freshness + */ +export async function cancelCompaction( + workspaceId: string, + aggregator: StreamingMessageAggregator, + startEditingMessage: (messageId: string, initialText: string) => void +): Promise { + // Find the compaction request message + const compactionRequestMsg = findCompactionRequestMessage(aggregator); + if (!compactionRequestMsg) { + return false; + } + + // Extract command before modifying history + const command = getCompactionCommand(aggregator); + if (!command) { + return false; + } + + // CRITICAL: Store cancellation marker in localStorage BEFORE interrupt + // Use the compaction-request user message ID (stable across retries) + // This persists across reloads and verifies we're cancelling the right compaction + const storageKey = getCancelledCompactionKey(workspaceId); + localStorage.setItem( + storageKey, + JSON.stringify({ + compactionRequestId: compactionRequestMsg.id, + timestamp: Date.now(), + }) + ); + + // Interrupt stream with abandonPartial flag + // This tells backend to DELETE the partial instead of committing it + // Result: history ends with the compaction-request user message (which is fine - just a user message) + await window.api.workspace.interruptStream(workspaceId, { abandonPartial: true }); + + // Enter edit mode on the compaction-request message with original command + // This lets user immediately edit the message or delete it + startEditingMessage(compactionRequestMsg.id, command); + + return true; +} diff --git a/src/utils/messages/StreamingMessageAggregator.ts b/src/utils/messages/StreamingMessageAggregator.ts index 7959ba8da..fb82c2ffb 100644 --- a/src/utils/messages/StreamingMessageAggregator.ts +++ b/src/utils/messages/StreamingMessageAggregator.ts @@ -92,6 +92,23 @@ export class StreamingMessageAggregator { return this.currentTodos; } + /** + * Extract compaction summary text from a completed assistant message. + * Used when a compaction stream completes to get the summary for history replacement. + * @param messageId The ID of the assistant message to extract text from + * @returns The concatenated text from all text parts, or undefined if message not found + */ + getCompactionSummary(messageId: string): string | undefined { + const message = this.messages.get(messageId); + if (!message) return undefined; + + // Concatenate all text parts (ignore tool calls and reasoning) + return message.parts + .filter((part): part is { type: "text"; text: string } => part.type === "text") + .map((part) => part.text) + .join(""); + } + /** * Clean up stream-scoped state when stream ends (normally or abnormally). * Called by handleStreamEnd, handleStreamAbort, and handleStreamError. diff --git a/src/utils/messages/compactionOptions.test.ts b/src/utils/messages/compactionOptions.test.ts index 3b78b9f41..6a413edde 100644 --- a/src/utils/messages/compactionOptions.test.ts +++ b/src/utils/messages/compactionOptions.test.ts @@ -58,12 +58,24 @@ describe("applyCompactionOverrides", () => { expect(result.maxOutputTokens).toBe(8000); }); - it("sets compact mode and tool policy", () => { + it("sets compact mode and disables all tools", () => { const compactData: CompactionRequestData = {}; const result = applyCompactionOverrides(baseOptions, compactData); expect(result.mode).toBe("compact"); - expect(result.toolPolicy).toEqual([{ regex_match: "compact_summary", action: "require" }]); + expect(result.toolPolicy).toEqual([]); + }); + + it("disables all tools even when base options has tool policy", () => { + const baseWithTools: SendMessageOptions = { + ...baseOptions, + toolPolicy: [{ regex_match: "bash", action: "enable" }], + }; + const compactData: CompactionRequestData = {}; + const result = applyCompactionOverrides(baseWithTools, compactData); + + expect(result.mode).toBe("compact"); + expect(result.toolPolicy).toEqual([]); // Tools always disabled for compaction }); it("applies all overrides together", () => { diff --git a/src/utils/messages/compactionOptions.ts b/src/utils/messages/compactionOptions.ts index 97809b20a..59ca409cd 100644 --- a/src/utils/messages/compactionOptions.ts +++ b/src/utils/messages/compactionOptions.ts @@ -34,8 +34,8 @@ export function applyCompactionOverrides( ...baseOptions, model: compactionModel, thinkingLevel: isAnthropic ? "off" : baseOptions.thinkingLevel, - toolPolicy: [{ regex_match: "compact_summary", action: "require" }], maxOutputTokens: compactData.maxOutputTokens, mode: "compact" as const, + toolPolicy: [], // Disable all tools during compaction }; } diff --git a/src/utils/tools/toolDefinitions.ts b/src/utils/tools/toolDefinitions.ts index 36288f17e..edbe57245 100644 --- a/src/utils/tools/toolDefinitions.ts +++ b/src/utils/tools/toolDefinitions.ts @@ -141,17 +141,6 @@ export const TOOL_DEFINITIONS = { ), }), }, - compact_summary: { - description: - "Summarize the conversation history into a compact form. This tool is used during context compaction to reduce token usage while preserving key information.", - schema: z.object({ - summary: z - .string() - .describe( - "Compact summary of the conversation, preserving key decisions, context, and important details. Include enough information for the conversation to continue meaningfully." - ), - }), - }, todo_write: { description: "Create or update the todo list for tracking multi-step tasks (limit: 7 items). " + @@ -229,7 +218,6 @@ export function getAvailableTools(modelString: string): string[] { // "file_edit_replace_lines", // DISABLED: causes models to break repo state "file_edit_insert", "propose_plan", - "compact_summary", "todo_write", "todo_read", ]; diff --git a/src/utils/tools/tools.ts b/src/utils/tools/tools.ts index 923ccfb6b..b924dbd9f 100644 --- a/src/utils/tools/tools.ts +++ b/src/utils/tools/tools.ts @@ -5,7 +5,6 @@ import { createFileEditReplaceStringTool } from "@/services/tools/file_edit_repl // DISABLED: import { createFileEditReplaceLinesTool } from "@/services/tools/file_edit_replace_lines"; import { createFileEditInsertTool } from "@/services/tools/file_edit_insert"; import { createProposePlanTool } from "@/services/tools/propose_plan"; -import { createCompactSummaryTool } from "@/services/tools/compact_summary"; import { createTodoWriteTool, createTodoReadTool } from "@/services/tools/todo"; import { log } from "@/services/log"; @@ -58,7 +57,6 @@ export async function getToolsForModel( file_edit_insert: createFileEditInsertTool(config), bash: createBashTool(config), propose_plan: createProposePlanTool(config), - compact_summary: createCompactSummaryTool(config), todo_write: createTodoWriteTool(config), todo_read: createTodoReadTool(config), }; diff --git a/src/utils/ui/keybinds.ts b/src/utils/ui/keybinds.ts index c59de04f5..5cc21e3cf 100644 --- a/src/utils/ui/keybinds.ts +++ b/src/utils/ui/keybinds.ts @@ -195,6 +195,9 @@ export const KEYBINDS = { /** Interrupt active stream (destructive - stops AI generation) */ INTERRUPT_STREAM: { key: "c", ctrl: true, macCtrlBehavior: "control" }, + /** Accept partial compaction early (adds [truncated] sentinel) */ + ACCEPT_EARLY_COMPACTION: { key: "a", ctrl: true, macCtrlBehavior: "control" }, + /** Focus chat input */ FOCUS_INPUT_I: { key: "i" }, diff --git a/src/utils/ui/modeUtils.ts b/src/utils/ui/modeUtils.ts index 0d9d10452..7f2e27af4 100644 --- a/src/utils/ui/modeUtils.ts +++ b/src/utils/ui/modeUtils.ts @@ -19,7 +19,6 @@ export function modeToToolPolicy(mode: UIMode): ToolPolicy { if (mode === "plan") { return [ { regex_match: "file_edit_.*", action: "disable" }, - { regex_match: "compact_summary", action: "disable" }, { regex_match: "propose_plan", action: "enable" }, ]; } @@ -27,7 +26,6 @@ export function modeToToolPolicy(mode: UIMode): ToolPolicy { // exec mode return [ { regex_match: "propose_plan", action: "disable" }, - { regex_match: "compact_summary", action: "disable" }, { regex_match: "file_edit_.*", action: "enable" }, ]; } diff --git a/tests/e2e/scenarios/slashCommands.spec.ts b/tests/e2e/scenarios/slashCommands.spec.ts index b1544a8fe..b118633ec 100644 --- a/tests/e2e/scenarios/slashCommands.spec.ts +++ b/tests/e2e/scenarios/slashCommands.spec.ts @@ -81,7 +81,7 @@ test.describe("slash command flows", () => { }); } - const timeline = await ui.chat.captureStreamTimeline( + await ui.chat.captureStreamTimeline( async () => { await ui.chat.sendMessage("/compact -t 500"); }, @@ -89,11 +89,9 @@ test.describe("slash command flows", () => { ); await ui.chat.expectStatusMessageContains("Compaction started"); - const compactToolStart = timeline.events.find( - (event) => event.type === "tool-call-start" && event.toolName === "compact_summary" - ); - expect(compactToolStart).toBeDefined(); + // Compaction now uses direct text streaming instead of a tool call + // Verify the summary text appears in the transcript const transcript = page.getByRole("log", { name: "Conversation transcript" }); await ui.chat.expectTranscriptContains(COMPACT_SUMMARY_TEXT); await expect(transcript).toContainText(COMPACT_SUMMARY_TEXT);