Skip to content

Commit 2742056

Browse files
authored
Merge pull request #7695 from continuedev/tingwai/con-3770-compaction-ui
feat: improve compaction UI, show compaction message as status message
2 parents 889e6ab + 46ef8a1 commit 2742056

11 files changed

+622
-90
lines changed

extensions/cli/src/compaction.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,15 @@ export interface CompactionCallbacks {
2828
* @param model The model configuration
2929
* @param llmApi The LLM API instance
3030
* @param callbacks Optional callbacks for streaming updates
31+
* @param abortController Optional abort controller for cancellation
3132
* @returns The compacted history with compaction index
3233
*/
3334
export async function compactChatHistory(
3435
chatHistory: ChatHistoryItem[],
3536
model: ModelConfig,
3637
llmApi: BaseLlmApi,
3738
callbacks?: CompactionCallbacks,
39+
abortController?: AbortController,
3840
): Promise<CompactionResult> {
3941
// Create a prompt to summarize the conversation
4042
const compactionPrompt: ChatHistoryItem = {
@@ -81,7 +83,7 @@ export async function compactChatHistory(
8183
}
8284

8385
// Stream the compaction response (service drives updates; this collects content locally)
84-
const controller = new AbortController();
86+
const controller = abortController || new AbortController();
8587

8688
let compactionContent = "";
8789
const streamCallbacks: StreamCallbacks = {

extensions/cli/src/stream/streamChatResponse.autoCompaction.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ describe("handleAutoCompaction", () => {
147147
"Auto-compacting...",
148148
);
149149
expect(mockCallbacks.onSystemMessage).toHaveBeenCalledWith(
150-
"Chat history auto-compacted successfully.",
150+
"Chat history auto-compacted successfully.",
151151
);
152152

153153
expect(result).toEqual({

extensions/cli/src/stream/streamChatResponse.autoCompaction.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ function handleCompactionSuccess(
5555
) {
5656
if (isHeadless) return;
5757

58-
const successMessage = "Chat history auto-compacted successfully.";
58+
const successMessage = "Chat history auto-compacted successfully.";
5959

6060
if (callbacks?.onSystemMessage) {
6161
callbacks.onSystemMessage(successMessage);

extensions/cli/src/ui/TUIChat.tsx

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Box, Text } from "ink";
1+
import { Box } from "ink";
22
import React, {
33
useCallback,
44
useEffect,
@@ -17,6 +17,7 @@ import {
1717
} from "../services/types.js";
1818
import { logger } from "../util/logger.js";
1919

20+
import { ActionStatus } from "./components/ActionStatus.js";
2021
import { BottomStatusBar } from "./components/BottomStatusBar.js";
2122
import { ResourceDebugBar } from "./components/ResourceDebugBar.js";
2223
import { ScreenContent } from "./components/ScreenContent.js";
@@ -31,8 +32,6 @@ import {
3132
useLoginHandlers,
3233
useSelectors,
3334
} from "./hooks/useTUIChatHooks.js";
34-
import { LoadingAnimation } from "./LoadingAnimation.js";
35-
import { Timer } from "./Timer.js";
3635

3736
interface TUIChatProps {
3837
// Remote mode props
@@ -185,6 +184,8 @@ const TUIChat: React.FC<TUIChatProps> = ({
185184
setChatHistory,
186185
isWaitingForResponse,
187186
responseStartTime,
187+
isCompacting,
188+
compactionStartTime,
188189
inputMode,
189190
activePermissionRequest,
190191
wasInterrupted,
@@ -292,18 +293,21 @@ const TUIChat: React.FC<TUIChatProps> = ({
292293
{/* Fixed bottom section */}
293294
<Box flexDirection="column" flexShrink={0}>
294295
{/* Status */}
295-
{isWaitingForResponse && responseStartTime && (
296-
<Box paddingX={1} flexDirection="row" gap={1}>
297-
<LoadingAnimation visible={isWaitingForResponse} />
298-
<Text key="loading-start" color="gray">
299-
(
300-
</Text>
301-
<Timer startTime={responseStartTime} />
302-
<Text key="loading-end" color="gray">
303-
• esc to interrupt )
304-
</Text>
305-
</Box>
306-
)}
296+
<ActionStatus
297+
visible={isWaitingForResponse && !!responseStartTime}
298+
startTime={responseStartTime || 0}
299+
message=""
300+
showSpinner={true}
301+
/>
302+
303+
{/* Compaction Status */}
304+
<ActionStatus
305+
visible={isCompacting && !!compactionStartTime}
306+
startTime={compactionStartTime || 0}
307+
message="Compacting history"
308+
showSpinner={true}
309+
loadingColor="grey"
310+
/>
307311

308312
{/* All screen-specific content */}
309313
<ScreenContent
@@ -320,6 +324,7 @@ const TUIChat: React.FC<TUIChatProps> = ({
320324
handleToolPermissionResponse={handleToolPermissionResponse}
321325
handleUserMessage={handleUserMessage}
322326
isWaitingForResponse={isWaitingForResponse}
327+
isCompacting={isCompacting}
323328
inputMode={inputMode}
324329
handleInterrupt={handleInterrupt}
325330
handleFileAttached={handleFileAttached}

extensions/cli/src/ui/UserInput.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ const FileSearchMaybe: React.FC<{
9595
interface UserInputProps {
9696
onSubmit: (message: string, imageMap?: Map<string, Buffer>) => void;
9797
isWaitingForResponse: boolean;
98+
isCompacting?: boolean;
9899
inputMode: boolean;
99100
onInterrupt?: () => void;
100101
assistant?: AssistantConfig;
@@ -110,6 +111,7 @@ interface UserInputProps {
110111
const UserInput: React.FC<UserInputProps> = ({
111112
onSubmit,
112113
isWaitingForResponse,
114+
isCompacting = false,
113115
inputMode,
114116
onInterrupt,
115117
assistant,
@@ -524,8 +526,8 @@ const UserInput: React.FC<UserInputProps> = ({
524526
textBuffer.expandAllPasteBlocks();
525527
const submittedText = textBuffer.text.trim();
526528

527-
if (isWaitingForResponse) {
528-
// Process message later when LLM has responded
529+
if (isWaitingForResponse || isCompacting) {
530+
// Process message later when LLM has responded or compaction is complete
529531
void messageQueue.enqueueMessage(
530532
submittedText,
531533
imageMap,
@@ -576,6 +578,12 @@ const UserInput: React.FC<UserInputProps> = ({
576578
return true;
577579
}
578580

581+
// Handle escape key to interrupt compaction (higher priority)
582+
if (isCompacting && onInterrupt) {
583+
onInterrupt();
584+
return true;
585+
}
586+
579587
// Handle escape key to interrupt streaming
580588
if (isWaitingForResponse && onInterrupt) {
581589
onInterrupt();
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import { render } from "ink-testing-library";
2+
import React from "react";
3+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4+
5+
import { createUITestContext } from "../../test-helpers/ui-test-context.js";
6+
import { AppRoot } from "../AppRoot.js";
7+
8+
// Mock the useChat hook to control state
9+
const mockUseChat = vi.fn();
10+
11+
vi.mock("../hooks/useChat.js", () => ({
12+
useChat: () => mockUseChat(),
13+
}));
14+
15+
describe("TUIChat - ActionStatus", () => {
16+
let context: any;
17+
18+
beforeEach(() => {
19+
context = createUITestContext({
20+
allServicesReady: true,
21+
serviceState: "ready",
22+
});
23+
24+
// Default mock return value
25+
mockUseChat.mockReturnValue({
26+
chatHistory: [],
27+
setChatHistory: vi.fn(),
28+
isWaitingForResponse: false,
29+
responseStartTime: null,
30+
isCompacting: false,
31+
compactionStartTime: null,
32+
inputMode: true,
33+
attachedFiles: [],
34+
activePermissionRequest: null,
35+
wasInterrupted: false,
36+
handleUserMessage: vi.fn(),
37+
handleInterrupt: vi.fn(),
38+
handleFileAttached: vi.fn(),
39+
resetChatHistory: vi.fn(),
40+
handleToolPermissionResponse: vi.fn(),
41+
});
42+
});
43+
44+
afterEach(() => {
45+
context.cleanup();
46+
vi.clearAllMocks();
47+
});
48+
49+
it("shows only response status when waiting for response", () => {
50+
const responseStartTime = Date.now();
51+
52+
mockUseChat.mockReturnValue({
53+
chatHistory: [],
54+
setChatHistory: vi.fn(),
55+
isWaitingForResponse: true,
56+
responseStartTime,
57+
isCompacting: false,
58+
compactionStartTime: null,
59+
inputMode: false,
60+
attachedFiles: [],
61+
activePermissionRequest: null,
62+
wasInterrupted: false,
63+
handleUserMessage: vi.fn(),
64+
handleInterrupt: vi.fn(),
65+
handleFileAttached: vi.fn(),
66+
resetChatHistory: vi.fn(),
67+
handleToolPermissionResponse: vi.fn(),
68+
});
69+
70+
const { lastFrame, unmount } = render(React.createElement(AppRoot, {}));
71+
72+
try {
73+
const frame = lastFrame();
74+
expect(frame).toBeDefined();
75+
76+
// Should show spinner/response indicator but not compaction message
77+
expect(frame).toContain("esc to interrupt");
78+
expect(frame).not.toContain("Compacting history");
79+
} finally {
80+
unmount();
81+
}
82+
});
83+
84+
it("shows only compaction status when compacting", () => {
85+
const compactionStartTime = Date.now();
86+
87+
mockUseChat.mockReturnValue({
88+
chatHistory: [],
89+
setChatHistory: vi.fn(),
90+
isWaitingForResponse: false,
91+
responseStartTime: null,
92+
isCompacting: true,
93+
compactionStartTime,
94+
inputMode: true,
95+
attachedFiles: [],
96+
activePermissionRequest: null,
97+
wasInterrupted: false,
98+
handleUserMessage: vi.fn(),
99+
handleInterrupt: vi.fn(),
100+
handleFileAttached: vi.fn(),
101+
resetChatHistory: vi.fn(),
102+
handleToolPermissionResponse: vi.fn(),
103+
});
104+
105+
const { lastFrame, unmount } = render(React.createElement(AppRoot, {}));
106+
107+
try {
108+
const frame = lastFrame();
109+
expect(frame).toBeDefined();
110+
111+
// Should show compaction message but not spinner
112+
expect(frame).toContain("Compacting history");
113+
expect(frame).toContain("esc to interrupt");
114+
} finally {
115+
unmount();
116+
}
117+
});
118+
119+
it("shows neither status when both states are false", () => {
120+
mockUseChat.mockReturnValue({
121+
chatHistory: [],
122+
setChatHistory: vi.fn(),
123+
isWaitingForResponse: false,
124+
responseStartTime: null,
125+
isCompacting: false,
126+
compactionStartTime: null,
127+
inputMode: true,
128+
attachedFiles: [],
129+
activePermissionRequest: null,
130+
wasInterrupted: false,
131+
handleUserMessage: vi.fn(),
132+
handleInterrupt: vi.fn(),
133+
handleFileAttached: vi.fn(),
134+
resetChatHistory: vi.fn(),
135+
handleToolPermissionResponse: vi.fn(),
136+
});
137+
138+
const { lastFrame, unmount } = render(React.createElement(AppRoot, {}));
139+
140+
try {
141+
const frame = lastFrame();
142+
expect(frame).toBeDefined();
143+
144+
// Should not show any status messages
145+
expect(frame).not.toContain("Compacting history");
146+
// The basic UI should still be there
147+
expect(frame).toContain("Ask anything");
148+
} finally {
149+
unmount();
150+
}
151+
});
152+
153+
it("handles state transitions correctly", () => {
154+
const { lastFrame, rerender, unmount } = render(
155+
React.createElement(AppRoot, {}),
156+
);
157+
158+
// Start with compaction
159+
const compactionStartTime = Date.now();
160+
mockUseChat.mockReturnValue({
161+
chatHistory: [],
162+
setChatHistory: vi.fn(),
163+
isWaitingForResponse: false,
164+
responseStartTime: null,
165+
isCompacting: true,
166+
compactionStartTime,
167+
inputMode: true,
168+
attachedFiles: [],
169+
activePermissionRequest: null,
170+
wasInterrupted: false,
171+
handleUserMessage: vi.fn(),
172+
handleInterrupt: vi.fn(),
173+
handleFileAttached: vi.fn(),
174+
resetChatHistory: vi.fn(),
175+
handleToolPermissionResponse: vi.fn(),
176+
});
177+
178+
try {
179+
rerender(React.createElement(AppRoot, {}));
180+
181+
let frame = lastFrame();
182+
expect(frame).toContain("Compacting history");
183+
expect(frame).not.toContain("⠀⠁⠃⠇⠏⠟⠿⣿"); // No spinner chars when only compacting
184+
185+
// Transition to response waiting
186+
const responseStartTime = Date.now();
187+
mockUseChat.mockReturnValue({
188+
chatHistory: [],
189+
setChatHistory: vi.fn(),
190+
isWaitingForResponse: true,
191+
responseStartTime,
192+
isCompacting: false,
193+
compactionStartTime: null,
194+
inputMode: false,
195+
attachedFiles: [],
196+
activePermissionRequest: null,
197+
wasInterrupted: false,
198+
handleUserMessage: vi.fn(),
199+
handleInterrupt: vi.fn(),
200+
handleFileAttached: vi.fn(),
201+
resetChatHistory: vi.fn(),
202+
handleToolPermissionResponse: vi.fn(),
203+
});
204+
205+
rerender(React.createElement(AppRoot, {}));
206+
207+
frame = lastFrame();
208+
expect(frame).not.toContain("Compacting history");
209+
expect(frame).toContain("esc to interrupt");
210+
} finally {
211+
unmount();
212+
}
213+
});
214+
});

0 commit comments

Comments
 (0)