Files
claude-code/src/main/java/com/claudecode/tui/JinkReplSession.java
T
abel533andCopilot 1b53357ded feat: Ctrl+C 中断 Agent 运行(而非退出应用)
- 使用 Ink.render(component, false) 禁用默认 Ctrl+C 退出
- Ctrl+C 在 Agent 运行中触发 cancel(),停止当前循环
- Ctrl+C 在空闲时清空输入
- 保留 Ctrl+D 作为退出快捷键

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-04 17:38:14 +08:00

288 lines
10 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.claudecode.tui;
import com.claudecode.config.AppConfig.ProviderInfo;
import com.claudecode.command.CommandRegistry;
import com.claudecode.core.AgentLoop;
import com.claudecode.core.ConversationPersistence;
import com.claudecode.core.TokenTracker;
import com.claudecode.permission.PermissionTypes.PermissionChoice;
import com.claudecode.tui.UIMessage.*;
import com.claudecode.tool.ToolRegistry;
import com.claudecode.tool.impl.AskUserQuestionTool;
import io.mybatis.jink.Ink;
import io.mybatis.jink.style.Color;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.CompletableFuture;
import java.util.function.Function;
/**
* 基于 jink 的 REPL 会话 —— 替代原有的 JLine readLine() 模式。
* <p>
* 使用 jink 的 Component 模型实现全屏 TUI
* <ul>
* <li>全屏渲染(alternate screen buffer</li>
* <li>输入区上下有分隔线(最关键的 UI 改进)</li>
* <li>消息列表、状态栏、快捷键栏</li>
* <li>AgentLoop 在后台线程运行,回调驱动 UI 更新</li>
* </ul>
*/
public class JinkReplSession {
private static final Logger log = LoggerFactory.getLogger(JinkReplSession.class);
private final AgentLoop agentLoop;
private final ToolRegistry toolRegistry;
private final CommandRegistry commandRegistry;
private final ProviderInfo providerInfo;
private final ConversationPersistence persistence;
private final TokenTracker tokenTracker;
private ClaudeCodeComponent component;
private Ink.Instance inkApp;
private String conversationSummary = "";
public JinkReplSession(AgentLoop agentLoop,
ToolRegistry toolRegistry,
CommandRegistry commandRegistry,
ProviderInfo providerInfo,
TokenTracker tokenTracker) {
this.agentLoop = agentLoop;
this.toolRegistry = toolRegistry;
this.commandRegistry = commandRegistry;
this.providerInfo = providerInfo;
this.persistence = new ConversationPersistence();
this.tokenTracker = tokenTracker;
}
/**
* 启动 jink TUI 会话。
*/
public void start() {
try {
startJink();
} catch (Exception e) {
log.error("Jink TUI startup failed: {}", e.getMessage(), e);
System.err.println("TUI startup failed: " + e.getMessage());
System.err.println("Please use a terminal that supports ANSI escape codes.");
}
}
private void startJink() {
// 创建主组件
component = new ClaudeCodeComponent(
agentLoop,
commandRegistry,
toolRegistry,
providerInfo.provider(),
providerInfo.model(),
providerInfo.baseUrl(),
toolRegistry.size(),
commandRegistry.getCommands().size(),
tokenTracker,
this::exit
);
// 注册 AgentLoop 回调
setupAgentCallbacks();
setupToolContextCallbacks();
// 注册首次用户输入回调(用于对话摘要)
component.setOnFirstUserInput(text -> {
conversationSummary = text.length() > 40 ? text.substring(0, 40) : text;
});
// 启动 jink 渲染(exitOnCtrlC=false,让组件处理 Ctrl+C
inkApp = Ink.render(component, false);
// 拦截 System.out/err,防止日志干扰 TUI
inkApp.patchConsole();
// 阻塞等待退出
inkApp.waitUntilExit();
// 退出后保存对话
saveConversation();
}
/** 注册 AgentLoop 事件回调,驱动 TUI 更新 */
private void setupAgentCallbacks() {
// 工具调用事件
agentLoop.setOnToolEvent(event -> {
switch (event.phase()) {
case START -> {
// 完成当前流式消息(如果有)
finishCurrentStreaming();
component.addMessage(new ToolCallMsg(
event.toolName(),
event.arguments(),
null,
true
));
}
case END -> {
component.completeLastToolCall(event.result());
}
}
});
// 流式输出第一个 token
agentLoop.setOnStreamStart(() -> {
component.setThinking(false, "");
});
// 阻塞模式回调(流式模式不使用)
agentLoop.setOnAssistantMessage(text -> {});
// 权限确认回调
agentLoop.setOnPermissionRequest(request -> {
return promptPermissionInTui(request);
});
// Thinking 内容回调
agentLoop.setOnThinkingContent(thinkingText -> {
component.setThinking(true, thinkingText);
component.addMessage(new ThinkingMsg(thinkingText));
});
}
/** 注册 ToolContext 回调(AskUser */
private void setupToolContextCallbacks() {
var toolContext = agentLoop.getToolContext();
if (toolContext != null) {
// 简单文本回调(兜底)
toolContext.set(AskUserQuestionTool.USER_INPUT_CALLBACK,
(Function<String, String>) this::askUserInTui);
// 结构化回调(支持交互式选择)
toolContext.set(AskUserQuestionTool.ASK_USER_STRUCTURED_CALLBACK,
(java.util.function.BiFunction<String, java.util.List<String>, String>) this::askUserStructured);
}
}
/** 在 TUI 中请求权限确认 */
private PermissionChoice promptPermissionInTui(AgentLoop.PermissionRequest request) {
// 完成当前流式消息
finishCurrentStreaming();
// 添加权限请求消息
boolean isDangerous = request.decision() != null
&& request.decision().reason() != null
&& request.decision().reason().startsWith("⚠ DANGEROUS");
String suggestedRule = null;
if (request.decision() != null && request.decision().commandPrefix() != null) {
suggestedRule = request.toolName() + "(" + request.decision().commandPrefix() + ":*)";
}
component.addMessage(new PermissionMsg(
request.toolName(),
request.activityDescription(),
request.arguments(),
isDangerous,
suggestedRule,
false
));
// 使用 CompletableFuture 阻塞等待用户输入
CompletableFuture<String> future = new CompletableFuture<>();
component.requestPermission(future::complete);
try {
String answer = future.get();
answer = answer.strip().toLowerCase();
return switch (answer) {
case "a", "always" -> {
component.addMessage(new SystemMsg(
"✓ Rule saved: always allow " + (suggestedRule != null ? suggestedRule : request.toolName()),
Color.BRIGHT_GREEN));
yield PermissionChoice.ALWAYS_ALLOW;
}
case "d" -> {
component.addMessage(new SystemMsg(
"✗ Rule saved: always deny " + (suggestedRule != null ? suggestedRule : request.toolName()),
Color.BRIGHT_RED));
yield PermissionChoice.ALWAYS_DENY;
}
case "n", "no" -> {
component.addMessage(new SystemMsg("✗ Operation denied", Color.BRIGHT_RED));
yield PermissionChoice.DENY_ONCE;
}
default -> PermissionChoice.ALLOW_ONCE;
};
} catch (Exception e) {
log.error("Permission prompt interrupted", e);
return PermissionChoice.DENY_ONCE;
}
}
/** 在 TUI 中请求用户输入(AskUser 工具 — 简单文本模式) */
private String askUserInTui(String prompt) {
finishCurrentStreaming();
component.addMessage(new SystemMsg(prompt, Color.BRIGHT_CYAN));
CompletableFuture<String> future = new CompletableFuture<>();
component.requestPermission(future::complete);
try {
return future.get();
} catch (Exception e) {
return "(User cancelled)";
}
}
/** 在 TUI 中请求用户输入(结构化模式 — 支持交互式选择) */
private String askUserStructured(String question, java.util.List<String> options) {
finishCurrentStreaming();
// 添加问题到消息列表
component.addMessage(new SystemMsg("🤔 " + question, Color.BRIGHT_CYAN));
CompletableFuture<String> future = new CompletableFuture<>();
if (options != null && !options.isEmpty()) {
// 有选项 — 使用交互式选择
component.requestAskUser(question, options, future::complete);
} else {
// 无选项 — 使用普通输入
component.requestPermission(future::complete);
}
try {
return future.get();
} catch (Exception e) {
return "(User cancelled)";
}
}
/** 完成当前流式消息(如果存在) */
private void finishCurrentStreaming() {
component.finishStreamingMessage();
}
/** 退出并清理 */
private void exit() {
saveConversation();
if (inkApp != null) {
inkApp.exit();
}
}
/** 保存对话历史 */
private void saveConversation() {
var history = agentLoop.getMessageHistory();
if (history.size() > 2) {
var file = persistence.save(history, conversationSummary);
if (file != null) {
log.info("Conversation saved: {}", file.getFileName());
}
}
}
/** 获取对话持久化管理器 */
public ConversationPersistence getPersistence() {
return persistence;
}
}