feat: P0功能补全 - 5命令+2工具+权限确认+Thinking显示

新增5个命令:
- /diff: 显示Git未提交变更(支持--staged/--stat)
- /version: 显示版本和环境信息
- /skills: 列出所有可用技能
- /memory: 查看/编辑CLAUDE.md(支持add/edit/user子命令)
- /copy: 复制最近AI回复到剪贴板

新增2个工具:
- WebSearchTool: DuckDuckGo搜索(免费,无需API Key)
- AskUserQuestionTool: AI向用户提问(通过ToolContext回调)

权限确认机制:
- 非只读工具执行前提示用户确认(Y/n/always)
- 支持always一次授权全部后续操作
- 在AgentLoop.executeToolCalls中拦截

Thinking内容显示:
- AgentLoop新增onThinkingContent回调
- 从ChatResponse metadata提取thinking内容
- ThinkingRenderer渲染思考过程(缩进暗色格式)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
pull/1/head
liuzh 1 month ago
parent 7dc4d142b3
commit 82a28b7aa7
  1. 69
      src/main/java/com/claudecode/command/impl/CopyCommand.java
  2. 139
      src/main/java/com/claudecode/command/impl/DiffCommand.java
  3. 157
      src/main/java/com/claudecode/command/impl/MemoryCommand.java
  4. 72
      src/main/java/com/claudecode/command/impl/SkillsCommand.java
  5. 64
      src/main/java/com/claudecode/command/impl/VersionCommand.java
  6. 9
      src/main/java/com/claudecode/config/AppConfig.java
  7. 91
      src/main/java/com/claudecode/core/AgentLoop.java
  8. 124
      src/main/java/com/claudecode/repl/ReplSession.java
  9. 126
      src/main/java/com/claudecode/tool/impl/AskUserQuestionTool.java
  10. 230
      src/main/java/com/claudecode/tool/impl/WebSearchTool.java
  11. 144
      需求文档.md

@ -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 回复复制到系统剪贴板
* <p>
* 从消息历史中提取最后一条 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<Message> 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());
}
}
}

@ -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 未提交的变更
* <p>
* 对应 claude-code /diff 命令展示工作区的变更内容
* <ul>
* <li>无参数显示所有未暂存变更</li>
* <li>--staged显示已暂存变更</li>
* <li>--stat仅显示文件统计不含详细diff</li>
* </ul>
*/
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<String>();
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();
}
}

@ -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 记忆文件
* <p>
* 对应 claude-code /memory 命令支持
* <ul>
* <li>/memory 显示当前 CLAUDE.md 内容</li>
* <li>/memory add [内容] 追加内容到项目级 CLAUDE.md</li>
* <li>/memory edit 用系统编辑器打开 CLAUDE.md</li>
* <li>/memory user 查看用户级 CLAUDE.md</li>
* </ul>
*/
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<String> 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());
}
}
}

@ -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 命令 列出所有可用的技能
* <p>
* 扫描并显示从用户级项目级和命令目录加载的技能文件
*/
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<SkillLoader.Skill> 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();
}
}

@ -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 命令 显示版本和环境信息
* <p>
* 展示 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";
}
}
}

@ -56,7 +56,9 @@ public class AppConfig {
new WebFetchTool(), new WebFetchTool(),
new TodoWriteTool(), new TodoWriteTool(),
new AgentTool(), new AgentTool(),
new NotebookEditTool() new NotebookEditTool(),
new WebSearchTool(),
new AskUserQuestionTool()
); );
return registry; return registry;
} }
@ -75,6 +77,11 @@ public class AppConfig {
new InitCommand(), new InitCommand(),
new ConfigCommand(), new ConfigCommand(),
new HistoryCommand(), new HistoryCommand(),
new DiffCommand(),
new VersionCommand(),
new SkillsCommand(),
new MemoryCommand(),
new CopyCommand(),
new ExitCommand() new ExitCommand()
); );
return registry; return registry;

