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>
This commit is contained in:
liuzh
2026-04-01 22:06:57 +08:00
co-authored by Copilot
parent 7dc4d142b3
commit 82a28b7aa7
11 changed files with 1204 additions and 23 deletions
@@ -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";
}
}
}