You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
432 lines
16 KiB
432 lines
16 KiB
package com.claudecode.repl;
|
|
|
|
import com.claudecode.config.AppConfig.ProviderInfo;
|
|
import com.claudecode.command.CommandContext;
|
|
import com.claudecode.command.CommandRegistry;
|
|
import com.claudecode.console.*;
|
|
import com.claudecode.core.AgentLoop;
|
|
import com.claudecode.core.ConversationPersistence;
|
|
import com.claudecode.tool.ToolRegistry;
|
|
import com.claudecode.tool.impl.AskUserQuestionTool;
|
|
import org.jline.reader.*;
|
|
import org.jline.reader.impl.DefaultParser;
|
|
import org.jline.terminal.Terminal;
|
|
import org.jline.terminal.TerminalBuilder;
|
|
import org.jline.utils.AttributedStringBuilder;
|
|
import org.jline.utils.AttributedStyle;
|
|
import org.slf4j.Logger;
|
|
import org.slf4j.LoggerFactory;
|
|
|
|
import java.io.IOException;
|
|
import java.io.PrintStream;
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.nio.file.Files;
|
|
import java.nio.file.Path;
|
|
import java.util.Scanner;
|
|
import java.util.function.Function;
|
|
|
|
/**
|
|
* REPL 会话管理器 —— 对应 claude-code/src/REPL.tsx。
|
|
* <p>
|
|
* 使用 JLine 3 提供丰富的终端交互体验:
|
|
* <ul>
|
|
* <li>行编辑(光标移动、删除、粘贴)</li>
|
|
* <li>历史记录(上下箭头浏览、持久化到文件)</li>
|
|
* <li>Tab 补全(斜杠命令、工具名称)</li>
|
|
* <li>信号处理(Ctrl+C 取消当前输入、Ctrl+D 退出)</li>
|
|
* </ul>
|
|
* 当 JLine 初始化失败时自动降级到 Scanner 模式。
|
|
*/
|
|
public class ReplSession {
|
|
|
|
private static final Logger log = LoggerFactory.getLogger(ReplSession.class);
|
|
|
|
private final AgentLoop agentLoop;
|
|
private final ToolRegistry toolRegistry;
|
|
private final CommandRegistry commandRegistry;
|
|
private final ProviderInfo providerInfo;
|
|
private final ConversationPersistence persistence;
|
|
private final PrintStream out;
|
|
private final ToolStatusRenderer toolStatusRenderer;
|
|
private final MarkdownRenderer markdownRenderer;
|
|
private final SpinnerAnimation spinner;
|
|
private final ThinkingRenderer thinkingRenderer;
|
|
private final StatusLine statusLine;
|
|
|
|
/** 对话摘要(取第一次用户输入的前40字) */
|
|
private String conversationSummary = "";
|
|
private volatile boolean running = true;
|
|
|
|
/** 当前活跃的 LineReader(JLine 模式下用于 AskUser 和权限确认) */
|
|
private volatile LineReader activeReader;
|
|
/** 当前活跃的 Scanner(Scanner 模式下用于 AskUser 和权限确认) */
|
|
private volatile Scanner activeScanner;
|
|
|
|
public ReplSession(AgentLoop agentLoop,
|
|
ToolRegistry toolRegistry,
|
|
CommandRegistry commandRegistry,
|
|
ProviderInfo providerInfo) {
|
|
this.agentLoop = agentLoop;
|
|
this.toolRegistry = toolRegistry;
|
|
this.commandRegistry = commandRegistry;
|
|
this.providerInfo = providerInfo;
|
|
this.persistence = new ConversationPersistence();
|
|
// 强制使用 UTF-8 编码输出,确保 emoji 等 Unicode 字符在 Windows 终端正常显示
|
|
this.out = new PrintStream(System.out, true, StandardCharsets.UTF_8);
|
|
this.toolStatusRenderer = new ToolStatusRenderer(out);
|
|
this.markdownRenderer = new MarkdownRenderer(out);
|
|
this.spinner = new SpinnerAnimation(out);
|
|
this.thinkingRenderer = new ThinkingRenderer(out);
|
|
this.statusLine = new StatusLine(out);
|
|
|
|
setupAgentCallbacks();
|
|
setupToolContextCallbacks();
|
|
}
|
|
|
|
/** 注册 ToolContext 回调(AskUser 用户输入) */
|
|
private void setupToolContextCallbacks() {
|
|
// 注册 AskUserQuestionTool 所需的用户输入回调
|
|
var toolContext = agentLoop.getToolContext();
|
|
if (toolContext != null) {
|
|
toolContext.set(AskUserQuestionTool.USER_INPUT_CALLBACK,
|
|
(Function<String, String>) this::readUserInputDuringAgentLoop);
|
|
}
|
|
}
|
|
|
|
/** 注册 AgentLoop 事件回调,驱动控制台 UI 渲染 */
|
|
private void setupAgentCallbacks() {
|
|
agentLoop.setOnToolEvent(event -> {
|
|
switch (event.phase()) {
|
|
case START -> {
|
|
spinner.stop();
|
|
toolStatusRenderer.renderStart(event.toolName(), event.arguments());
|
|
}
|
|
case END -> toolStatusRenderer.renderEnd(event.toolName(), event.result());
|
|
}
|
|
});
|
|
|
|
// 流式输出第一个 token 到达时停止 spinner
|
|
agentLoop.setOnStreamStart(() -> spinner.stop());
|
|
|
|
agentLoop.setOnAssistantMessage(text -> {
|
|
// 阻塞模式回调:流式模式下由 onToken 实时输出,此回调不触发
|
|
});
|
|
|
|
// 权限确认回调:非只读工具执行前请求用户确认
|
|
agentLoop.setOnPermissionRequest(request -> {
|
|
spinner.stop();
|
|
return promptPermission(request);
|
|
});
|
|
|
|
// Thinking 内容回调:显示 AI 思考过程
|
|
agentLoop.setOnThinkingContent(thinkingText -> {
|
|
spinner.stop();
|
|
thinkingRenderer.render(thinkingText);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 启动 REPL —— 优先使用 JLine,失败时降级到 Scanner。
|
|
*/
|
|
public void start() {
|
|
try {
|
|
startWithJLine();
|
|
} catch (Exception e) {
|
|
log.warn("JLine 初始化失败,降级到 Scanner 模式: {}", e.getMessage());
|
|
startWithScanner();
|
|
}
|
|
}
|
|
|
|
// ==================== JLine 模式 ====================
|
|
|
|
private void startWithJLine() throws IOException {
|
|
Path historyDir = Path.of(System.getProperty("user.home"), ".claude-code-java");
|
|
Files.createDirectories(historyDir);
|
|
|
|
try (Terminal terminal = TerminalBuilder.builder()
|
|
.system(true)
|
|
.streams(System.in, System.out)
|
|
.build()) {
|
|
|
|
boolean isDumb = "dumb".equals(terminal.getType());
|
|
if (isDumb) {
|
|
log.info("当前为 dumb 终端模式,建议使用 Windows Terminal / PowerShell / cmd 获得完整体验");
|
|
}
|
|
|
|
// 配置 Parser:支持反斜杠续行 (\) 和 三引号块 (""")
|
|
DefaultParser parser = new DefaultParser();
|
|
parser.setEscapeChars(new char[]{'\\'}); // 反斜杠续行
|
|
|
|
LineReader reader = LineReaderBuilder.builder()
|
|
.terminal(terminal)
|
|
.parser(parser)
|
|
.completer(new ClaudeCodeCompleter(commandRegistry, toolRegistry))
|
|
.variable(LineReader.HISTORY_FILE, historyDir.resolve("history"))
|
|
.variable(LineReader.HISTORY_SIZE, 1000)
|
|
.variable(LineReader.SECONDARY_PROMPT_PATTERN, "%P ... ")
|
|
.option(LineReader.Option.CASE_INSENSITIVE, true)
|
|
.option(LineReader.Option.AUTO_LIST, true)
|
|
.build();
|
|
|
|
// 主提示符
|
|
String prompt = new AttributedStringBuilder()
|
|
.style(AttributedStyle.BOLD.foreground(AttributedStyle.CYAN))
|
|
.append("❯ ")
|
|
.style(AttributedStyle.DEFAULT)
|
|
.toAnsi(terminal);
|
|
|
|
printBanner(terminal);
|
|
|
|
// 设置活跃的 reader,供 AskUser 和权限确认使用
|
|
this.activeReader = reader;
|
|
|
|
// 非 dumb 终端启用底部状态行
|
|
boolean isDumb2 = "dumb".equals(terminal.getType());
|
|
if (!isDumb2) {
|
|
statusLine.enable(providerInfo.model(), agentLoop.getTokenTracker());
|
|
}
|
|
|
|
CommandContext cmdContext = new CommandContext(agentLoop, toolRegistry, out, () -> running = false);
|
|
|
|
while (running) {
|
|
String input;
|
|
try {
|
|
input = reader.readLine(prompt).strip();
|
|
} catch (UserInterruptException e) {
|
|
spinner.stop();
|
|
out.println(AnsiStyle.dim(" ^C"));
|
|
continue;
|
|
} catch (EndOfFileException e) {
|
|
break;
|
|
}
|
|
|
|
if (input.isEmpty()) {
|
|
continue;
|
|
}
|
|
|
|
handleInput(input, cmdContext);
|
|
}
|
|
|
|
saveConversation();
|
|
out.println(AnsiStyle.dim("\n Goodbye! 👋\n"));
|
|
}
|
|
}
|
|
|
|
/** 打印启动 Banner(JLine 模式) */
|
|
private void printBanner(Terminal terminal) {
|
|
BannerPrinter.printCompact(out);
|
|
|
|
// 显示 API 提供者、模型和 URL
|
|
out.println(AnsiStyle.dim(" Provider: ") + AnsiStyle.cyan(providerInfo.provider().toUpperCase())
|
|
+ AnsiStyle.dim(" Model: ") + AnsiStyle.cyan(providerInfo.model()));
|
|
out.println(AnsiStyle.dim(" API URL: ") + AnsiStyle.cyan(providerInfo.baseUrl()));
|
|
|
|
out.println(AnsiStyle.dim(" Work Dir: " + System.getProperty("user.dir")));
|
|
out.println(AnsiStyle.dim(" Tools: " + toolRegistry.size() + " registered"));
|
|
|
|
boolean isDumb = "dumb".equals(terminal.getType());
|
|
int w = terminal.getWidth();
|
|
int h = terminal.getHeight();
|
|
String termInfo = terminal.getType();
|
|
if (w > 0 && h > 0) {
|
|
termInfo += " (" + w + "×" + h + ")";
|
|
}
|
|
out.println(AnsiStyle.dim(" Terminal: " + termInfo));
|
|
|
|
if (isDumb) {
|
|
out.println(AnsiStyle.yellow(" ⚠ Dumb 终端模式:Tab补全和行编辑可能受限"));
|
|
out.println(AnsiStyle.yellow(" 建议在 Windows Terminal / PowerShell / cmd.exe 中运行"));
|
|
} else {
|
|
out.println(AnsiStyle.dim(" Tip: Tab to complete commands, ↑↓ to browse history, Ctrl+D to exit"));
|
|
}
|
|
out.println();
|
|
}
|
|
|
|
// ==================== Scanner 降级模式 ====================
|
|
|
|
private void startWithScanner() {
|
|
BannerPrinter.printCompact(out);
|
|
out.println(AnsiStyle.dim(" Provider: ") + AnsiStyle.cyan(providerInfo.provider().toUpperCase())
|
|
+ AnsiStyle.dim(" Model: ") + AnsiStyle.cyan(providerInfo.model()));
|
|
out.println(AnsiStyle.dim(" API URL: ") + AnsiStyle.cyan(providerInfo.baseUrl()));
|
|
out.println(AnsiStyle.dim(" Work Dir: " + System.getProperty("user.dir")));
|
|
out.println(AnsiStyle.dim(" Tools: " + toolRegistry.size() + " registered"));
|
|
out.println(AnsiStyle.dim(" Mode: Scanner (basic input)"));
|
|
out.println();
|
|
|
|
Scanner scanner = new Scanner(System.in);
|
|
this.activeScanner = scanner;
|
|
CommandContext cmdContext = new CommandContext(agentLoop, toolRegistry, out, () -> running = false);
|
|
|
|
while (running) {
|
|
out.print(AnsiStyle.BOLD + AnsiStyle.BRIGHT_CYAN + "❯ " + AnsiStyle.RESET);
|
|
out.flush();
|
|
|
|
String input;
|
|
try {
|
|
if (!scanner.hasNextLine()) break;
|
|
input = scanner.nextLine().strip();
|
|
} catch (Exception e) {
|
|
break;
|
|
}
|
|
|
|
if (input.isEmpty()) continue;
|
|
|
|
handleInput(input, cmdContext);
|
|
}
|
|
|
|
saveConversation();
|
|
out.println(AnsiStyle.dim("\n Goodbye! 👋\n"));
|
|
}
|
|
|
|
/** 处理用户输入(命令分发或 Agent 调用) */
|
|
private void handleInput(String input, CommandContext cmdContext) {
|
|
// 斜杠命令
|
|
if (commandRegistry.isCommand(input)) {
|
|
var result = commandRegistry.dispatch(input, cmdContext);
|
|
result.ifPresent(out::println);
|
|
out.println();
|
|
return;
|
|
}
|
|
|
|
// 记录对话摘要(取第一次用户输入前40字)
|
|
if (conversationSummary.isEmpty()) {
|
|
conversationSummary = input.length() > 40 ? input.substring(0, 40) : input;
|
|
}
|
|
|
|
// Agent 循环(流式输出)
|
|
try {
|
|
spinner.start("Thinking...");
|
|
out.println(); // 换行准备输出区域
|
|
|
|
// 流式回调:逐 token 输出到终端
|
|
String response = agentLoop.runStreaming(input, token -> {
|
|
out.print(token);
|
|
out.flush();
|
|
});
|
|
|
|
spinner.stop();
|
|
out.println(); // 流式输出结束后换行
|
|
|
|
// 刷新底部状态行(显示最新 token 用量)
|
|
if (statusLine.isEnabled()) {
|
|
out.println(statusLine.renderInline());
|
|
}
|
|
out.println();
|
|
} catch (Exception e) {
|
|
spinner.stop();
|
|
out.println(AnsiStyle.red("\n ✗ Error: " + e.getMessage()));
|
|
log.error("Agent 循环异常", e);
|
|
out.println();
|
|
}
|
|
}
|
|
|
|
/** 退出时保存对话历史 */
|
|
private void saveConversation() {
|
|
var history = agentLoop.getMessageHistory();
|
|
// 只有有实际对话内容时才保存(至少包含系统提示+用户消息+助手回复)
|
|
if (history.size() > 2) {
|
|
var file = persistence.save(history, conversationSummary);
|
|
if (file != null) {
|
|
out.println(AnsiStyle.dim(" 💾 对话已保存: " + file.getFileName()));
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 获取对话持久化管理器 */
|
|
public ConversationPersistence getPersistence() {
|
|
return persistence;
|
|
}
|
|
|
|
public void stop() {
|
|
running = false;
|
|
}
|
|
|
|
// ==================== 权限确认 UI ====================
|
|
|
|
/**
|
|
* 显示权限确认提示并等待用户输入。
|
|
* 用于危险操作(文件写入、bash 执行等)前的安全确认。
|
|
*/
|
|
private boolean promptPermission(AgentLoop.PermissionRequest request) {
|
|
out.println();
|
|
out.println(AnsiStyle.yellow(" ⚠ 权限确认"));
|
|
out.println(" " + "─".repeat(50));
|
|
out.println(" " + AnsiStyle.bold("工具: ") + AnsiStyle.cyan(request.toolName()));
|
|
out.println(" " + AnsiStyle.bold("操作: ") + request.activityDescription());
|
|
|
|
// 显示参数摘要(截断过长的参数)
|
|
String argsPreview = request.arguments();
|
|
if (argsPreview != null && argsPreview.length() > 200) {
|
|
argsPreview = argsPreview.substring(0, 200) + "...";
|
|
}
|
|
if (argsPreview != null && !argsPreview.isBlank()) {
|
|
out.println(" " + AnsiStyle.dim("参数: " + argsPreview));
|
|
}
|
|
|
|
out.println(" " + "─".repeat(50));
|
|
out.print(" " + AnsiStyle.bold("允许执行?") + AnsiStyle.dim(" [Y/n/always] ") + AnsiStyle.BOLD + AnsiStyle.BRIGHT_CYAN + "→ " + AnsiStyle.RESET);
|
|
out.flush();
|
|
|
|
String answer = readLineForPermission();
|
|
if (answer == null) return false;
|
|
|
|
answer = answer.strip().toLowerCase();
|
|
|
|
// "always" → 禁用后续权限确认
|
|
if (answer.equals("always") || answer.equals("a")) {
|
|
agentLoop.setOnPermissionRequest(null); // 移除权限回调
|
|
out.println(AnsiStyle.green(" ✓ 已授权所有后续操作"));
|
|
return true;
|
|
}
|
|
|
|
// 空字符串或 y/yes → 允许
|
|
if (answer.isEmpty() || answer.equals("y") || answer.equals("yes")) {
|
|
return true;
|
|
}
|
|
|
|
// 其他输入 → 拒绝
|
|
out.println(AnsiStyle.red(" ✗ 操作已拒绝"));
|
|
return false;
|
|
}
|
|
|
|
/** 读取权限确认的用户输入(兼容 JLine 和 Scanner 模式) */
|
|
private String readLineForPermission() {
|
|
try {
|
|
if (activeReader != null) {
|
|
return activeReader.readLine();
|
|
} else if (activeScanner != null && activeScanner.hasNextLine()) {
|
|
return activeScanner.nextLine();
|
|
}
|
|
} catch (Exception e) {
|
|
log.debug("读取权限确认输入异常: {}", e.getMessage());
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ==================== AskUser 工具回调 ====================
|
|
|
|
/**
|
|
* 在 Agent 循环执行过程中读取用户输入。
|
|
* 被 AskUserQuestionTool 通过 ToolContext 回调使用。
|
|
*/
|
|
private String readUserInputDuringAgentLoop(String prompt) {
|
|
spinner.stop();
|
|
out.print(prompt);
|
|
out.print(" " + AnsiStyle.BOLD + AnsiStyle.BRIGHT_CYAN + "→ " + AnsiStyle.RESET);
|
|
out.flush();
|
|
|
|
try {
|
|
if (activeReader != null) {
|
|
return activeReader.readLine();
|
|
} else if (activeScanner != null && activeScanner.hasNextLine()) {
|
|
return activeScanner.nextLine();
|
|
}
|
|
} catch (UserInterruptException e) {
|
|
return "(用户取消)";
|
|
} catch (Exception e) {
|
|
log.debug("读取用户输入异常: {}", e.getMessage());
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|