Skip to content

Commit 80c6797

Browse files
authored
Merge pull request #7501 from continuedev/pause-cli
feat: pause and resume cn
2 parents e28060a + 0bfb934 commit 80c6797

File tree

11 files changed

+307
-30
lines changed

11 files changed

+307
-30
lines changed

core/util/chatDescriber.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,6 @@ export class ChatDescriber {
103103

104104
return undefined;
105105
} catch (error) {
106-
console.debug("Error generating chat title:", error);
107106
return undefined;
108107
}
109108
}

extensions/cli/src/slashCommands.test.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@ vi.mock("./session.js", () => ({
8383
() => "/home/test/.continue/cli-sessions/continue-cli-pid-12345.json",
8484
),
8585
hasSession: vi.fn(() => false),
86+
getCurrentSession: vi.fn(() => {
87+
throw new Error("Session not available");
88+
}),
8689
}));
8790

8891
describe("slashCommands", () => {
@@ -141,8 +144,8 @@ describe("slashCommands", () => {
141144
expect(result?.output).toContain("Not logged in");
142145
expect(result?.output).toContain("Configuration:");
143146
expect(result?.output).toContain("/test/config.yaml");
144-
expect(result?.output).toContain("Session History:");
145-
expect(result?.output).toContain(".json");
147+
expect(result?.output).toContain("Session:");
148+
expect(result?.output).toContain("Session not available");
146149
expect(result?.exit).toBe(false);
147150
});
148151

@@ -244,12 +247,18 @@ describe("slashCommands", () => {
244247
it("should use test session directory when in test mode", async () => {
245248
const { isAuthenticated } = await import("./auth/workos.js");
246249
const { services } = await import("./services/index.js");
247-
const { getSessionFilePath } = await import("./session.js");
250+
const { getSessionFilePath, getCurrentSession } = await import(
251+
"./session.js"
252+
);
248253

249-
// Mock the session path for this specific test
254+
// Mock the session functions for this specific test
250255
(getSessionFilePath as any).mockReturnValue(
251256
"/test-home/.continue/cli-sessions/continue-cli-test-123.json",
252257
);
258+
(getCurrentSession as any).mockReturnValue({
259+
sessionId: "test-123",
260+
title: "Test Session",
261+
});
253262

254263
(
255264
isAuthenticated as MockedFunction<typeof isAuthenticated>
@@ -265,6 +274,8 @@ describe("slashCommands", () => {
265274

266275
const result = await handleSlashCommands("/info", mockAssistant);
267276

277+
expect(result?.output).toContain("Session:");
278+
expect(result?.output).toContain("Test Session");
268279
expect(result?.output).toContain("/test-home/.continue/cli-sessions/");
269280
expect(result?.output).toContain(".json");
270281
});

extensions/cli/src/slashCommands.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
} from "./auth/workos.js";
99
import { getAllSlashCommands } from "./commands/commands.js";
1010
import { reloadService, SERVICE_NAMES, services } from "./services/index.js";
11-
import { getSessionFilePath } from "./session.js";
11+
import { getCurrentSession, getSessionFilePath } from "./session.js";
1212
import { posthogService } from "./telemetry/posthogService.js";
1313
import { SlashCommandResult } from "./ui/hooks/useChat.types.js";
1414
import { getVersion } from "./version.js";
@@ -179,12 +179,19 @@ async function handleInfo() {
179179
infoLines.push(` ${chalk.red("Configuration service not available")}`);
180180
}
181181

182-
// Session history path
182+
// Session info
183183
infoLines.push("");
184-
infoLines.push(chalk.white("Session History:"));
185-
const sessionFilePath = getSessionFilePath();
184+
infoLines.push(chalk.white("Session:"));
185+
try {
186+
const currentSession = getCurrentSession();
187+
infoLines.push(` Title: ${chalk.green(currentSession.title)}`);
188+
infoLines.push(` ID: ${chalk.gray(currentSession.sessionId)}`);
186189

187-
infoLines.push(` File: ${chalk.blue(sessionFilePath)}`);
190+
const sessionFilePath = getSessionFilePath();
191+
infoLines.push(` File: ${chalk.blue(sessionFilePath)}`);
192+
} catch {
193+
infoLines.push(` ${chalk.red("Session not available")}`);
194+
}
188195

189196
return {
190197
exit: false,

extensions/cli/src/stream/streamChatResponse.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,10 @@ export async function streamChatResponse(
342342
tools,
343343
});
344344

345+
if (abortController?.signal.aborted) {
346+
return finalResponse || content || fullResponse;
347+
}
348+
345349
fullResponse += content;
346350

347351
// Update final response based on mode

extensions/cli/src/ui/TUIChat.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ const TUIChat: React.FC<TUIChatProps> = ({
188188
responseStartTime,
189189
inputMode,
190190
activePermissionRequest,
191+
wasInterrupted,
191192
handleUserMessage,
192193
handleInterrupt,
193194
handleFileAttached,
@@ -318,6 +319,7 @@ const TUIChat: React.FC<TUIChatProps> = ({
318319
handleInterrupt={handleInterrupt}
319320
handleFileAttached={handleFileAttached}
320321
isInputDisabled={isInputDisabled}
322+
wasInterrupted={wasInterrupted}
321323
isRemoteMode={isRemoteMode}
322324
/>
323325

extensions/cli/src/ui/UserInput.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ interface UserInputProps {
2525
inputMode: boolean;
2626
onInterrupt?: () => void;
2727
assistant?: AssistantConfig;
28+
wasInterrupted?: boolean;
2829
onFileAttached?: (filePath: string, content: string) => void;
2930
disabled?: boolean;
3031
placeholder?: string;
@@ -38,6 +39,7 @@ const UserInput: React.FC<UserInputProps> = ({
3839
inputMode,
3940
onInterrupt,
4041
assistant,
42+
wasInterrupted = false,
4143
onFileAttached,
4244
disabled = false,
4345
placeholder,
@@ -406,12 +408,18 @@ const UserInput: React.FC<UserInputProps> = ({
406408
return true;
407409
}
408410

409-
// Normal Enter behavior - submit if there's content
410-
if (textBuffer.text.trim() && !isWaitingForResponse) {
411+
// Normal Enter behavior - submit if there's content OR if resuming after interruption
412+
if ((textBuffer.text.trim() || wasInterrupted) && !isWaitingForResponse) {
411413
// Expand all paste blocks before submitting
412414
textBuffer.expandAllPasteBlocks();
413415
const submittedText = textBuffer.text.trim();
414-
inputHistory.addEntry(submittedText);
416+
417+
// Only add to history if there's actual text (not when resuming)
418+
if (submittedText) {
419+
inputHistory.addEntry(submittedText);
420+
}
421+
422+
// Send empty string when resuming, actual text otherwise
415423
onSubmit(submittedText);
416424
textBuffer.clear();
417425
setInputText("");
@@ -638,6 +646,15 @@ const UserInput: React.FC<UserInputProps> = ({
638646

639647
return (
640648
<Box flexDirection="column">
649+
{/* Interruption message - shown just above the input box */}
650+
{wasInterrupted && (
651+
<Box paddingX={1} marginBottom={0}>
652+
<Text color="yellow">
653+
⚠ Interrupted by user - Press enter to resume
654+
</Text>
655+
</Box>
656+
)}
657+
641658
{/* Input box */}
642659
<Box
643660
borderStyle="round"
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { render } from "ink-testing-library";
2+
import React from "react";
3+
import { vi, expect, describe, it, beforeEach, afterEach } from "vitest";
4+
5+
import { UserInput } from "../UserInput.js";
6+
7+
describe("TUIChat - Interruption UI (Minimal Test)", () => {
8+
beforeEach(() => {
9+
vi.useFakeTimers();
10+
});
11+
12+
afterEach(() => {
13+
vi.clearAllTimers();
14+
vi.useRealTimers();
15+
});
16+
17+
it("shows interruption message when wasInterrupted is true", () => {
18+
const mockOnSubmit = vi.fn();
19+
const mockOnInterrupt = vi.fn();
20+
21+
const { lastFrame, rerender, unmount } = render(
22+
React.createElement(UserInput, {
23+
onSubmit: mockOnSubmit,
24+
isWaitingForResponse: false,
25+
inputMode: true,
26+
onInterrupt: mockOnInterrupt,
27+
wasInterrupted: false, // Initially false
28+
}),
29+
);
30+
31+
try {
32+
// Initial render - no interruption message
33+
let frame = lastFrame();
34+
expect(frame).toBeDefined();
35+
expect(frame).not.toContain("⚠ Interrupted by user");
36+
expect(frame).not.toContain("Press enter to resume");
37+
38+
// Re-render with wasInterrupted: true
39+
rerender(
40+
React.createElement(UserInput, {
41+
onSubmit: mockOnSubmit,
42+
isWaitingForResponse: false,
43+
inputMode: true,
44+
onInterrupt: mockOnInterrupt,
45+
wasInterrupted: true, // Now true
46+
}),
47+
);
48+
49+
// Should show interruption message
50+
frame = lastFrame();
51+
expect(frame).toBeDefined();
52+
expect(frame).toContain("⚠ Interrupted by user");
53+
expect(frame).toContain("Press enter to resume");
54+
} finally {
55+
unmount();
56+
}
57+
});
58+
59+
it("hides interruption message when wasInterrupted is false", () => {
60+
const mockOnSubmit = vi.fn();
61+
const mockOnInterrupt = vi.fn();
62+
63+
const { lastFrame, rerender, unmount } = render(
64+
React.createElement(UserInput, {
65+
onSubmit: mockOnSubmit,
66+
isWaitingForResponse: false,
67+
inputMode: true,
68+
onInterrupt: mockOnInterrupt,
69+
wasInterrupted: true, // Initially true
70+
}),
71+
);
72+
73+
try {
74+
// Initial render - should show interruption message
75+
let frame = lastFrame();
76+
expect(frame).toBeDefined();
77+
expect(frame).toContain("⚠ Interrupted by user");
78+
expect(frame).toContain("Press enter to resume");
79+
80+
// Re-render with wasInterrupted: false
81+
rerender(
82+
React.createElement(UserInput, {
83+
onSubmit: mockOnSubmit,
84+
isWaitingForResponse: false,
85+
inputMode: true,
86+
onInterrupt: mockOnInterrupt,
87+
wasInterrupted: false, // Now false
88+
}),
89+
);
90+
91+
// Should not show interruption message
92+
frame = lastFrame();
93+
expect(frame).toBeDefined();
94+
expect(frame).not.toContain("⚠ Interrupted by user");
95+
expect(frame).not.toContain("Press enter to resume");
96+
} finally {
97+
unmount();
98+
}
99+
});
100+
101+
it("calls onSubmit with empty string when Enter is pressed while interrupted", () => {
102+
const mockOnSubmit = vi.fn();
103+
const mockOnInterrupt = vi.fn();
104+
105+
const { stdin, unmount } = render(
106+
React.createElement(UserInput, {
107+
onSubmit: mockOnSubmit,
108+
isWaitingForResponse: false,
109+
inputMode: true,
110+
onInterrupt: mockOnInterrupt,
111+
wasInterrupted: true, // Interrupted state
112+
}),
113+
);
114+
115+
try {
116+
// Press Enter while in interrupted state
117+
stdin.write("\r");
118+
119+
// Should call onSubmit with empty string (for resume)
120+
expect(mockOnSubmit).toHaveBeenCalledWith("");
121+
} finally {
122+
unmount();
123+
}
124+
});
125+
126+
it("calls onSubmit with typed content when typing new message after interruption", () => {
127+
const mockOnSubmit = vi.fn();
128+
const mockOnInterrupt = vi.fn();
129+
130+
const { stdin, unmount } = render(
131+
React.createElement(UserInput, {
132+
onSubmit: mockOnSubmit,
133+
isWaitingForResponse: false,
134+
inputMode: true,
135+
onInterrupt: mockOnInterrupt,
136+
wasInterrupted: true, // Interrupted state
137+
}),
138+
);
139+
140+
try {
141+
// Type a new message
142+
stdin.write("New message after interruption");
143+
stdin.write("\r");
144+
145+
// Should call onSubmit with the typed content
146+
expect(mockOnSubmit).toHaveBeenCalledWith(
147+
"New message after interruption",
148+
);
149+
} finally {
150+
unmount();
151+
}
152+
});
153+
154+
it("shows interruption message above input box in correct position", () => {
155+
const mockOnSubmit = vi.fn();
156+
const mockOnInterrupt = vi.fn();
157+
158+
const { lastFrame, unmount } = render(
159+
React.createElement(UserInput, {
160+
onSubmit: mockOnSubmit,
161+
isWaitingForResponse: false,
162+
inputMode: true,
163+
onInterrupt: mockOnInterrupt,
164+
wasInterrupted: true,
165+
}),
166+
);
167+
168+
try {
169+
const frame = lastFrame();
170+
expect(frame).toBeDefined();
171+
172+
// The interruption message should appear before the input box
173+
const frameLines = frame?.split("\n") || [];
174+
175+
let foundInterruptionLine = -1;
176+
let foundInputBoxLine = -1;
177+
178+
frameLines.forEach((line, index) => {
179+
if (line.includes("⚠ Interrupted by user")) {
180+
foundInterruptionLine = index;
181+
}
182+
if (line.includes("●") && line.includes("▋")) {
183+
foundInputBoxLine = index;
184+
}
185+
});
186+
187+
// Interruption message should come before the input box
188+
expect(foundInterruptionLine).toBeGreaterThan(-1);
189+
expect(foundInputBoxLine).toBeGreaterThan(-1);
190+
expect(foundInterruptionLine).toBeLessThan(foundInputBoxLine);
191+
} finally {
192+
unmount();
193+
}
194+
});
195+
});

extensions/cli/src/ui/components/ScreenContent.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ interface ScreenContentProps {
3636
handleInterrupt: () => void;
3737
handleFileAttached: (filePath: string, content: string) => void;
3838
isInputDisabled: boolean;
39+
wasInterrupted?: boolean;
3940
isRemoteMode: boolean;
4041
}
4142

@@ -57,6 +58,7 @@ export const ScreenContent: React.FC<ScreenContentProps> = ({
5758
handleInterrupt,
5859
handleFileAttached,
5960
isInputDisabled,
61+
wasInterrupted = false,
6062
isRemoteMode,
6163
}) => {
6264
// Login prompt
@@ -147,6 +149,7 @@ export const ScreenContent: React.FC<ScreenContentProps> = ({
147149
inputMode={inputMode}
148150
onInterrupt={handleInterrupt}
149151
assistant={services.config?.config || undefined}
152+
wasInterrupted={wasInterrupted}
150153
onFileAttached={handleFileAttached}
151154
disabled={isInputDisabled}
152155
isRemoteMode={isRemoteMode}

0 commit comments

Comments
 (0)