diff --git a/src/main/java/com/claudecode/command/impl/CopyCommand.java b/src/main/java/com/claudecode/command/impl/CopyCommand.java
new file mode 100644
index 0000000..c74e278
--- /dev/null
+++ b/src/main/java/com/claudecode/command/impl/CopyCommand.java
@@ -0,0 +1,69 @@
+package com.claudecode.command.impl;
+
+import com.claudecode.command.CommandContext;
+import com.claudecode.command.SlashCommand;
+import com.claudecode.console.AnsiStyle;
+import org.springframework.ai.chat.messages.AssistantMessage;
+import org.springframework.ai.chat.messages.Message;
+
+import java.awt.Toolkit;
+import java.awt.datatransfer.StringSelection;
+import java.util.List;
+
+/**
+ * /copy 命令 —— 将最近一次 AI 回复复制到系统剪贴板。
+ *
+ * 从消息历史中提取最后一条 AssistantMessage 的文本内容,
+ * 使用 AWT 剪贴板 API 复制。
+ */
+public class CopyCommand implements SlashCommand {
+
+ @Override
+ public String name() {
+ return "copy";
+ }
+
+ @Override
+ public String description() {
+ return "Copy last AI response to clipboard";
+ }
+
+ @Override
+ public String execute(String args, CommandContext context) {
+ // 从消息历史中查找最后一条助手消息
+ List history = context.agentLoop().getMessageHistory();
+ String lastResponse = null;
+
+ for (int i = history.size() - 1; i >= 0; i--) {
+ Message msg = history.get(i);
+ if (msg instanceof AssistantMessage assistant) {
+ String text = assistant.getText();
+ if (text != null && !text.isBlank()) {
+ lastResponse = text;
+ break;
+ }
+ }
+ }
+
+ if (lastResponse == null) {
+ return AnsiStyle.yellow(" ⚠ 暂无 AI 回复可复制");
+ }
+
+ try {
+ // 使用 AWT 剪贴板
+ StringSelection selection = new StringSelection(lastResponse);
+ Toolkit.getDefaultToolkit().getSystemClipboard().setContents(selection, selection);
+
+ int charCount = lastResponse.length();
+ int lineCount = (int) lastResponse.lines().count();
+ return AnsiStyle.green(" ✓ 已复制到剪贴板")
+ + AnsiStyle.dim(" (" + charCount + " 字符, " + lineCount + " 行)");
+ } catch (java.awt.HeadlessException e) {
+ // 无头环境(如 SSH)无法使用 AWT 剪贴板
+ return AnsiStyle.yellow(" ⚠ 当前环境不支持剪贴板(Headless 模式)\n")
+ + AnsiStyle.dim(" 提示:在有图形界面的终端中运行可使用此功能");
+ } catch (Exception e) {
+ return AnsiStyle.red(" ✗ 复制失败: " + e.getMessage());
+ }
+ }
+}
diff --git a/src/main/java/com/claudecode/command/impl/DiffCommand.java b/src/main/java/com/claudecode/command/impl/DiffCommand.java
new file mode 100644
index 0000000..7e283dc
--- /dev/null
+++ b/src/main/java/com/claudecode/command/impl/DiffCommand.java
@@ -0,0 +1,139 @@
+package com.claudecode.command.impl;
+
+import com.claudecode.command.CommandContext;
+import com.claudecode.command.SlashCommand;
+import com.claudecode.console.AnsiStyle;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * /diff 命令 —— 显示 Git 未提交的变更。
+ *
+ * 对应 claude-code 的 /diff 命令,展示工作区的变更内容:
+ *
+ * - 无参数:显示所有未暂存变更
+ * - --staged:显示已暂存变更
+ * - --stat:仅显示文件统计(不含详细diff)
+ *
+ */
+public class DiffCommand implements SlashCommand {
+
+ @Override
+ public String name() {
+ return "diff";
+ }
+
+ @Override
+ public String description() {
+ return "Show uncommitted git changes";
+ }
+
+ @Override
+ public String execute(String args, CommandContext context) {
+ Path projectDir = Path.of(System.getProperty("user.dir"));
+ if (!Files.isDirectory(projectDir.resolve(".git"))) {
+ return AnsiStyle.yellow(" ⚠ 当前目录不是 Git 仓库");
+ }
+
+ args = args == null ? "" : args.strip();
+
+ try {
+ String diffOutput;
+ String header;
+
+ if (args.contains("--staged")) {
+ diffOutput = runGit(projectDir, "diff", "--staged", "--color=always");
+ header = "Staged Changes";
+ } else if (args.contains("--stat")) {
+ diffOutput = runGit(projectDir, "diff", "--stat", "--color=always");
+ header = "Changes (stat)";
+ } else {
+ // 默认:显示所有变更(未暂存 + 已暂存的统计 + 未跟踪文件)
+ String unstaged = runGit(projectDir, "diff", "--color=always");
+ String staged = runGit(projectDir, "diff", "--staged", "--stat");
+ String untracked = runGit(projectDir, "ls-files", "--others", "--exclude-standard");
+
+ StringBuilder sb = new StringBuilder();
+ sb.append("\n").append(AnsiStyle.bold(" 📋 Git Diff\n"));
+ sb.append(" ").append("─".repeat(50)).append("\n");
+
+ if (!staged.isBlank()) {
+ sb.append("\n").append(AnsiStyle.green(" ▸ Staged:\n"));
+ staged.lines().forEach(l -> sb.append(" ").append(l).append("\n"));
+ }
+
+ if (!unstaged.isBlank()) {
+ sb.append("\n").append(AnsiStyle.yellow(" ▸ Unstaged changes:\n"));
+ // 限制行数避免输出过长
+ long lineCount = unstaged.lines().count();
+ if (lineCount > 100) {
+ unstaged.lines().limit(100).forEach(l -> sb.append(" ").append(l).append("\n"));
+ sb.append(AnsiStyle.dim(" ... (共 " + lineCount + " 行,截断显示前100行)\n"));
+ } else {
+ unstaged.lines().forEach(l -> sb.append(" ").append(l).append("\n"));
+ }
+ }
+
+ if (!untracked.isBlank()) {
+ sb.append("\n").append(AnsiStyle.red(" ▸ Untracked files:\n"));
+ untracked.lines().forEach(l -> sb.append(" ").append(l).append("\n"));
+ }
+
+ if (staged.isBlank() && unstaged.isBlank() && untracked.isBlank()) {
+ sb.append("\n").append(AnsiStyle.green(" ✓ 工作区干净,无变更\n"));
+ }
+
+ return sb.toString();
+ }
+
+ // --staged 或 --stat 模式
+ StringBuilder sb = new StringBuilder();
+ sb.append("\n").append(AnsiStyle.bold(" 📋 " + header + "\n"));
+ sb.append(" ").append("─".repeat(50)).append("\n\n");
+
+ if (diffOutput.isBlank()) {
+ sb.append(AnsiStyle.green(" ✓ 无变更\n"));
+ } else {
+ long lineCount = diffOutput.lines().count();
+ if (lineCount > 100) {
+ diffOutput.lines().limit(100).forEach(l -> sb.append(" ").append(l).append("\n"));
+ sb.append(AnsiStyle.dim(" ... (共 " + lineCount + " 行)\n"));
+ } else {
+ diffOutput.lines().forEach(l -> sb.append(" ").append(l).append("\n"));
+ }
+ }
+
+ return sb.toString();
+
+ } catch (Exception e) {
+ return AnsiStyle.red(" ✗ Git diff 执行失败: " + e.getMessage());
+ }
+ }
+
+ private String runGit(Path dir, String... args) throws Exception {
+ var command = new java.util.ArrayList();
+ command.add("git");
+ command.add("--no-pager");
+ command.addAll(java.util.List.of(args));
+
+ ProcessBuilder pb = new ProcessBuilder(command);
+ pb.directory(dir.toFile());
+ pb.redirectErrorStream(true);
+
+ Process process = pb.start();
+ StringBuilder output = new StringBuilder();
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ output.append(line).append("\n");
+ }
+ }
+
+ process.waitFor(10, TimeUnit.SECONDS);
+ return output.toString().stripTrailing();
+ }
+}
diff --git a/src/main/java/com/claudecode/command/impl/MemoryCommand.java b/src/main/java/com/claudecode/command/impl/MemoryCommand.java
new file mode 100644
index 0000000..b9a2177
--- /dev/null
+++ b/src/main/java/com/claudecode/command/impl/MemoryCommand.java
@@ -0,0 +1,157 @@
+package com.claudecode.command.impl;
+
+import com.claudecode.command.CommandContext;
+import com.claudecode.command.SlashCommand;
+import com.claudecode.console.AnsiStyle;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+/**
+ * /memory 命令 —— 查看和编辑 CLAUDE.md 记忆文件。
+ *
+ * 对应 claude-code 的 /memory 命令,支持:
+ *
+ * - /memory —— 显示当前 CLAUDE.md 内容
+ * - /memory add [内容] —— 追加内容到项目级 CLAUDE.md
+ * - /memory edit —— 用系统编辑器打开 CLAUDE.md
+ * - /memory user —— 查看用户级 CLAUDE.md
+ *
+ */
+public class MemoryCommand implements SlashCommand {
+
+ @Override
+ public String name() {
+ return "memory";
+ }
+
+ @Override
+ public String description() {
+ return "View/edit CLAUDE.md memory files";
+ }
+
+ @Override
+ public List aliases() {
+ return List.of("mem");
+ }
+
+ @Override
+ public String execute(String args, CommandContext context) {
+ args = args == null ? "" : args.strip();
+
+ if (args.startsWith("add ")) {
+ return handleAdd(args.substring(4).strip());
+ } else if (args.equals("edit")) {
+ return handleEdit();
+ } else if (args.equals("user")) {
+ return showUserMemory();
+ } else {
+ return showProjectMemory();
+ }
+ }
+
+ /** 显示项目级 CLAUDE.md */
+ private String showProjectMemory() {
+ Path projectClaudeMd = Path.of(System.getProperty("user.dir"), "CLAUDE.md");
+ return showMemoryFile(projectClaudeMd, "项目级");
+ }
+
+ /** 显示用户级 CLAUDE.md */
+ private String showUserMemory() {
+ Path userClaudeMd = Path.of(System.getProperty("user.home"), ".claude", "CLAUDE.md");
+ return showMemoryFile(userClaudeMd, "用户级");
+ }
+
+ private String showMemoryFile(Path path, String level) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("\n");
+ sb.append(AnsiStyle.bold(" 📝 CLAUDE.md (" + level + ")\n"));
+ sb.append(" ").append("─".repeat(50)).append("\n");
+ sb.append(" ").append(AnsiStyle.dim("Path: " + path)).append("\n\n");
+
+ if (Files.exists(path)) {
+ try {
+ String content = Files.readString(path, StandardCharsets.UTF_8);
+ if (content.isBlank()) {
+ sb.append(AnsiStyle.dim(" (文件为空)\n"));
+ } else {
+ content.lines().forEach(line -> sb.append(" ").append(line).append("\n"));
+ }
+ } catch (IOException e) {
+ sb.append(AnsiStyle.red(" ✗ 读取失败: " + e.getMessage() + "\n"));
+ }
+ } else {
+ sb.append(AnsiStyle.dim(" (文件不存在)\n\n"));
+ sb.append(AnsiStyle.dim(" 使用 /memory add <内容> 创建并添加内容\n"));
+ sb.append(AnsiStyle.dim(" 或使用 /init 命令初始化\n"));
+ }
+
+ return sb.toString();
+ }
+
+ /** 追加内容到项目级 CLAUDE.md */
+ private String handleAdd(String content) {
+ if (content.isEmpty()) {
+ return AnsiStyle.yellow(" ⚠ 请提供要添加的内容:/memory add <内容>");
+ }
+
+ Path projectClaudeMd = Path.of(System.getProperty("user.dir"), "CLAUDE.md");
+ try {
+ // 确保文件存在
+ if (!Files.exists(projectClaudeMd)) {
+ Files.writeString(projectClaudeMd,
+ "# CLAUDE.md\n\n" + content + "\n",
+ StandardCharsets.UTF_8);
+ return AnsiStyle.green(" ✓ 已创建 CLAUDE.md 并添加内容");
+ }
+
+ // 追加内容
+ String existing = Files.readString(projectClaudeMd, StandardCharsets.UTF_8);
+ String newContent = existing.endsWith("\n") ? existing + "\n" + content + "\n" : existing + "\n\n" + content + "\n";
+ Files.writeString(projectClaudeMd, newContent, StandardCharsets.UTF_8);
+
+ return AnsiStyle.green(" ✓ 已追加内容到 CLAUDE.md");
+ } catch (IOException e) {
+ return AnsiStyle.red(" ✗ 写入失败: " + e.getMessage());
+ }
+ }
+
+ /** 用系统编辑器打开 CLAUDE.md */
+ private String handleEdit() {
+ Path projectClaudeMd = Path.of(System.getProperty("user.dir"), "CLAUDE.md");
+ try {
+ if (!Files.exists(projectClaudeMd)) {
+ Files.writeString(projectClaudeMd, "# CLAUDE.md\n\n", StandardCharsets.UTF_8);
+ }
+
+ // 尝试用系统编辑器打开
+ String editor = System.getenv("EDITOR");
+ if (editor == null || editor.isBlank()) {
+ editor = System.getenv("VISUAL");
+ }
+
+ if (editor != null && !editor.isBlank()) {
+ ProcessBuilder pb = new ProcessBuilder(editor, projectClaudeMd.toString());
+ pb.inheritIO();
+ Process p = pb.start();
+ p.waitFor();
+ return AnsiStyle.green(" ✓ 编辑器已关闭");
+ }
+
+ // Windows: 尝试 notepad
+ if (System.getProperty("os.name").toLowerCase().contains("win")) {
+ ProcessBuilder pb = new ProcessBuilder("notepad", projectClaudeMd.toString());
+ pb.start(); // 不等待
+ return AnsiStyle.green(" ✓ 已用记事本打开 CLAUDE.md");
+ }
+
+ return AnsiStyle.yellow(" ⚠ 未找到编辑器。请设置 EDITOR 环境变量,或手动编辑:\n " + projectClaudeMd);
+
+ } catch (Exception e) {
+ return AnsiStyle.red(" ✗ 打开编辑器失败: " + e.getMessage());
+ }
+ }
+}
diff --git a/src/main/java/com/claudecode/command/impl/SkillsCommand.java b/src/main/java/com/claudecode/command/impl/SkillsCommand.java
new file mode 100644
index 0000000..e97437a
--- /dev/null
+++ b/src/main/java/com/claudecode/command/impl/SkillsCommand.java
@@ -0,0 +1,72 @@
+package com.claudecode.command.impl;
+
+import com.claudecode.command.CommandContext;
+import com.claudecode.command.SlashCommand;
+import com.claudecode.console.AnsiStyle;
+import com.claudecode.context.SkillLoader;
+
+import java.nio.file.Path;
+import java.util.List;
+
+/**
+ * /skills 命令 —— 列出所有可用的技能。
+ *
+ * 扫描并显示从用户级、项目级和命令目录加载的技能文件。
+ */
+public class SkillsCommand implements SlashCommand {
+
+ @Override
+ public String name() {
+ return "skills";
+ }
+
+ @Override
+ public String description() {
+ return "List available skills";
+ }
+
+ @Override
+ public String execute(String args, CommandContext context) {
+ Path projectDir = Path.of(System.getProperty("user.dir"));
+ SkillLoader loader = new SkillLoader(projectDir);
+ List skills = loader.loadAll();
+
+ StringBuilder sb = new StringBuilder();
+ sb.append("\n");
+ sb.append(AnsiStyle.bold(" 🎯 Available Skills\n"));
+ sb.append(" ").append("─".repeat(50)).append("\n\n");
+
+ if (skills.isEmpty()) {
+ sb.append(AnsiStyle.dim(" (无可用技能)\n\n"));
+ sb.append(AnsiStyle.dim(" 技能文件放置位置:\n"));
+ sb.append(AnsiStyle.dim(" 用户级: ~/.claude/skills/*.md\n"));
+ sb.append(AnsiStyle.dim(" 项目级: ./.claude/skills/*.md\n"));
+ sb.append(AnsiStyle.dim(" 命令级: ./.claude/commands/*.md\n"));
+ } else {
+ for (SkillLoader.Skill skill : skills) {
+ sb.append(" ").append(AnsiStyle.cyan("▸ ")).append(AnsiStyle.bold(skill.name()));
+
+ // 来源标签
+ String sourceLabel = switch (skill.source()) {
+ case "user" -> AnsiStyle.dim(" [用户级]");
+ case "project" -> AnsiStyle.dim(" [项目级]");
+ case "command" -> AnsiStyle.dim(" [命令]");
+ default -> AnsiStyle.dim(" [" + skill.source() + "]");
+ };
+ sb.append(sourceLabel).append("\n");
+
+ if (!skill.description().isEmpty()) {
+ sb.append(" ").append(skill.description()).append("\n");
+ }
+ if (!skill.whenToUse().isEmpty()) {
+ sb.append(" ").append(AnsiStyle.dim("When: " + skill.whenToUse())).append("\n");
+ }
+ sb.append(" ").append(AnsiStyle.dim("File: " + skill.filePath())).append("\n");
+ sb.append("\n");
+ }
+ sb.append(AnsiStyle.dim(" 共 " + skills.size() + " 个技能\n"));
+ }
+
+ return sb.toString();
+ }
+}
diff --git a/src/main/java/com/claudecode/command/impl/VersionCommand.java b/src/main/java/com/claudecode/command/impl/VersionCommand.java
new file mode 100644
index 0000000..5f646dd
--- /dev/null
+++ b/src/main/java/com/claudecode/command/impl/VersionCommand.java
@@ -0,0 +1,64 @@
+package com.claudecode.command.impl;
+
+import com.claudecode.command.CommandContext;
+import com.claudecode.command.SlashCommand;
+import com.claudecode.console.AnsiStyle;
+
+/**
+ * /version 命令 —— 显示版本和环境信息。
+ *
+ * 展示 Claude Code Java 版本、运行时环境等信息。
+ */
+public class VersionCommand implements SlashCommand {
+
+ /** 当前版本号 */
+ public static final String VERSION = "1.0.0";
+ public static final String BUILD_DATE = "2025-07";
+
+ @Override
+ public String name() {
+ return "version";
+ }
+
+ @Override
+ public String description() {
+ return "Show version information";
+ }
+
+ @Override
+ public String execute(String args, CommandContext context) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("\n");
+ sb.append(AnsiStyle.bold(" 🏷️ Claude Code Java\n"));
+ sb.append(" ").append("─".repeat(40)).append("\n\n");
+
+ sb.append(" ").append(AnsiStyle.bold("Version: "))
+ .append(AnsiStyle.cyan("v" + VERSION)).append("\n");
+ sb.append(" ").append(AnsiStyle.bold("Build: "))
+ .append(BUILD_DATE).append("\n");
+ sb.append(" ").append(AnsiStyle.bold("Java: "))
+ .append(System.getProperty("java.version")).append("\n");
+ sb.append(" ").append(AnsiStyle.bold("JVM: "))
+ .append(System.getProperty("java.vm.name")).append(" ")
+ .append(System.getProperty("java.vm.version")).append("\n");
+ sb.append(" ").append(AnsiStyle.bold("OS: "))
+ .append(System.getProperty("os.name")).append(" ")
+ .append(System.getProperty("os.arch")).append("\n");
+ sb.append(" ").append(AnsiStyle.bold("Spring Boot: "))
+ .append(getSpringBootVersion()).append("\n");
+ sb.append(" ").append(AnsiStyle.bold("Spring AI: "))
+ .append("2.0.0-M4").append("\n");
+
+ return sb.toString();
+ }
+
+ private String getSpringBootVersion() {
+ try {
+ // 从 Spring Boot 包中获取版本
+ String version = org.springframework.boot.SpringBootVersion.getVersion();
+ return version != null ? version : "4.1.0-M2";
+ } catch (Exception e) {
+ return "4.1.0-M2";
+ }
+ }
+}
diff --git a/src/main/java/com/claudecode/config/AppConfig.java b/src/main/java/com/claudecode/config/AppConfig.java
index a072bb0..ca08c6b 100644
--- a/src/main/java/com/claudecode/config/AppConfig.java
+++ b/src/main/java/com/claudecode/config/AppConfig.java
@@ -56,7 +56,9 @@ public class AppConfig {
new WebFetchTool(),
new TodoWriteTool(),
new AgentTool(),
- new NotebookEditTool()
+ new NotebookEditTool(),
+ new WebSearchTool(),
+ new AskUserQuestionTool()
);
return registry;
}
@@ -75,6 +77,11 @@ public class AppConfig {
new InitCommand(),
new ConfigCommand(),
new HistoryCommand(),
+ new DiffCommand(),
+ new VersionCommand(),
+ new SkillsCommand(),
+ new MemoryCommand(),
+ new CopyCommand(),
new ExitCommand()
);
return registry;
diff --git a/src/main/java/com/claudecode/core/AgentLoop.java b/src/main/java/com/claudecode/core/AgentLoop.java
index b976073..d0dceb3 100644
--- a/src/main/java/com/claudecode/core/AgentLoop.java
+++ b/src/main/java/com/claudecode/core/AgentLoop.java
@@ -19,6 +19,7 @@ import reactor.core.publisher.Flux;
import java.util.*;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
+import java.util.function.Function;
/**
* Agent 循环 —— 对应 claude-code/src/core/query.ts 的 agent loop。
@@ -32,7 +33,7 @@ import java.util.function.Consumer;
*
* - 构建 Prompt(消息历史 + 系统提示 + 工具定义)
* - 调用 ChatModel.call() 或 ChatModel.stream()
- * - 检查工具调用 → 执行工具 → 结果回传
+ * - 检查工具调用 → 权限确认 → 执行工具 → 结果回传
* - 循环直到无工具调用或达到最大迭代
*
*/
@@ -62,6 +63,12 @@ public class AgentLoop {
/** 流式输出开始回调:通知 UI 停止 spinner */
private Runnable onStreamStart;
+ /** 权限确认回调:危险操作前请求用户确认(返回 true 表示允许) */
+ private Function onPermissionRequest;
+
+ /** Thinking 内容回调:显示 AI 的思考过程 */
+ private Consumer onThinkingContent;
+
public AgentLoop(ChatModel chatModel, ToolRegistry toolRegistry,
ToolContext toolContext, String systemPrompt) {
this(chatModel, toolRegistry, toolContext, systemPrompt, new TokenTracker());
@@ -89,6 +96,14 @@ public class AgentLoop {
this.onStreamStart = onStreamStart;
}
+ public void setOnPermissionRequest(Function onPermissionRequest) {
+ this.onPermissionRequest = onPermissionRequest;
+ }
+
+ public void setOnThinkingContent(Consumer onThinkingContent) {
+ this.onThinkingContent = onThinkingContent;
+ }
+
// ==================== 阻塞模式 ====================
/**
@@ -187,6 +202,9 @@ public class AgentLoop {
completionTokens = usage.getCompletionTokens();
}
+ // 尝试提取 thinking 内容(Anthropic extended thinking)
+ extractThinkingContent(response);
+
return new IterationResult(response.getResult().getOutput(), promptTokens, completionTokens);
}
@@ -251,6 +269,7 @@ public class AgentLoop {
}
/** 执行工具调用列表并将结果加入消息历史 */
+ @SuppressWarnings("unchecked")
private void executeToolCalls(List toolCalls,
List callbacks) {
List toolResponses = new ArrayList<>();
@@ -267,7 +286,27 @@ public class AgentLoop {
String result;
ToolCallbackAdapter adapter = findCallbackByName(callbacks, toolName);
if (adapter != null) {
- result = adapter.call(toolArgs);
+ // 权限确认:非只读工具需要用户确认
+ boolean permitted = true;
+ if (!adapter.getTool().isReadOnly() && onPermissionRequest != null) {
+ try {
+ Map parsedArgs = MAPPER.readValue(toolArgs, Map.class);
+ String activity = adapter.getTool().activityDescription(parsedArgs);
+ PermissionRequest req = new PermissionRequest(toolName, toolArgs, activity);
+ permitted = onPermissionRequest.apply(req);
+ } catch (Exception e) {
+ // JSON 解析失败时仍然请求确认
+ PermissionRequest req = new PermissionRequest(toolName, toolArgs, "执行 " + toolName);
+ permitted = onPermissionRequest.apply(req);
+ }
+ }
+
+ if (permitted) {
+ result = adapter.call(toolArgs);
+ } else {
+ result = "Permission denied: 用户拒绝了此操作";
+ log.info("[{}] 用户拒绝工具执行", toolName);
+ }
} else {
result = "Error: Unknown tool '" + toolName + "'";
log.warn("未知工具: {}", toolName);
@@ -313,6 +352,11 @@ public class AgentLoop {
return chatModel;
}
+ /** 获取工具上下文(用于注册回调) */
+ public ToolContext getToolContext() {
+ return toolContext;
+ }
+
/** 重置历史(保留系统提示词) */
public void reset() {
messageHistory.clear();
@@ -328,6 +372,51 @@ public class AgentLoop {
/** 单次迭代结果 */
private record IterationResult(AssistantMessage assistant, long promptTokens, long completionTokens) {}
+ /**
+ * 从 ChatResponse 中尝试提取 thinking 内容。
+ *
+ * Anthropic 的 extended thinking 功能会在响应中包含思考过程。
+ * Spring AI 可能将其放在 metadata 中或作为独立的消息属性。
+ */
+ private void extractThinkingContent(ChatResponse response) {
+ if (onThinkingContent == null) return;
+
+ try {
+ // 方式1: 检查 response metadata 中的 thinking 字段
+ if (response.getMetadata() != null) {
+ var metadata = response.getMetadata();
+ // Spring AI 可能在 metadata 中存储 thinking 内容
+ // 不同版本可能有不同的 key
+ if (metadata instanceof Map, ?> metaMap) {
+ Object thinking = metaMap.get("thinking");
+ if (thinking instanceof String thinkText && !thinkText.isBlank()) {
+ onThinkingContent.accept(thinkText);
+ return;
+ }
+ }
+ }
+
+ // 方式2: 检查 AssistantMessage 的 metadata
+ if (response.getResult() != null && response.getResult().getOutput() != null) {
+ var output = response.getResult().getOutput();
+ var msgMeta = output.getMetadata();
+ if (msgMeta != null) {
+ // 尝试获取 thinking 相关的元数据
+ Object thinking = msgMeta.get("thinking");
+ if (thinking instanceof String thinkText && !thinkText.isBlank()) {
+ onThinkingContent.accept(thinkText);
+ }
+ }
+ }
+ } catch (Exception e) {
+ // thinking 提取失败不影响主流程
+ log.debug("Thinking 内容提取异常(可忽略): {}", e.getMessage());
+ }
+ }
+
+ /** 权限确认请求 */
+ public record PermissionRequest(String toolName, String arguments, String activityDescription) {}
+
/** 工具事件,用于 UI 展示 */
public record ToolEvent(String toolName, Phase phase, String arguments, String result) {
public enum Phase { START, END }
diff --git a/src/main/java/com/claudecode/repl/ReplSession.java b/src/main/java/com/claudecode/repl/ReplSession.java
index 06179c0..694098e 100644
--- a/src/main/java/com/claudecode/repl/ReplSession.java
+++ b/src/main/java/com/claudecode/repl/ReplSession.java
@@ -7,6 +7,7 @@ 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;
@@ -22,6 +23,7 @@ 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。
@@ -48,11 +50,17 @@ public class ReplSession {
private final ToolStatusRenderer toolStatusRenderer;
private final MarkdownRenderer markdownRenderer;
private final SpinnerAnimation spinner;
+ private final ThinkingRenderer thinkingRenderer;
/** 对话摘要(取第一次用户输入的前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,
@@ -67,8 +75,20 @@ public class ReplSession {
this.toolStatusRenderer = new ToolStatusRenderer(out);
this.markdownRenderer = new MarkdownRenderer(out);
this.spinner = new SpinnerAnimation(out);
+ this.thinkingRenderer = new ThinkingRenderer(out);
setupAgentCallbacks();
+ setupToolContextCallbacks();
+ }
+
+ /** 注册 ToolContext 回调(AskUser 用户输入) */
+ private void setupToolContextCallbacks() {
+ // 注册 AskUserQuestionTool 所需的用户输入回调
+ var toolContext = agentLoop.getToolContext();
+ if (toolContext != null) {
+ toolContext.set(AskUserQuestionTool.USER_INPUT_CALLBACK,
+ (Function) this::readUserInputDuringAgentLoop);
+ }
}
/** 注册 AgentLoop 事件回调,驱动控制台 UI 渲染 */
@@ -89,6 +109,18 @@ public class ReplSession {
agentLoop.setOnAssistantMessage(text -> {
// 阻塞模式回调:流式模式下由 onToken 实时输出,此回调不触发
});
+
+ // 权限确认回调:非只读工具执行前请求用户确认
+ agentLoop.setOnPermissionRequest(request -> {
+ spinner.stop();
+ return promptPermission(request);
+ });
+
+ // Thinking 内容回调:显示 AI 思考过程
+ agentLoop.setOnThinkingContent(thinkingText -> {
+ spinner.stop();
+ thinkingRenderer.render(thinkingText);
+ });
}
/**
@@ -143,6 +175,9 @@ public class ReplSession {
printBanner(terminal);
+ // 设置活跃的 reader,供 AskUser 和权限确认使用
+ this.activeReader = reader;
+
CommandContext cmdContext = new CommandContext(agentLoop, toolRegistry, out, () -> running = false);
while (running) {
@@ -212,6 +247,7 @@ public class ReplSession {
out.println();
Scanner scanner = new Scanner(System.in);
+ this.activeScanner = scanner;
CommandContext cmdContext = new CommandContext(agentLoop, toolRegistry, out, () -> running = false);
while (running) {
@@ -292,4 +328,92 @@ public class ReplSession {
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;
+ }
}
diff --git a/src/main/java/com/claudecode/tool/impl/AskUserQuestionTool.java b/src/main/java/com/claudecode/tool/impl/AskUserQuestionTool.java
new file mode 100644
index 0000000..c78d52d
--- /dev/null
+++ b/src/main/java/com/claudecode/tool/impl/AskUserQuestionTool.java
@@ -0,0 +1,126 @@
+package com.claudecode.tool.impl;
+
+import com.claudecode.tool.Tool;
+import com.claudecode.tool.ToolContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Map;
+import java.util.function.Function;
+
+/**
+ * 用户提问工具 —— AI 在执行过程中向用户提问并获取回答。
+ *
+ * 对应 claude-code 的 AskUserQuestionTool,允许 AI 在需要澄清信息时
+ * 暂停执行并向用户提问。用户的回答会作为工具返回值传回 AI。
+ *
+ * 依赖 ToolContext 中注册的 {@code USER_INPUT_CALLBACK} 回调函数,
+ * 该回调由 ReplSession 在启动时设置,用于读取终端用户输入。
+ */
+public class AskUserQuestionTool implements Tool {
+
+ private static final Logger log = LoggerFactory.getLogger(AskUserQuestionTool.class);
+
+ /** ToolContext 中用于读取用户输入的回调 Key */
+ public static final String USER_INPUT_CALLBACK = "ask_user_input_callback";
+
+ @Override
+ public String name() {
+ return "AskUserQuestion";
+ }
+
+ @Override
+ public String description() {
+ return "Ask the user a question and wait for their response. Use this when you need clarification, " +
+ "confirmation, or additional information from the user to proceed with a task. " +
+ "The question should be clear, specific, and actionable.";
+ }
+
+ @Override
+ public String inputSchema() {
+ return """
+ {
+ "type": "object",
+ "properties": {
+ "question": {
+ "type": "string",
+ "description": "The question to ask the user. Should be clear and specific."
+ },
+ "options": {
+ "type": "array",
+ "items": { "type": "string" },
+ "description": "Optional list of choices for the user to pick from"
+ }
+ },
+ "required": ["question"]
+ }
+ """;
+ }
+
+ @Override
+ public boolean isReadOnly() {
+ return true;
+ }
+
+ @Override
+ public String activityDescription(Map input) {
+ return "Asking user a question...";
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public String execute(Map input, ToolContext context) {
+ String question = (String) input.get("question");
+ if (question == null || question.isBlank()) {
+ return "Error: question parameter is required";
+ }
+
+ // 获取用户输入回调
+ Object callback = context.get(USER_INPUT_CALLBACK);
+ if (callback == null) {
+ log.warn("未注册用户输入回调(USER_INPUT_CALLBACK),返回默认回复");
+ return "Error: User input not available in current environment";
+ }
+
+ if (!(callback instanceof Function, ?> inputFn)) {
+ return "Error: Invalid user input callback type";
+ }
+
+ try {
+ Function askUser = (Function) inputFn;
+
+ // 构建提问文本
+ StringBuilder prompt = new StringBuilder();
+ prompt.append("\n 🤔 AI 正在向你提问:\n");
+ prompt.append(" ").append("─".repeat(50)).append("\n");
+ prompt.append(" ").append(question).append("\n");
+
+ // 如果有选项
+ if (input.containsKey("options")) {
+ var options = (java.util.List) input.get("options");
+ if (options != null && !options.isEmpty()) {
+ prompt.append("\n 可选项:\n");
+ for (int i = 0; i < options.size(); i++) {
+ prompt.append(" ").append(i + 1).append(". ").append(options.get(i)).append("\n");
+ }
+ }
+ }
+
+ prompt.append(" ").append("─".repeat(50)).append("\n");
+
+ // 调用回调获取用户输入
+ String userResponse = askUser.apply(prompt.toString());
+
+ if (userResponse == null || userResponse.isBlank()) {
+ return "(User provided no response)";
+ }
+
+ log.debug("用户回答: {}", userResponse);
+ return "User response: " + userResponse;
+
+ } catch (Exception e) {
+ log.error("获取用户输入失败", e);
+ return "Error: Failed to get user input - " + e.getMessage();
+ }
+ }
+}
diff --git a/src/main/java/com/claudecode/tool/impl/WebSearchTool.java b/src/main/java/com/claudecode/tool/impl/WebSearchTool.java
new file mode 100644
index 0000000..a71e7cb
--- /dev/null
+++ b/src/main/java/com/claudecode/tool/impl/WebSearchTool.java
@@ -0,0 +1,230 @@
+package com.claudecode.tool.impl;
+
+import com.claudecode.tool.Tool;
+import com.claudecode.tool.PermissionResult;
+import com.claudecode.tool.ToolContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * 网络搜索工具 —— 使用 DuckDuckGo HTML 搜索(免费,无需 API Key)。
+ *
+ * 对应 claude-code 的 WebSearchTool,用于搜索互联网获取实时信息。
+ * 通过解析 DuckDuckGo HTML 搜索结果页面提取搜索结果。
+ */
+public class WebSearchTool implements Tool {
+
+ private static final Logger log = LoggerFactory.getLogger(WebSearchTool.class);
+
+ /** DuckDuckGo HTML 搜索端点(不需要 JavaScript) */
+ private static final String DDG_URL = "https://html.duckduckgo.com/html/";
+ private static final int MAX_RESULTS = 8;
+ private static final Duration TIMEOUT = Duration.ofSeconds(15);
+
+ @Override
+ public String name() {
+ return "WebSearch";
+ }
+
+ @Override
+ public String description() {
+ return "Search the web using DuckDuckGo. Returns search results with titles, URLs and snippets. " +
+ "Use this to find up-to-date information, documentation, or answers to questions.";
+ }
+
+ @Override
+ public String inputSchema() {
+ return """
+ {
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": "Search query string"
+ },
+ "maxResults": {
+ "type": "integer",
+ "description": "Maximum number of results to return (default: 8)"
+ }
+ },
+ "required": ["query"]
+ }
+ """;
+ }
+
+ @Override
+ public boolean isReadOnly() {
+ return true;
+ }
+
+ @Override
+ public String activityDescription(Map input) {
+ String query = (String) input.getOrDefault("query", "");
+ return "Searching: " + query;
+ }
+
+ @Override
+ public String execute(Map input, ToolContext context) {
+ String query = (String) input.get("query");
+ if (query == null || query.isBlank()) {
+ return "Error: query parameter is required";
+ }
+
+ int maxResults = MAX_RESULTS;
+ if (input.containsKey("maxResults")) {
+ maxResults = ((Number) input.get("maxResults")).intValue();
+ maxResults = Math.max(1, Math.min(maxResults, 20));
+ }
+
+ try {
+ String html = fetchSearchPage(query);
+ return parseResults(html, maxResults);
+ } catch (Exception e) {
+ log.error("搜索失败: query={}", query, e);
+ return "Error: Search failed - " + e.getMessage();
+ }
+ }
+
+ /** 请求 DuckDuckGo HTML 搜索页面 */
+ private String fetchSearchPage(String query) throws IOException, InterruptedException {
+ String encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8);
+
+ HttpClient client = HttpClient.newBuilder()
+ .connectTimeout(TIMEOUT)
+ .followRedirects(HttpClient.Redirect.NORMAL)
+ .build();
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(DDG_URL + "?q=" + encodedQuery))
+ .header("User-Agent", "Mozilla/5.0 (compatible; ClaudeCodeJava/1.0)")
+ .GET()
+ .timeout(TIMEOUT)
+ .build();
+
+ HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString());
+
+ if (response.statusCode() != 200) {
+ throw new IOException("HTTP " + response.statusCode());
+ }
+
+ return response.body();
+ }
+
+ /** 从 DuckDuckGo HTML 页面解析搜索结果 */
+ private String parseResults(String html, int maxResults) {
+ StringBuilder sb = new StringBuilder();
+
+ // DuckDuckGo HTML 搜索结果格式:
+ // Title
+ // Snippet
+ // 或者结果块在
+
+ // 提取结果链接和标题
+ Pattern resultPattern = Pattern.compile(
+ "
]+class=\"result__a\"[^>]*href=\"([^\"]*)\"[^>]*>(.*?)",
+ Pattern.DOTALL);
+
+ // 提取摘要
+ Pattern snippetPattern = Pattern.compile(
+ "
]+class=\"result__snippet\"[^>]*>(.*?)",
+ Pattern.DOTALL);
+
+ Matcher resultMatcher = resultPattern.matcher(html);
+ Matcher snippetMatcher = snippetPattern.matcher(html);
+
+ int count = 0;
+ while (resultMatcher.find() && count < maxResults) {
+ count++;
+ String url = resultMatcher.group(1);
+ String title = stripHtml(resultMatcher.group(2));
+
+ // DuckDuckGo 的链接是重定向格式,提取实际 URL
+ if (url.contains("uddg=")) {
+ try {
+ String decoded = java.net.URLDecoder.decode(
+ url.substring(url.indexOf("uddg=") + 5), StandardCharsets.UTF_8);
+ // 截取到 & 之前
+ int ampIdx = decoded.indexOf('&');
+ if (ampIdx > 0) decoded = decoded.substring(0, ampIdx);
+ url = decoded;
+ } catch (Exception ignored) {}
+ }
+
+ String snippet = "";
+ if (snippetMatcher.find()) {
+ snippet = stripHtml(snippetMatcher.group(1));
+ }
+
+ sb.append(count).append(". ").append(title).append("\n");
+ sb.append(" URL: ").append(url).append("\n");
+ if (!snippet.isBlank()) {
+ sb.append(" ").append(snippet).append("\n");
+ }
+ sb.append("\n");
+ }
+
+ if (count == 0) {
+ // 尝试备用解析模式
+ return parseResultsFallback(html, maxResults);
+ }
+
+ return sb.toString();
+ }
+
+ /** 备用解析:简单的链接提取 */
+ private String parseResultsFallback(String html, int maxResults) {
+ StringBuilder sb = new StringBuilder();
+
+ // 提取所有外部链接
+ Pattern linkPattern = Pattern.compile("
]+href=\"(https?://[^\"]*)\"[^>]*>(.*?)", Pattern.DOTALL);
+ Matcher matcher = linkPattern.matcher(html);
+
+ int count = 0;
+ java.util.Set
seenUrls = new java.util.HashSet<>();
+
+ while (matcher.find() && count < maxResults) {
+ String url = matcher.group(1);
+ String title = stripHtml(matcher.group(2));
+
+ // 跳过 DuckDuckGo 自身链接和重复
+ if (url.contains("duckduckgo.com") || title.isBlank() || !seenUrls.add(url)) {
+ continue;
+ }
+
+ count++;
+ sb.append(count).append(". ").append(title).append("\n");
+ sb.append(" URL: ").append(url).append("\n\n");
+ }
+
+ if (count == 0) {
+ return "No results found. Try a different query.";
+ }
+
+ return sb.toString();
+ }
+
+ /** 去除 HTML 标签 */
+ private String stripHtml(String html) {
+ if (html == null) return "";
+ return html.replaceAll("<[^>]+>", "")
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll(""", "\"")
+ .replaceAll("'", "'")
+ .replaceAll(" ", " ")
+ .strip();
+ }
+}
diff --git a/需求文档.md b/需求文档.md
index 760ddb6..f499cbf 100644
--- a/需求文档.md
+++ b/需求文档.md
@@ -269,32 +269,136 @@ allowed-tools: [BashTool, FileReadTool]
## 4. 验收标准
### 4.1 基础功能
-- [ ] 应用可正常启动并显示 Banner
-- [ ] 支持通过环境变量或配置文件设置 API Key 和模型
-- [ ] REPL 循环正常工作:输入问题 → 获得 AI 回复
-- [ ] 工具调用循环正常:AI 可以调用工具并获取结果
-- [ ] 至少支持 8 个核心工具(Bash、FileRead、FileWrite、FileEdit、Glob、Grep、WebFetch、TodoWrite)
-- [ ] 至少支持 8 个斜杠命令(/help、/clear、/compact、/exit、/cost、/model、/memory、/context)
+- [x] 应用可正常启动并显示 Banner
+- [x] 支持通过环境变量或配置文件设置 API Key 和模型
+- [x] REPL 循环正常工作:输入问题 → 获得 AI 回复
+- [x] 工具调用循环正常:AI 可以调用工具并获取结果
+- [x] 至少支持 8 个核心工具(实际 11 个)
+- [x] 至少支持 8 个斜杠命令(实际 11 个)
### 4.2 交互体验
-- [ ] Banner 显示正常(Logo + 版本 + 模型信息)
-- [ ] 输入提示符支持行编辑和历史记录
-- [ ] Thinking 过程可见
-- [ ] 工具调用时显示 Spinner 动画和状态
-- [ ] 支持 ANSI 颜色输出
-- [ ] Markdown 内容基本格式化
+- [x] Banner 显示正常(Logo + 版本 + 模型信息 + Provider + URL)
+- [x] 输入提示符支持行编辑和历史记录
+- [x] Thinking 过程可见(Spinner 动画 + "Thinking..." 提示)
+- [x] 工具调用时显示 Spinner 动画和状态
+- [x] 支持 ANSI 颜色输出
+- [x] Markdown 内容基本格式化
### 4.3 上下文功能
-- [ ] 能加载项目 CLAUDE.md
-- [ ] 能加载用户级 CLAUDE.md
-- [ ] Git 上下文信息正确收集
-- [ ] 系统提示词包含所有必要信息
+- [x] 能加载项目 CLAUDE.md
+- [x] 能加载用户级 CLAUDE.md
+- [x] Git 上下文信息正确收集
+- [x] 系统提示词包含所有必要信息
### 4.4 代码质量
-- [ ] Maven 编译无错误
-- [ ] 代码结构清晰,包划分合理
-- [ ] 关键逻辑有中文注释
-- [ ] 提供完整的使用文档
+- [x] Maven 编译无错误
+- [x] 代码结构清晰,包划分合理
+- [x] 关键逻辑有中文注释
+- [x] 提供完整的使用文档(README.md)
+
+### 4.5 已超出原始验收标准的功能
+- [x] 流式输出(Flux 逐 token 实时显示)
+- [x] 对话历史持久化(JSON 格式自动保存/加载)
+- [x] AI 驱动的上下文压缩(/compact 生成摘要)
+- [x] 多行输入支持(反斜杠续行)
+- [x] 双 API 提供者(OpenAI + Anthropic 切换)
+- [x] /history 命令查看保存的对话
+
+---
+
+## 5. 实现状态总结
+
+### 5.1 已实现功能清单
+
+| 类别 | 功能 | 状态 |
+|------|------|------|
+| **REPL 核心** | ChatModel 自定义循环 | ✅ |
+| | 流式输出 (Flux) | ✅ |
+| | 阻塞模式降级 | ✅ |
+| | 最大迭代限制 (50轮) | ✅ |
+| | 消息历史管理 | ✅ |
+| | 对话持久化 | ✅ |
+| **工具系统 (11个)** | BashTool | ✅ |
+| | FileReadTool | ✅ |
+| | FileWriteTool | ✅ |
+| | FileEditTool | ✅ |
+| | GlobTool | ✅ |
+| | GrepTool | ✅ |
+| | WebFetchTool | ✅ |
+| | TodoWriteTool | ✅ |
+| | ListFilesTool | ✅ |
+| | AgentTool | ✅ |
+| | NotebookEditTool | ✅ |
+| **命令系统 (11个)** | /help, /clear, /exit | ✅ |
+| | /compact (AI摘要) | ✅ |
+| | /cost, /model, /status | ✅ |
+| | /context, /config, /init | ✅ |
+| | /history | ✅ |
+| **上下文** | CLAUDE.md 加载 | ✅ |
+| | Skills 技能加载 | ✅ |
+| | Git 上下文收集 | ✅ |
+| | 系统提示词构建 | ✅ |
+| **终端 UI** | Banner + Provider 信息 | ✅ |
+| | JLine 行编辑/历史/Tab补全 | ✅ |
+| | 多行输入 | ✅ |
+| | Spinner 动画 | ✅ |
+| | 工具状态渲染 | ✅ |
+| | ANSI 颜色 | ✅ |
+| | Markdown 渲染 | ✅ |
+| **配置** | 双 API 提供者切换 | ✅ |
+| | 环境变量统一 | ✅ |
+| | Token/费用追踪 | ✅ |
+
+### 5.2 未实现功能清单
+
+#### 🔴 P0 核心(建议实现)
+
+| 功能 | 难度 | 说明 |
+|------|------|------|
+| **WebSearchTool** | 中 | 网络搜索工具,需要搜索 API(可用 DuckDuckGo 免费 API) |
+| **AskUserQuestionTool** | 中 | AI 向用户提问工具,需要在 agent loop 中暂停等待用户输入 |
+| **/diff 命令** | 低 | 显示未提交的 Git 变更,可复用 GitContext |
+| **/version 命令** | 低 | 显示版本号,直接读取 pom.xml 版本 |
+| **/skills 命令** | 低 | 列出可用技能,已有 SkillLoader |
+| **/memory 命令** | 低 | 编辑 CLAUDE.md,打开编辑器或直接编辑 |
+| **/copy 命令** | 低 | 复制最近回复到剪贴板 |
+| **权限确认机制** | 中 | 危险操作前要求用户确认(如文件写入、bash 执行) |
+| **Thinking 内容显示** | 高 | 显示 AI 思考过程(需要 Anthropic extended thinking API 支持) |
+
+#### 🟡 P1 重要(体验增强)
+
+| 功能 | 难度 | 说明 |
+|------|------|------|
+| CLAUDE.local.md 支持 | 低 | 本地级记忆文件 |
+| 底部状态行 | 中 | 持续显示模型、token、工作目录 |
+| Hook 系统 (PreToolUse) | 中 | 工具调用前后钩子 |
+| /resume 恢复对话 | 低 | 从持久化文件恢复对话 |
+| /export 导出对话 | 低 | 导出为 Markdown 文件 |
+| /commit 命令 | 中 | 直接创建 Git commit |
+| Vim 模式输入 | 高 | JLine Vim 模式绑定 |
+| 代码语法高亮 | 中 | MarkdownRenderer 中的代码块着色 |
+
+#### 🟢 P2 扩展(可后续迭代)
+
+| 功能 | 说明 |
+|------|------|
+| MCP 客户端集成 | Model Context Protocol 支持 |
+| 插件系统 | 可扩展的工具/命令加载 |
+| LSP 集成 | 语言服务器协议 |
+| 差异视图 (DiffRenderer) | 文件变更的彩色diff显示 |
+| OAuth 认证流程 | 第三方认证 |
+| 多代理协调 | Agent Swarm 模式 |
+
+#### ⚪ P3 跳过(不适用于 Java CLI)
+
+| 功能 | 原因 |
+|------|------|
+| Ink/React UI 框架 | Java 用 JLine 替代 |
+| IDE 桥接 (bridge/) | 需要独立的 IDE 插件 |
+| 语音功能 (voice/) | 平台特定 |
+| 远程会话 (remote/) | 需要服务端 |
+| 沙箱隔离 (sandbox/) | 需要容器化 |
+| 遥测 (telemetry/) | 非核心功能 |
---