Skip to content

Commit 1f5c4e4

Browse files
committed
feat: add task abort functionality and improve UI feedback
- Add ESC key support to abort running tasks - Implement loop state display showing progress and current tool - Enhance keyboard input handling with proper signal management - Update hooks to use useMemoizedFn for performance - Add abort signal support in Agent for cancellable operations - Improve command handler with abort functionality - Add proper cleanup for agent event listeners
1 parent d21a345 commit 1f5c4e4

11 files changed

Lines changed: 407 additions & 210 deletions

.claude/settings.local.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@
2424
"WebFetch(domain:gist.github.com)",
2525
"Bash(pnpm update:*)",
2626
"WebFetch(domain:jannesklaas.github.io)",
27-
"Bash(npm view:*)"
27+
"Bash(npm view:*)",
28+
"Bash(for file in src/ui/hooks/useKeyboardInput.ts src/ui/components/ReplInterface.tsx src/ui/ink/Input.tsx src/ui/ink/InputManager.tsx)",
29+
"Bash(do echo \"=== $file ===\" grep -n \"useInput\\|key\\.\" \"$file\")"
2830
],
2931
"deny": [],
3032
"ask": [],

src/agent/Agent.ts

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -203,8 +203,16 @@ export class Agent extends EventEmitter {
203203

204204
// 如果提供了 context,使用增强的工具调用流程
205205
if (context) {
206-
const result = await this.runLoop(message, context);
206+
const result = await this.runLoop(message, context, {
207+
signal: context.signal,
208+
});
207209
if (!result.success) {
210+
// 如果是用户中止,触发事件并返回空字符串(不抛出异常)
211+
if (result.error?.type === 'aborted') {
212+
this.emit('taskAborted', result.metadata);
213+
return ''; // 返回空字符串,让调用方自行处理
214+
}
215+
// 其他错误则抛出异常
208216
throw new Error(result.error?.message || '执行失败');
209217
}
210218
return result.finalMessage || '';
@@ -271,8 +279,8 @@ export class Agent extends EventEmitter {
271279
return {
272280
success: false,
273281
error: {
274-
type: 'canceled',
275-
message: '用户中断',
282+
type: 'aborted',
283+
message: '任务已被用户中止',
276284
},
277285
metadata: {
278286
turnsCount,
@@ -317,6 +325,22 @@ export class Agent extends EventEmitter {
317325
for (const toolCall of turnResult.toolCalls) {
318326
if (toolCall.type !== 'function') continue;
319327

328+
// 在每个工具执行前检查中断信号
329+
if (options?.signal?.aborted) {
330+
return {
331+
success: false,
332+
error: {
333+
type: 'aborted',
334+
message: '任务已被用户中止',
335+
},
336+
metadata: {
337+
turnsCount,
338+
toolCallsCount: allToolResults.length,
339+
duration: Date.now() - startTime,
340+
},
341+
};
342+
}
343+
320344
try {
321345
// 触发工具执行开始事件
322346
this.emit('toolExecutionStart', {
@@ -331,7 +355,8 @@ export class Agent extends EventEmitter {
331355

332356
const params = JSON.parse(toolCall.function.arguments);
333357
const toolInvocation = tool.build(params);
334-
const result = await toolInvocation.execute(new AbortController().signal);
358+
const signalToUse = options?.signal || new AbortController().signal;
359+
const result = await toolInvocation.execute(signalToUse);
335360
allToolResults.push(result);
336361

337362
// 触发工具执行完成事件
@@ -378,6 +403,22 @@ export class Agent extends EventEmitter {
378403
}
379404
}
380405

406+
// 检查工具执行后的中断信号
407+
if (options?.signal?.aborted) {
408+
return {
409+
success: false,
410+
error: {
411+
type: 'aborted',
412+
message: '任务已被用户中止',
413+
},
414+
metadata: {
415+
turnsCount,
416+
toolCallsCount: allToolResults.length,
417+
duration: Date.now() - startTime,
418+
},
419+
};
420+
}
421+
381422
// 7. 循环检测 - 检测是否陷入死循环
382423
const loopDetected = await this.loopDetector.detect(
383424
turnResult.toolCalls.filter(

src/agent/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export interface ChatContext {
1313
userId: string;
1414
sessionId: string;
1515
workspaceRoot: string;
16+
signal?: AbortSignal;
1617
}
1718

1819
/**
@@ -135,7 +136,7 @@ export interface LoopResult {
135136
success: boolean;
136137
finalMessage?: string;
137138
error?: {
138-
type: 'canceled' | 'max_turns_exceeded' | 'api_error' | 'loop_detected';
139+
type: 'canceled' | 'max_turns_exceeded' | 'api_error' | 'loop_detected' | 'aborted';
139140
message: string;
140141
details?: any;
141142
};

src/ui/components/BladeInterface.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,14 +91,18 @@ export const BladeInterface: React.FC<BladeInterfaceProps> = ({
9191
}, [stdout]);
9292

9393
// 使用 hooks
94-
const { isProcessing, executeCommand } = useCommandHandler(otherProps.appendSystemPrompt);
94+
const { isProcessing, executeCommand, loopState, handleAbort } = useCommandHandler(
95+
otherProps.appendSystemPrompt
96+
);
9597
const { getPreviousCommand, getNextCommand, addToHistory } = useCommandHistory();
9698

9799
const { input, showSuggestions, suggestions, selectedSuggestionIndex } = useKeyboardInput(
98100
(command: string) => executeCommand(command, addUserMessage, addAssistantMessage),
99101
getPreviousCommand,
100102
getNextCommand,
101-
addToHistory
103+
addToHistory,
104+
handleAbort,
105+
isProcessing
102106
);
103107

104108
// 主界面 - 统一显示,不再区分初始化状态
@@ -111,6 +115,7 @@ export const BladeInterface: React.FC<BladeInterfaceProps> = ({
111115
terminalWidth={terminalWidth}
112116
isProcessing={isProcessing}
113117
isInitialized={hasApiKey}
118+
loopState={loopState}
114119
/>
115120

116121
<InputArea

src/ui/components/MessageArea.tsx

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ import { Box, Text } from 'ink';
22
import React from 'react';
33
import { MessageRenderer } from './MessageRenderer.js';
44
import { getCopyright } from '../../utils/package-info.js';
5+
import type { LoopState } from '../hooks/useCommandHandler.js';
56

67
interface MessageAreaProps {
78
sessionState: any;
89
terminalWidth: number;
910
isProcessing: boolean;
1011
isInitialized: boolean;
12+
loopState: LoopState;
1113
}
1214

1315
/**
@@ -19,6 +21,7 @@ export const MessageArea: React.FC<MessageAreaProps> = ({
1921
terminalWidth,
2022
isProcessing,
2123
isInitialized,
24+
loopState,
2225
}) => {
2326
// 判断是否显示欢迎界面(只有assistant消息,没有用户消息)
2427
const hasUserMessages = sessionState.messages.some((msg: any) => msg.role === 'user');
@@ -122,10 +125,25 @@ export const MessageArea: React.FC<MessageAreaProps> = ({
122125
/>
123126
))}
124127
{isProcessing && (
125-
<Box paddingX={2}>
126-
<Text color="yellow" dimColor>
127-
正在思考中...
128-
</Text>
128+
<Box paddingX={2} flexDirection="column">
129+
{loopState.active ? (
130+
<>
131+
<Text color="cyan" bold>
132+
🔄 回合 {loopState.turn}/{loopState.maxTurns} (
133+
{Math.round((loopState.turn / loopState.maxTurns) * 100)}%)
134+
</Text>
135+
{loopState.currentTool && (
136+
<Text color="green" bold>🔧 正在执行: {loopState.currentTool}</Text>
137+
)}
138+
<Text color="yellow">
139+
按 ESC 停止任务
140+
</Text>
141+
</>
142+
) : (
143+
<Text color="yellow" bold>
144+
正在思考中...
145+
</Text>
146+
)}
129147
</Box>
130148
)}
131149
</Box>

src/ui/components/ReplInterface.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,13 @@ export const ReplInterface: React.FC<ReplInterfaceProps> = ({ onCommandSubmit })
3131
if (key.return) {
3232
// 回车键提交命令
3333
handleSubmit();
34-
} else if (key.ctrl && key.name === 'c') {
34+
} else if (key.ctrl && inputKey === 'c') {
3535
// Ctrl+C 退出
3636
exit();
3737
} else if (key.backspace || key.delete) {
3838
// 退格键删除字符
3939
setInput((prev) => prev.slice(0, -1));
40-
} else if (inputKey && key.name !== 'escape') {
40+
} else if (inputKey && !key.escape) {
4141
// 普通字符输入
4242
setInput((prev) => prev + inputKey);
4343
}

src/ui/hooks/useAppNavigation.ts

Lines changed: 40 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { useCallback, useState } from 'react';
1+
import { useMemoizedFn } from 'ahooks';
2+
import { useState } from 'react';
23
import type { RouteConfig } from '../../config/route-config.js';
34

45
export type AppView =
@@ -50,7 +51,7 @@ export const useAppNavigation = (): UseAppNavigationReturn => {
5051
const [navigationState, setNavigationState] =
5152
useState<NavigationState>(defaultNavigationState);
5253

53-
const navigate = useCallback((view: AppView, options: NavigationOptions = {}) => {
54+
const navigate = useMemoizedFn((view: AppView, options: NavigationOptions = {}) => {
5455
const { replace = false, preserveHistory = false } = options;
5556

5657
setNavigationState((prevState) => {
@@ -80,9 +81,9 @@ export const useAppNavigation = (): UseAppNavigationReturn => {
8081
if (routeConfig?.onNavigate) {
8182
routeConfig.onNavigate(view, options);
8283
}
83-
}, []);
84+
});
8485

85-
const goBack = useCallback(() => {
86+
const goBack = useMemoizedFn(() => {
8687
setNavigationState((prevState) => {
8788
if (prevState.history.length <= 1) {
8889
return prevState; // 无法返回
@@ -98,26 +99,26 @@ export const useAppNavigation = (): UseAppNavigationReturn => {
9899
canGoForward: true,
99100
};
100101
});
101-
}, []);
102+
});
102103

103-
const goForward = useCallback(() => {
104+
const goForward = useMemoizedFn(() => {
104105
setNavigationState((prevState) => {
105106
// 这里简化处理,实际应该维护一个前进历史栈
106107
return prevState; // 暂时无法前进
107108
});
108-
}, []);
109+
});
109110

110-
const goToHome = useCallback(() => {
111+
const goToHome = useMemoizedFn(() => {
111112
navigate('main', { replace: true });
112-
}, [navigate]);
113+
});
113114

114-
const registerRoute = useCallback((route: RouteConfig) => {
115+
const registerRoute = useMemoizedFn((route: RouteConfig) => {
115116
routeRegistry.set(route.path as AppView, route);
116-
}, []);
117+
});
117118

118-
const getRouteConfig = useCallback((view: AppView): RouteConfig | undefined => {
119+
const getRouteConfig = useMemoizedFn((view: AppView): RouteConfig | undefined => {
119120
return routeRegistry.get(view);
120-
}, []);
121+
});
121122

122123
return {
123124
currentView: navigationState.currentView,
@@ -152,31 +153,28 @@ export const useNavigationHistory = () => {
152153
export const useDeepLink = () => {
153154
const navigate = useAppNavigation().navigate;
154155

155-
const handleDeepLink = useCallback(
156-
(url: string) => {
157-
try {
158-
const parsedUrl = new URL(url);
159-
const path = parsedUrl.pathname.slice(1); // 去掉开头的'/'
160-
const params = Object.fromEntries(parsedUrl.searchParams);
161-
162-
// 解析路径并导航
163-
if (['settings', 'help', 'logs', 'tools', 'chat', 'config'].includes(path)) {
164-
navigate(path as AppView, { state: params });
165-
} else {
166-
// 处理特殊的深度链接格式
167-
const [view, ...rest] = path.split('/');
168-
if (['settings', 'help', 'logs', 'tools', 'chat', 'config'].includes(view)) {
169-
navigate(view as AppView, {
170-
state: { section: rest.join('/'), ...params },
171-
});
172-
}
156+
const handleDeepLink = useMemoizedFn((url: string) => {
157+
try {
158+
const parsedUrl = new URL(url);
159+
const path = parsedUrl.pathname.slice(1); // 去掉开头的'/'
160+
const params = Object.fromEntries(parsedUrl.searchParams);
161+
162+
// 解析路径并导航
163+
if (['settings', 'help', 'logs', 'tools', 'chat', 'config'].includes(path)) {
164+
navigate(path as AppView, { state: params });
165+
} else {
166+
// 处理特殊的深度链接格式
167+
const [view, ...rest] = path.split('/');
168+
if (['settings', 'help', 'logs', 'tools', 'chat', 'config'].includes(view)) {
169+
navigate(view as AppView, {
170+
state: { section: rest.join('/'), ...params },
171+
});
173172
}
174-
} catch (error) {
175-
console.error('解析深度链接失败:', error);
176173
}
177-
},
178-
[navigate]
179-
);
174+
} catch (error) {
175+
console.error('解析深度链接失败:', error);
176+
}
177+
});
180178

181179
return { handleDeepLink };
182180
};
@@ -185,7 +183,7 @@ export const useDeepLink = () => {
185183
export const useRouteGuard = () => {
186184
const navigate = useAppNavigation().navigate;
187185

188-
const requireAuth = useCallback((targetView: AppView) => {
186+
const requireAuth = useMemoizedFn((targetView: AppView) => {
189187
const isAuthenticated = false; // 这里应该从认证状态获取
190188

191189
if (!isAuthenticated) {
@@ -195,9 +193,9 @@ export const useRouteGuard = () => {
195193
}
196194

197195
return true;
198-
}, []);
196+
});
199197

200-
const requirePermission = useCallback((permission: string, targetView: AppView) => {
198+
const requirePermission = useMemoizedFn((permission: string, targetView: AppView) => {
201199
const hasPermission = true; // 这里应该从权限系统获取
202200

203201
if (!hasPermission) {
@@ -206,9 +204,9 @@ export const useRouteGuard = () => {
206204
}
207205

208206
return true;
209-
}, []);
207+
});
210208

211-
const navigateWithGuard = useCallback(
209+
const navigateWithGuard = useMemoizedFn(
212210
(view: AppView, options?: NavigationOptions) => {
213211
const routeConfig = routeRegistry.get(view);
214212

@@ -223,8 +221,7 @@ export const useRouteGuard = () => {
223221
}
224222

225223
navigate(view, options);
226-
},
227-
[navigate]
224+
}
228225
);
229226

230227
return {

0 commit comments

Comments
 (0)