@ -19,6 +19,7 @@ import reactor.core.publisher.Flux;
import java.util.*; import java.util.*;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.function.Function;
/** /**
* Agent 循环 对应 claude-code/src/core/query.ts agent loop * Agent 循环 对应 claude-code/src/core/query.ts agent loop
@ -32,7 +33,7 @@ import java.util.function.Consumer;
* <ol> * <ol>
* <li>构建 Prompt消息历史 + 系统提示 + 工具定义</li> * <li>构建 Prompt消息历史 + 系统提示 + 工具定义</li>
* <li>调用 ChatModel.call() ChatModel.stream()</li> * <li>调用 ChatModel.call() ChatModel.stream()</li>
* <li>检查工具调用 执行工具 结果回传</li> * <li>检查工具调用 权限确认 执行工具 结果回传</li>
* <li>循环直到无工具调用或达到最大迭代</li> * <li>循环直到无工具调用或达到最大迭代</li>
* </ol> * </ol>
*/ */
@ -62,6 +63,12 @@ public class AgentLoop {
/** 流式输出开始回调:通知 UI 停止 spinner */ /** 流式输出开始回调:通知 UI 停止 spinner */
private Runnable onStreamStart; private Runnable onStreamStart;
/** 权限确认回调:危险操作前请求用户确认(返回 true 表示允许) */
private Function<PermissionRequest, Boolean> onPermissionRequest;
/** Thinking 内容回调:显示 AI 的思考过程 */
private Consumer<String> onThinkingContent;
public AgentLoop(ChatModel chatModel, ToolRegistry toolRegistry, public AgentLoop(ChatModel chatModel, ToolRegistry toolRegistry,
ToolContext toolContext, String systemPrompt) { ToolContext toolContext, String systemPrompt) {
this(chatModel, toolRegistry, toolContext, systemPrompt, new TokenTracker()); this(chatModel, toolRegistry, toolContext, systemPrompt, new TokenTracker());
@ -89,6 +96,14 @@ public class AgentLoop {
this.onStreamStart = onStreamStart; this.onStreamStart = onStreamStart;
} }
public void setOnPermissionRequest(Function<PermissionRequest, Boolean> onPermissionRequest) {
this.onPermissionRequest = onPermissionRequest;
}
public void setOnThinkingContent(Consumer<String> onThinkingContent) {
this.onThinkingContent = onThinkingContent;
}
// ==================== 阻塞模式 ==================== // ==================== 阻塞模式 ====================
/** /**
@ -187,6 +202,9 @@ public class AgentLoop {
completionTokens = usage.getCompletionTokens(); completionTokens = usage.getCompletionTokens();
} }
// 尝试提取 thinking 内容(Anthropic extended thinking)
extractThinkingContent(response);
return new IterationResult(response.getResult().getOutput(), promptTokens, completionTokens); return new IterationResult(response.getResult().getOutput(), promptTokens, completionTokens);
} }
@ -251,6 +269,7 @@ public class AgentLoop {
} }
/** 执行工具调用列表并将结果加入消息历史 */ /** 执行工具调用列表并将结果加入消息历史 */
@SuppressWarnings("unchecked")
private void executeToolCalls(List<AssistantMessage.ToolCall> toolCalls, private void executeToolCalls(List<AssistantMessage.ToolCall> toolCalls,
List<ToolCallback> callbacks) { List<ToolCallback> callbacks) {
List<ToolResponseMessage.ToolResponse> toolResponses = new ArrayList<>(); List<ToolResponseMessage.ToolResponse> toolResponses = new ArrayList<>();
@ -267,7 +286,27 @@ public class AgentLoop {
String result; String result;
ToolCallbackAdapter adapter = findCallbackByName(callbacks, toolName); ToolCallbackAdapter adapter = findCallbackByName(callbacks, toolName);
if (adapter != null) { if (adapter != null) {
// 权限确认:非只读工具需要用户确认
boolean permitted = true;
if (!adapter.getTool().isReadOnly() && onPermissionRequest != null) {
try {
Map<String, Object> 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); result = adapter.call(toolArgs);
} else {
result = "Permission denied: 用户拒绝了此操作";
log.info("[{}] 用户拒绝工具执行", toolName);
}
} else { } else {
result = "Error: Unknown tool '" + toolName + "'"; result = "Error: Unknown tool '" + toolName + "'";
log.warn("未知工具: {}", toolName); log.warn("未知工具: {}", toolName);
@ -313,6 +352,11 @@ public class AgentLoop {
return chatModel; return chatModel;
} }
/** 获取工具上下文(用于注册回调) */
public ToolContext getToolContext() {
return toolContext;
}
/** 重置历史(保留系统提示词) */ /** 重置历史(保留系统提示词) */
public void reset() { public void reset() {
messageHistory.clear(); messageHistory.clear();
@ -328,6 +372,51 @@ public class AgentLoop {
/** 单次迭代结果 */ /** 单次迭代结果 */
private record IterationResult(AssistantMessage assistant, long promptTokens, long completionTokens) {} private record IterationResult(AssistantMessage assistant, long promptTokens, long completionTokens) {}
/**
* ChatResponse 中尝试提取 thinking 内容
* <p>
* 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 展示 */ /** 工具事件,用于 UI 展示 */
public record ToolEvent(String toolName, Phase phase, String arguments, String result) { public record ToolEvent(String toolName, Phase phase, String arguments, String result) {
public enum Phase { START, END } public enum Phase { START, END }

@ -7,6 +7,7 @@ import com.claudecode.console.*;
import com.claudecode.core.AgentLoop; import com.claudecode.core.AgentLoop;
import com.claudecode.core.ConversationPersistence; import com.claudecode.core.ConversationPersistence;
import com.claudecode.tool.ToolRegistry; import com.claudecode.tool.ToolRegistry;
import com.claudecode.tool.impl.AskUserQuestionTool;
import org.jline.reader.*; import org.jline.reader.*;
import org.jline.reader.impl.DefaultParser; import org.jline.reader.impl.DefaultParser;
import org.jline.terminal.Terminal; import org.jline.terminal.Terminal;
@ -22,6 +23,7 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Scanner; import java.util.Scanner;
import java.util.function.Function;
/** /**
* REPL 会话管理器 对应 claude-code/src/REPL.tsx * REPL 会话管理器 对应 claude-code/src/REPL.tsx
@ -48,11 +50,17 @@ public class ReplSession {
private final ToolStatusRenderer toolStatusRenderer; private final ToolStatusRenderer toolStatusRenderer;
private final MarkdownRenderer markdownRenderer; private final MarkdownRenderer markdownRenderer;
private final SpinnerAnimation spinner; private final SpinnerAnimation spinner;
private final ThinkingRenderer thinkingRenderer;
/** 对话摘要(取第一次用户输入的前40字) */ /** 对话摘要(取第一次用户输入的前40字) */
private String conversationSummary = ""; private String conversationSummary = "";
private volatile boolean running = true; private volatile boolean running = true;
/** 当前活跃的 LineReader(JLine 模式下用于 AskUser 和权限确认) */
private volatile LineReader activeReader;
/** 当前活跃的 Scanner(Scanner 模式下用于 AskUser 和权限确认) */
private volatile Scanner activeScanner;
public ReplSession(AgentLoop agentLoop, public ReplSession(AgentLoop agentLoop,
ToolRegistry toolRegistry, ToolRegistry toolRegistry,
CommandRegistry commandRegistry, CommandRegistry commandRegistry,
@ -67,8 +75,20 @@ public class ReplSession {
this.toolStatusRenderer = new ToolStatusRenderer(out); this.toolStatusRenderer = new ToolStatusRenderer(out);
this.markdownRenderer = new MarkdownRenderer(out); this.markdownRenderer = new MarkdownRenderer(out);
this.spinner = new SpinnerAnimation(out); this.spinner = new SpinnerAnimation(out);
this.thinkingRenderer = new ThinkingRenderer(out);
setupAgentCallbacks(); 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 渲染 */ /** 注册 AgentLoop 事件回调,驱动控制台 UI 渲染 */
@ -89,6 +109,18 @@ public class ReplSession {
agentLoop.setOnAssistantMessage(text -> { agentLoop.setOnAssistantMessage(text -> {
// 阻塞模式回调:流式模式下由 onToken 实时输出,此回调不触发 // 阻塞模式回调:流式模式下由 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); printBanner(terminal);
// 设置活跃的 reader,供 AskUser 和权限确认使用
this.activeReader = reader;
CommandContext cmdContext = new CommandContext(agentLoop, toolRegistry, out, () -> running = false); CommandContext cmdContext = new CommandContext(agentLoop, toolRegistry, out, () -> running = false);
while (running) { while (running) {
@ -212,6 +247,7 @@ public class ReplSession {
out.println(); out.println();
Scanner scanner = new Scanner(System.in); Scanner scanner = new Scanner(System.in);
this.activeScanner = scanner;
CommandContext cmdContext = new CommandContext(agentLoop, toolRegistry, out, () -> running = false); CommandContext cmdContext = new CommandContext(agentLoop, toolRegistry, out, () -> running = false);
while (running) { while (running) {
@ -292,4 +328,92 @@ public class ReplSession {
public void stop() { public void stop() {
running = false; 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;
}
} }

@ -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 在执行过程中向用户提问并获取回答
* <p>
* 对应 claude-code AskUserQuestionTool允许 AI 在需要澄清信息时
* 暂停执行并向用户提问用户的回答会作为工具返回值传回 AI
* <p>
* 依赖 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<String, Object> input) {
return "Asking user a question...";
}
@Override
@SuppressWarnings("unchecked")
public String execute(Map<String, Object> 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<String, String> askUser = (Function<String, String>) 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<String>) 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();
}
}
}

@ -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
* <p>
* 对应 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<String, Object> input) {
String query = (String) input.getOrDefault("query", "");
return "Searching: " + query;
}
@Override
public String execute(Map<String, Object> 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<String> 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 搜索结果格式:
// <a class="result__a" href="...">Title</a>
// <a class="result__snippet" href="...">Snippet</a>
// 或者结果块在 <div class="result results_links results_links_deep web-result">
// 提取结果链接和标题
Pattern resultPattern = Pattern.compile(
"<a[^>]+class=\"result__a\"[^>]*href=\"([^\"]*)\"[^>]*>(.*?)</a>",
Pattern.DOTALL);
// 提取摘要
Pattern snippetPattern = Pattern.compile(
"<a[^>]+class=\"result__snippet\"[^>]*>(.*?)</a>",
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("<a[^>]+href=\"(https?://[^\"]*)\"[^>]*>(.*?)</a>", Pattern.DOTALL);
Matcher matcher = linkPattern.matcher(html);
int count = 0;
java.util.Set<String> 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("&amp;", "&")
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&quot;", "\"")
.replaceAll("&#x27;", "'")
.replaceAll("&nbsp;", " ")
.strip();
}
}

@ -269,32 +269,136 @@ allowed-tools: [BashTool, FileReadTool]
## 4. 验收标准 ## 4. 验收标准
### 4.1 基础功能 ### 4.1 基础功能
- [ ] 应用可正常启动并显示 Banner - [x] 应用可正常启动并显示 Banner
- [ ] 支持通过环境变量或配置文件设置 API Key 和模型 - [x] 支持通过环境变量或配置文件设置 API Key 和模型
- [ ] REPL 循环正常工作:输入问题 → 获得 AI 回复 - [x] REPL 循环正常工作:输入问题 → 获得 AI 回复
- [ ] 工具调用循环正常:AI 可以调用工具并获取结果 - [x] 工具调用循环正常:AI 可以调用工具并获取结果
- [ ] 至少支持 8 个核心工具(Bash、FileRead、FileWrite、FileEdit、Glob、Grep、WebFetch、TodoWrite - [x] 至少支持 8 个核心工具(实际 11 个
- [ ] 至少支持 8 个斜杠命令(/help、/clear、/compact、/exit、/cost、/model、/memory、/context - [x] 至少支持 8 个斜杠命令(实际 11 个
### 4.2 交互体验 ### 4.2 交互体验
- [ ] Banner 显示正常(Logo + 版本 + 模型信息) - [x] Banner 显示正常(Logo + 版本 + 模型信息 + Provider + URL
- [ ] 输入提示符支持行编辑和历史记录 - [x] 输入提示符支持行编辑和历史记录
- [ ] Thinking 过程可见 - [x] Thinking 过程可见(Spinner 动画 + "Thinking..." 提示)
- [ ] 工具调用时显示 Spinner 动画和状态 - [x] 工具调用时显示 Spinner 动画和状态
- [ ] 支持 ANSI 颜色输出 - [x] 支持 ANSI 颜色输出
- [ ] Markdown 内容基本格式化 - [x] Markdown 内容基本格式化
### 4.3 上下文功能 ### 4.3 上下文功能
- [ ] 能加载项目 CLAUDE.md - [x] 能加载项目 CLAUDE.md
- [ ] 能加载用户级 CLAUDE.md - [x] 能加载用户级 CLAUDE.md
- [ ] Git 上下文信息正确收集 - [x] Git 上下文信息正确收集
- [ ] 系统提示词包含所有必要信息 - [x] 系统提示词包含所有必要信息
### 4.4 代码质量 ### 4.4 代码质量
- [ ] Maven 编译无错误 - [x] Maven 编译无错误
- [ ] 代码结构清晰,包划分合理 - [x] 代码结构清晰,包划分合理
- [ ] 关键逻辑有中文注释 - [x] 关键逻辑有中文注释
- [ ] 提供完整的使用文档 - [x] 提供完整的使用文档(README.md)
### 4.5 已超出原始验收标准的功能
- [x] 流式输出(Flux<ChatResponse> 逐 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/) | 非核心功能 |
--- ---

Loading…
Cancel
Save