新增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>pull/1/head
parent
82a28b7aa7
commit
749062fba7
@ -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(); |
||||
} |
||||
} |
||||
@ -0,0 +1,130 @@ |
||||
package com.claudecode.console; |
||||
|
||||
import com.claudecode.core.TokenTracker; |
||||
|
||||
import java.io.PrintStream; |
||||
|
||||
/** |
||||
* 底部状态行渲染器 —— 对应 claude-code 的 StatusLine 组件。 |
||||
* <p> |
||||
* 在终端底部持续显示:模型名、Token 用量/费用、工作目录等状态信息。 |
||||
* 使用 ANSI 转义序列控制光标位置,在每次输出后刷新状态行。 |
||||
* <p> |
||||
* 注意:仅在非 dumb 终端下启用,dumb 终端不支持光标控制。 |
||||
*/ |
||||
public class StatusLine { |
||||
|
||||
private final PrintStream out; |
||||
private volatile boolean enabled = false; |
||||
private volatile String modelName = ""; |
||||
private volatile TokenTracker tokenTracker; |
||||
private volatile String workDir = ""; |
||||
|
||||
public StatusLine(PrintStream out) { |
||||
this.out = out; |
||||
} |
||||
|
||||
/** 启用状态行 */ |
||||
public void enable(String model, TokenTracker tracker) { |
||||
this.modelName = model; |
||||
this.tokenTracker = tracker; |
||||
this.workDir = abbreviatePath(System.getProperty("user.dir")); |
||||
this.enabled = true; |
||||
} |
||||
|
||||
/** 禁用状态行 */ |
||||
public void disable() { |
||||
this.enabled = false; |
||||
clearStatusLine(); |
||||
} |
||||
|
||||
/** |
||||
* 刷新底部状态行显示。 |
||||
* <p> |
||||
* 使用 ANSI 转义序列: |
||||
* - 保存光标位置 |
||||
* - 移动到屏幕底部 |
||||
* - 输出状态信息 |
||||
* - 恢复光标位置 |
||||
*/ |
||||
public void refresh() { |
||||
if (!enabled || tokenTracker == null) return; |
||||
|
||||
String status = buildStatusText(); |
||||
|
||||
// 保存光标 → 移到最后一行 → 清行 → 写状态 → 恢复光标
|
||||
out.print("\033[s"); // 保存光标
|
||||
out.print("\033[999;1H"); // 移到最后一行
|
||||
out.print("\033[2K"); // 清除该行
|
||||
out.print(status); |
||||
out.print("\033[u"); // 恢复光标
|
||||
out.flush(); |
||||
} |
||||
|
||||
/** |
||||
* 渲染一行式状态摘要(不使用光标控制,适合在提示符之前显示)。 |
||||
* 这是一种更安全的替代方案,不会干扰终端滚动。 |
||||
*/ |
||||
public String renderInline() { |
||||
if (!enabled || tokenTracker == null) return ""; |
||||
return buildStatusText(); |
||||
} |
||||
|
||||
private String buildStatusText() { |
||||
long inputTokens = tokenTracker.getInputTokens(); |
||||
long outputTokens = tokenTracker.getOutputTokens(); |
||||
double cost = tokenTracker.estimateCost(); |
||||
long apiCalls = tokenTracker.getApiCallCount(); |
||||
|
||||
StringBuilder sb = new StringBuilder(); |
||||
|
||||
// 反色背景的状态栏
|
||||
sb.append(AnsiStyle.DIM); |
||||
|
||||
// 模型名
|
||||
sb.append(" ").append(modelName); |
||||
|
||||
// Token 用量
|
||||
sb.append(" │ ↑").append(TokenTracker.formatTokens(inputTokens)); |
||||
sb.append(" ↓").append(TokenTracker.formatTokens(outputTokens)); |
||||
|
||||
// 费用
|
||||
if (cost > 0) { |
||||
sb.append(String.format(" $%.4f", cost)); |
||||
} |
||||
|
||||
// API 调用次数
|
||||
sb.append(" │ ").append(apiCalls).append(" calls"); |
||||
|
||||
// 工作目录
|
||||
sb.append(" │ ").append(workDir); |
||||
|
||||
sb.append(AnsiStyle.RESET); |
||||
|
||||
return sb.toString(); |
||||
} |
||||
|
||||
/** 清除状态行 */ |
||||
private void clearStatusLine() { |
||||
out.print("\033[s\033[999;1H\033[2K\033[u"); |
||||
out.flush(); |
||||
} |
||||
|
||||
/** 缩写路径:将 home 目录替换为 ~ */ |
||||
private String abbreviatePath(String path) { |
||||
if (path == null) return ""; |
||||
String home = System.getProperty("user.home"); |
||||
if (path.startsWith(home)) { |
||||
return "~" + path.substring(home.length()); |
||||
} |
||||
// 过长时截断
|
||||
if (path.length() > 40) { |
||||
return "..." + path.substring(path.length() - 37); |
||||
} |
||||
return path; |
||||
} |
||||
|
||||
public boolean isEnabled() { |
||||
return enabled; |
||||
} |
||||
} |
||||
Loading…
Reference in new issue