feat: P1功能增强 - 3命令+代码高亮+状态行

新增3个命令:
- /resume: 恢复已保存的对话(支持list/序号选择)
- /export: 导出对话为Markdown文件
- /commit: 创建Git commit(支持AI生成commit message)

代码语法高亮(MarkdownRenderer增强):
- 支持Java/JS/TS/Python/Bash/SQL关键字着色
- 字符串字面量黄色、数字紫色、注释灰色斜体
- 注解(@Annotation)亮黄色
- true/false/null 红色
- 新增引用块、有序列表、复选框、链接渲染
- 代码块边框对齐

底部状态行(StatusLine):
- 模型名、Token用量、费用、API调用次数、工作目录
- 非dumb终端自动启用
- 每次Agent循环后刷新显示

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
liuzh
2026-04-01 22:25:26 +08:00
co-authored by Copilot
parent 82a28b7aa7
commit 749062fba7
7 changed files with 771 additions and 12 deletions
@@ -0,0 +1,174 @@
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;
/**
* /commit 命令 —— 创建 Git commit。
* <p>
* 支持多种模式:
* <ul>
* <li>/commit —— 自动生成 AI commit message(基于 git diff</li>
* <li>/commit [message] —— 使用指定的 commit message</li>
* <li>/commit --all —— 添加所有文件并提交</li>
* </ul>
*/
public class CommitCommand implements SlashCommand {
@Override
public String name() {
return "commit";
}
@Override
public String description() {
return "Create a git commit (with optional AI-generated message)";
}
@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 {
boolean addAll = args.contains("--all") || args.contains("-a");
String message = args.replaceAll("--all|-a", "").strip();
// --all 模式:先执行 git add -A
if (addAll) {
String addResult = runGit(projectDir, "add", "-A");
if (addResult == null) {
return AnsiStyle.red(" ✗ git add 失败");
}
}
// 检查是否有已暂存的变更
String staged = runGit(projectDir, "diff", "--cached", "--stat");
if (staged == null || staged.isBlank()) {
String status = runGit(projectDir, "status", "--short");
if (status != null && !status.isBlank()) {
return AnsiStyle.yellow(" ⚠ 没有已暂存的变更\n")
+ AnsiStyle.dim(" 使用 /commit --all 自动添加所有文件\n")
+ AnsiStyle.dim(" 或先手动执行 git add");
}
return AnsiStyle.green(" ✓ 工作区干净,无需提交");
}
// 如果没有指定 message,使用 AI 生成
if (message.isEmpty()) {
message = generateCommitMessage(projectDir, context);
if (message == null || message.isBlank()) {
return AnsiStyle.red(" ✗ 无法生成 commit message");
}
}
// 执行 git commit
String commitResult = runGit(projectDir, "commit", "-m", message);
if (commitResult == null) {
return AnsiStyle.red(" ✗ git commit 失败");
}
StringBuilder sb = new StringBuilder();
sb.append("\n").append(AnsiStyle.green(" ✓ Commit 成功\n"));
sb.append(" ").append("".repeat(50)).append("\n");
sb.append(" ").append(AnsiStyle.bold("Message: ")).append(message).append("\n");
// 显示提交摘要
commitResult.lines().forEach(line -> sb.append(" ").append(AnsiStyle.dim(line)).append("\n"));
return sb.toString();
} catch (Exception e) {
return AnsiStyle.red(" ✗ 提交失败: " + e.getMessage());
}
}
/** 使用 AI 分析 git diff 生成 commit message */
private String generateCommitMessage(Path projectDir, CommandContext context) {
try {
// 获取暂存区的 diff
String diff = runGit(projectDir, "diff", "--cached");
if (diff == null || diff.isBlank()) return null;
// 截断过长的 diff
if (diff.length() > 4000) {
diff = diff.substring(0, 4000) + "\n... (diff truncated)";
}
// 使用 ChatModel 生成 commit message
String prompt = """
分析以下 git diff,生成一个简洁的 commit message。
要求:
1. 使用 conventional commits 格式(feat/fix/docs/refactor/chore等前缀)
2. 第一行不超过 72 个字符
3. 如果有多个变更,可以在第一行后空一行添加详细说明
4. 只返回 commit message 文本,不要添加其他说明
Git diff:
```
%s
```
""".formatted(diff);
var chatModel = context.agentLoop().getChatModel();
var response = chatModel.call(
new org.springframework.ai.chat.prompt.Prompt(prompt));
String generated = response.getResult().getOutput().getText();
if (generated != null) {
// 清理:去除可能的引号和多余空行
generated = generated.strip()
.replaceAll("^[\"'`]+|[\"'`]+$", "")
.strip();
}
return generated;
} catch (Exception e) {
// AI 生成失败时返回默认消息
return null;
}
}
private String runGit(Path dir, String... args) {
try {
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");
}
}
boolean finished = process.waitFor(30, TimeUnit.SECONDS);
if (!finished) {
process.destroyForcibly();
return null;
}
return output.toString().stripTrailing();
} catch (Exception e) {
return null;
}
}
}
@@ -0,0 +1,134 @@
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.*;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
/**
* /export 命令 —— 将对话历史导出为 Markdown 文件。
* <p>
* 支持格式:
* <ul>
* <li>/export —— 导出到当前目录的 conversation-时间戳.md</li>
* <li>/export [路径] —— 导出到指定路径</li>
* </ul>
*/
public class ExportCommand implements SlashCommand {
private static final DateTimeFormatter TIMESTAMP_FMT = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss");
@Override
public String name() {
return "export";
}
@Override
public String description() {
return "Export conversation to Markdown file";
}
@Override
public String execute(String args, CommandContext context) {
List<Message> history = context.agentLoop().getMessageHistory();
// 至少需要系统提示 + 用户消息 + 助手回复
if (history.size() < 3) {
return AnsiStyle.yellow(" ⚠ 暂无足够的对话内容可导出");
}
// 确定输出路径
args = args == null ? "" : args.strip();
Path outputPath;
if (!args.isEmpty()) {
outputPath = Path.of(args);
if (!outputPath.isAbsolute()) {
outputPath = Path.of(System.getProperty("user.dir")).resolve(outputPath);
}
} else {
String timestamp = TIMESTAMP_FMT.format(LocalDateTime.now());
outputPath = Path.of(System.getProperty("user.dir"), "conversation-" + timestamp + ".md");
}
// 生成 Markdown 内容
String markdown = generateMarkdown(history);
try {
Files.createDirectories(outputPath.getParent());
Files.writeString(outputPath, markdown, StandardCharsets.UTF_8);
int msgCount = history.size();
int lineCount = (int) markdown.lines().count();
return AnsiStyle.green(" ✓ 对话已导出: " + outputPath)
+ AnsiStyle.dim(" (" + msgCount + " messages, " + lineCount + " lines)");
} catch (IOException e) {
return AnsiStyle.red(" ✗ 导出失败: " + e.getMessage());
}
}
private String generateMarkdown(List<Message> history) {
StringBuilder md = new StringBuilder();
md.append("# Claude Code Java - Conversation Export\n\n");
md.append("- **Exported at**: ").append(LocalDateTime.now()).append("\n");
md.append("- **Working directory**: ").append(System.getProperty("user.dir")).append("\n");
md.append("- **Messages**: ").append(history.size()).append("\n\n");
md.append("---\n\n");
for (Message msg : history) {
switch (msg) {
case SystemMessage sm -> {
md.append("## 🔧 System Prompt\n\n");
md.append("<details>\n<summary>Click to expand system prompt</summary>\n\n");
md.append(sm.getText()).append("\n\n");
md.append("</details>\n\n---\n\n");
}
case UserMessage um -> {
md.append("## 👤 User\n\n");
md.append(um.getText()).append("\n\n");
}
case AssistantMessage am -> {
md.append("## 🤖 Assistant\n\n");
if (am.getText() != null && !am.getText().isBlank()) {
md.append(am.getText()).append("\n\n");
}
if (am.hasToolCalls()) {
md.append("### Tool Calls\n\n");
for (var tc : am.getToolCalls()) {
md.append("- **").append(tc.name()).append("**");
if (tc.arguments() != null) {
md.append("\n ```json\n ").append(tc.arguments()).append("\n ```");
}
md.append("\n");
}
md.append("\n");
}
}
case ToolResponseMessage trm -> {
md.append("### 🔨 Tool Results\n\n");
for (var resp : trm.getResponses()) {
md.append("**").append(resp.name()).append("**:\n");
String data = resp.responseData();
if (data != null) {
// 截断过长的工具输出
if (data.length() > 2000) {
data = data.substring(0, 2000) + "\n... (truncated)";
}
md.append("```\n").append(data).append("\n```\n\n");
}
}
}
default -> {}
}
}
return md.toString();
}
}
@@ -0,0 +1,114 @@
package com.claudecode.command.impl;
import com.claudecode.command.CommandContext;
import com.claudecode.command.SlashCommand;
import com.claudecode.console.AnsiStyle;
import com.claudecode.core.ConversationPersistence;
import com.claudecode.core.ConversationPersistence.ConversationSummary;
import org.springframework.ai.chat.messages.Message;
import java.nio.file.Path;
import java.util.List;
/**
* /resume 命令 —— 恢复之前保存的对话。
* <p>
* 从 ~/.claude-code-java/conversations/ 加载对话历史,
* 替换当前消息历史,恢复之前的上下文。
* <ul>
* <li>/resume —— 恢复最近一次对话</li>
* <li>/resume list —— 列出可恢复的对话</li>
* <li>/resume [序号] —— 恢复指定序号的对话</li>
* </ul>
*/
public class ResumeCommand implements SlashCommand {
@Override
public String name() {
return "resume";
}
@Override
public String description() {
return "Resume a saved conversation";
}
@Override
public String execute(String args, CommandContext context) {
ConversationPersistence persistence = new ConversationPersistence();
List<ConversationSummary> conversations = persistence.listConversations();
args = args == null ? "" : args.strip();
if (conversations.isEmpty()) {
return AnsiStyle.yellow(" ⚠ 没有已保存的对话\n")
+ AnsiStyle.dim(" 对话在退出时自动保存到 ~/.claude-code-java/conversations/");
}
// /resume list —— 列出所有对话
if (args.equals("list")) {
return formatConversationList(conversations);
}
// 确定要恢复的对话索引
int index = 0; // 默认最近一个
if (!args.isEmpty()) {
try {
index = Integer.parseInt(args) - 1;
if (index < 0 || index >= conversations.size()) {
return AnsiStyle.red(" ✗ 无效序号(范围 1-" + conversations.size() + "");
}
} catch (NumberFormatException e) {
return AnsiStyle.yellow(" ⚠ 用法: /resume [序号] 或 /resume list");
}
}
// 加载并恢复对话
ConversationSummary summary = conversations.get(index);
Path file = persistence.getConversationsDir().resolve(summary.filename());
List<Message> messages = persistence.loadFromFile(file);
if (messages.isEmpty()) {
return AnsiStyle.red(" ✗ 加载对话失败: " + summary.filename());
}
// 替换当前消息历史
context.agentLoop().replaceHistory(messages);
StringBuilder sb = new StringBuilder();
sb.append("\n");
sb.append(AnsiStyle.green(" ✓ 对话已恢复\n"));
sb.append(" ").append("".repeat(50)).append("\n");
sb.append(" ").append(AnsiStyle.bold("摘要: ")).append(summary.summary()).append("\n");
sb.append(" ").append(AnsiStyle.bold("时间: ")).append(summary.savedAt()).append("\n");
sb.append(" ").append(AnsiStyle.bold("消息数: ")).append(summary.messageCount()).append("\n");
sb.append(" ").append(AnsiStyle.bold("目录: ")).append(AnsiStyle.dim(summary.workingDir())).append("\n");
return sb.toString();
}
private String formatConversationList(List<ConversationSummary> conversations) {
StringBuilder sb = new StringBuilder();
sb.append("\n");
sb.append(AnsiStyle.bold(" 📂 Saved Conversations\n"));
sb.append(" ").append("".repeat(50)).append("\n\n");
int maxShow = Math.min(conversations.size(), 20);
for (int i = 0; i < maxShow; i++) {
ConversationSummary conv = conversations.get(i);
sb.append(" ").append(AnsiStyle.cyan(String.format("%2d", i + 1))).append(". ");
sb.append(AnsiStyle.bold(conv.summary())).append("\n");
sb.append(" ").append(AnsiStyle.dim(conv.savedAt()))
.append(AnsiStyle.dim(" | " + conv.messageCount() + " messages"))
.append("\n");
}
if (conversations.size() > maxShow) {
sb.append(AnsiStyle.dim("\n ... 还有 " + (conversations.size() - maxShow) + " 个对话\n"));
}
sb.append(AnsiStyle.dim("\n 使用 /resume [序号] 恢复指定对话\n"));
return sb.toString();
}
}