From 749062fba74bbf99b647d9a4876f954013f57197 Mon Sep 17 00:00:00 2001 From: liuzh Date: Wed, 1 Apr 2026 22:25:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20P1=E5=8A=9F=E8=83=BD=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=20-=203=E5=91=BD=E4=BB=A4+=E4=BB=A3=E7=A0=81=E9=AB=98=E4=BA=AE?= =?UTF-8?q?+=E7=8A=B6=E6=80=81=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增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> --- .../command/impl/CommitCommand.java | 174 ++++++++++++++ .../command/impl/ExportCommand.java | 134 +++++++++++ .../command/impl/ResumeCommand.java | 114 ++++++++++ .../java/com/claudecode/config/AppConfig.java | 3 + .../claudecode/console/MarkdownRenderer.java | 215 +++++++++++++++++- .../com/claudecode/console/StatusLine.java | 130 +++++++++++ .../java/com/claudecode/repl/ReplSession.java | 13 ++ 7 files changed, 771 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/claudecode/command/impl/CommitCommand.java create mode 100644 src/main/java/com/claudecode/command/impl/ExportCommand.java create mode 100644 src/main/java/com/claudecode/command/impl/ResumeCommand.java create mode 100644 src/main/java/com/claudecode/console/StatusLine.java diff --git a/src/main/java/com/claudecode/command/impl/CommitCommand.java b/src/main/java/com/claudecode/command/impl/CommitCommand.java new file mode 100644 index 0000000..54aa4f4 --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/CommitCommand.java @@ -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。 + *

+ * 支持多种模式: + *

+ */ +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(); + 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; + } + } +} diff --git a/src/main/java/com/claudecode/command/impl/ExportCommand.java b/src/main/java/com/claudecode/command/impl/ExportCommand.java new file mode 100644 index 0000000..7a5c61e --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/ExportCommand.java @@ -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 文件。 + *

+ * 支持格式: + *

+ */ +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 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 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("
\nClick to expand system prompt\n\n"); + md.append(sm.getText()).append("\n\n"); + md.append("
\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(); + } +} diff --git a/src/main/java/com/claudecode/command/impl/ResumeCommand.java b/src/main/java/com/claudecode/command/impl/ResumeCommand.java new file mode 100644 index 0000000..87f44e4 --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/ResumeCommand.java @@ -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 命令 —— 恢复之前保存的对话。 + *

+ * 从 ~/.claude-code-java/conversations/ 加载对话历史, + * 替换当前消息历史,恢复之前的上下文。 + *

    + *
  • /resume —— 恢复最近一次对话
  • + *
  • /resume list —— 列出可恢复的对话
  • + *
  • /resume [序号] —— 恢复指定序号的对话
  • + *
+ */ +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 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 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 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(); + } +} diff --git a/src/main/java/com/claudecode/config/AppConfig.java b/src/main/java/com/claudecode/config/AppConfig.java index ca08c6b..619ee49 100644 --- a/src/main/java/com/claudecode/config/AppConfig.java +++ b/src/main/java/com/claudecode/config/AppConfig.java @@ -82,6 +82,9 @@ public class AppConfig { new SkillsCommand(), new MemoryCommand(), new CopyCommand(), + new ResumeCommand(), + new ExportCommand(), + new CommitCommand(), new ExitCommand() ); return registry; diff --git a/src/main/java/com/claudecode/console/MarkdownRenderer.java b/src/main/java/com/claudecode/console/MarkdownRenderer.java index d901d2a..60a99c6 100644 --- a/src/main/java/com/claudecode/console/MarkdownRenderer.java +++ b/src/main/java/com/claudecode/console/MarkdownRenderer.java @@ -1,17 +1,82 @@ package com.claudecode.console; import java.io.PrintStream; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** - * Markdown 简易渲染器 —— 对应 claude-code/src/renderers/markdown.ts。 + * Markdown 渲染器(增强版) —— 对应 claude-code/src/renderers/markdown.ts。 *

* 将 AI 回复中的 Markdown 格式转换为终端 ANSI 样式输出。 - * 这是一个简化版,支持常见格式。 + * 支持代码块语法高亮、有序列表、引用块、表格等。 */ public class MarkdownRenderer { private final PrintStream out; + // 各语言的关键字集合,用于代码高亮 + private static final Set JAVA_KEYWORDS = Set.of( + "abstract", "assert", "boolean", "break", "byte", "case", "catch", "char", + "class", "const", "continue", "default", "do", "double", "else", "enum", + "extends", "final", "finally", "float", "for", "goto", "if", "implements", + "import", "instanceof", "int", "interface", "long", "native", "new", "package", + "private", "protected", "public", "return", "short", "static", "strictfp", + "super", "switch", "synchronized", "this", "throw", "throws", "transient", + "try", "void", "volatile", "while", "var", "record", "sealed", "permits", + "yield", "when"); + + private static final Set JS_KEYWORDS = Set.of( + "async", "await", "break", "case", "catch", "class", "const", "continue", + "debugger", "default", "delete", "do", "else", "export", "extends", "false", + "finally", "for", "from", "function", "if", "import", "in", "instanceof", + "let", "new", "null", "of", "return", "super", "switch", "this", "throw", + "true", "try", "typeof", "undefined", "var", "void", "while", "with", "yield"); + + private static final Set PYTHON_KEYWORDS = Set.of( + "and", "as", "assert", "async", "await", "break", "class", "continue", + "def", "del", "elif", "else", "except", "False", "finally", "for", "from", + "global", "if", "import", "in", "is", "lambda", "None", "nonlocal", "not", + "or", "pass", "raise", "return", "True", "try", "while", "with", "yield"); + + private static final Set SHELL_KEYWORDS = Set.of( + "if", "then", "else", "elif", "fi", "for", "while", "do", "done", "case", + "esac", "function", "return", "exit", "echo", "export", "source", "set", + "unset", "local", "readonly", "declare", "cd", "pwd", "ls", "cat", "grep", + "sed", "awk", "find", "mkdir", "rm", "cp", "mv", "chmod", "chown"); + + private static final Set SQL_KEYWORDS = Set.of( + "SELECT", "FROM", "WHERE", "INSERT", "INTO", "VALUES", "UPDATE", "SET", + "DELETE", "CREATE", "TABLE", "ALTER", "DROP", "INDEX", "JOIN", "LEFT", + "RIGHT", "INNER", "OUTER", "ON", "AND", "OR", "NOT", "NULL", "IS", + "IN", "LIKE", "BETWEEN", "ORDER", "BY", "GROUP", "HAVING", "LIMIT", + "OFFSET", "AS", "DISTINCT", "COUNT", "SUM", "AVG", "MAX", "MIN"); + + /** 语言到关键字集的映射 */ + private static final Map> LANG_KEYWORDS; + static { + var map = new java.util.HashMap>(); + map.put("java", JAVA_KEYWORDS); + map.put("javascript", JS_KEYWORDS); + map.put("js", JS_KEYWORDS); + map.put("typescript", JS_KEYWORDS); + map.put("ts", JS_KEYWORDS); + map.put("python", PYTHON_KEYWORDS); + map.put("py", PYTHON_KEYWORDS); + map.put("bash", SHELL_KEYWORDS); + map.put("sh", SHELL_KEYWORDS); + map.put("shell", SHELL_KEYWORDS); + map.put("sql", SQL_KEYWORDS); + LANG_KEYWORDS = Map.copyOf(map); + } + + // 高亮用的正则 + private static final Pattern STRING_PATTERN = Pattern.compile("(\"[^\"\\\\]*(\\\\.[^\"\\\\]*)*\"|'[^'\\\\]*(\\\\.[^'\\\\]*)*')"); + private static final Pattern NUMBER_PATTERN = Pattern.compile("\\b(\\d+\\.?\\d*[fFdDlL]?|0x[0-9a-fA-F]+)\\b"); + private static final Pattern SINGLE_LINE_COMMENT = Pattern.compile("(//.*|#.*)$"); + private static final Pattern ANNOTATION_PATTERN = Pattern.compile("(@\\w+)"); + public MarkdownRenderer(PrintStream out) { this.out = out; } @@ -27,19 +92,21 @@ public class MarkdownRenderer { // 代码块 if (line.stripLeading().startsWith("```")) { if (!inCodeBlock) { - codeBlockLang = line.stripLeading().substring(3).strip(); + codeBlockLang = line.stripLeading().substring(3).strip().toLowerCase(); inCodeBlock = true; - out.println(AnsiStyle.dim(" ┌─" + (codeBlockLang.isEmpty() ? "code" : codeBlockLang) + "─")); + String langLabel = codeBlockLang.isEmpty() ? "code" : codeBlockLang; + out.println(AnsiStyle.dim(" ┌─" + langLabel + "─" + "─".repeat(Math.max(0, 40 - langLabel.length())))); continue; } else { inCodeBlock = false; - out.println(AnsiStyle.dim(" └─────")); + out.println(AnsiStyle.dim(" └" + "─".repeat(42))); + codeBlockLang = ""; continue; } } if (inCodeBlock) { - out.println(AnsiStyle.BRIGHT_GREEN + " │ " + line + AnsiStyle.RESET); + out.println(" " + AnsiStyle.DIM + "│" + AnsiStyle.RESET + " " + highlightCode(line, codeBlockLang)); continue; } @@ -51,13 +118,38 @@ public class MarkdownRenderer { } else if (line.startsWith("# ")) { out.println(AnsiStyle.bold(AnsiStyle.MAGENTA + " " + line.substring(2)) + AnsiStyle.RESET); } - // 列表项 + // 引用块 + else if (line.stripLeading().startsWith("> ")) { + String quoteText = line.stripLeading().substring(2); + out.println(" " + AnsiStyle.DIM + "┃" + AnsiStyle.RESET + " " + AnsiStyle.ITALIC + renderInline(quoteText) + AnsiStyle.RESET); + } + // 有序列表 + else if (line.stripLeading().matches("^\\d+\\.\\s+.*")) { + Matcher m = Pattern.compile("^(\\s*)(\\d+)\\.\\s+(.*)").matcher(line); + if (m.matches()) { + String indent = m.group(1); + String num = m.group(2); + String text = m.group(3); + out.println(" " + indent + AnsiStyle.CYAN + num + "." + AnsiStyle.RESET + " " + renderInline(text)); + } else { + out.println(" " + renderInline(line)); + } + } + // 无序列表 else if (line.stripLeading().startsWith("- ") || line.stripLeading().startsWith("* ")) { - out.println(" " + AnsiStyle.CYAN + "•" + AnsiStyle.RESET + " " + renderInline(line.stripLeading().substring(2))); + int indent = line.length() - line.stripLeading().length(); + String prefix = " ".repeat(indent); + out.println(" " + prefix + AnsiStyle.CYAN + "•" + AnsiStyle.RESET + " " + renderInline(line.stripLeading().substring(2))); + } + // 复选框列表 + else if (line.stripLeading().startsWith("- [ ] ")) { + out.println(" " + AnsiStyle.DIM + "☐" + AnsiStyle.RESET + " " + renderInline(line.stripLeading().substring(6))); + } else if (line.stripLeading().startsWith("- [x] ") || line.stripLeading().startsWith("- [X] ")) { + out.println(" " + AnsiStyle.GREEN + "☑" + AnsiStyle.RESET + " " + renderInline(line.stripLeading().substring(6))); } // 分隔线 - else if (line.strip().matches("^-{3,}$") || line.strip().matches("^\\*{3,}$")) { - out.println(AnsiStyle.dim(" ─────────────────────────────────────────")); + else if (line.strip().matches("^[-*]{3,}$")) { + out.println(AnsiStyle.dim(" " + "─".repeat(42))); } // 普通文本 else { @@ -66,14 +158,113 @@ public class MarkdownRenderer { } } + // ==================== 代码语法高亮 ==================== + + /** + * 基于语言的代码行高亮。 + * 着色优先级:注释 > 字符串 > 注解 > 关键字 > 数字。 + */ + private String highlightCode(String line, String lang) { + if (lang.isEmpty() || !LANG_KEYWORDS.containsKey(lang)) { + // 未知语言:仅着绿色 + return AnsiStyle.BRIGHT_GREEN + line + AnsiStyle.RESET; + } + + Set keywords = LANG_KEYWORDS.get(lang); + StringBuilder result = new StringBuilder(); + + // 简单的逐行高亮:先检测注释和字符串区间,再对非特殊区间着色关键字 + // 为简化实现,采用分段替换策略 + + String processed = line; + + // 1. 注释(行末 // 或 #)—— 灰色斜体 + Matcher commentMatcher = SINGLE_LINE_COMMENT.matcher(processed); + if (commentMatcher.find()) { + String beforeComment = processed.substring(0, commentMatcher.start()); + String comment = commentMatcher.group(); + return highlightNonComment(beforeComment, keywords, lang) + + AnsiStyle.BRIGHT_BLACK + AnsiStyle.ITALIC + comment + AnsiStyle.RESET; + } + + return highlightNonComment(processed, keywords, lang); + } + + /** 对非注释部分进行高亮 */ + private String highlightNonComment(String code, Set keywords, String lang) { + // 用占位符保护字符串字面量 + var stringRanges = new java.util.ArrayList(); + Matcher strMatcher = STRING_PATTERN.matcher(code); + while (strMatcher.find()) { + stringRanges.add(new int[]{strMatcher.start(), strMatcher.end()}); + } + + StringBuilder result = new StringBuilder(); + int pos = 0; + + for (int[] range : stringRanges) { + // 高亮字符串之前的部分 + if (range[0] > pos) { + result.append(highlightSegment(code.substring(pos, range[0]), keywords, lang)); + } + // 字符串本身着黄色 + result.append(AnsiStyle.YELLOW).append(code, range[0], range[1]).append(AnsiStyle.RESET); + pos = range[1]; + } + + // 最后一段 + if (pos < code.length()) { + result.append(highlightSegment(code.substring(pos), keywords, lang)); + } + + return result.toString(); + } + + /** 对普通代码段(无字符串)进行关键字和数字高亮 */ + private String highlightSegment(String segment, Set keywords, String lang) { + // 注解(@Annotation)— 仅 Java/Python + if (lang.equals("java") || lang.equals("python") || lang.equals("py")) { + Matcher annMatcher = ANNOTATION_PATTERN.matcher(segment); + segment = annMatcher.replaceAll(AnsiStyle.BRIGHT_YELLOW + "$1" + AnsiStyle.RESET); + } + + // 关键字着色 — 使用 word boundary 匹配 + for (String kw : keywords) { + // SQL 关键字大小写不敏感 + if (lang.equals("sql")) { + segment = segment.replaceAll("(?i)\\b(" + Pattern.quote(kw) + ")\\b", + AnsiStyle.BRIGHT_CYAN + "$1" + AnsiStyle.RESET); + } else { + segment = segment.replaceAll("\\b(" + Pattern.quote(kw) + ")\\b", + AnsiStyle.BRIGHT_CYAN + "$1" + AnsiStyle.RESET); + } + } + + // 数字着色 + Matcher numMatcher = NUMBER_PATTERN.matcher(segment); + segment = numMatcher.replaceAll(AnsiStyle.BRIGHT_MAGENTA + "$1" + AnsiStyle.RESET); + + // true/false/null 着色 + segment = segment.replaceAll("\\b(true|false|null|None|nil)\\b", + AnsiStyle.BRIGHT_RED + "$1" + AnsiStyle.RESET); + + return segment; + } + + // ==================== 行内格式 ==================== + /** 行内格式渲染 */ private String renderInline(String text) { // 粗体 **text** text = text.replaceAll("\\*\\*(.+?)\\*\\*", AnsiStyle.BOLD + "$1" + AnsiStyle.RESET); // 行内代码 `text` text = text.replaceAll("`(.+?)`", AnsiStyle.BRIGHT_GREEN + "$1" + AnsiStyle.RESET); - // 斜体 *text* - text = text.replaceAll("\\*(.+?)\\*", AnsiStyle.ITALIC + "$1" + AnsiStyle.RESET); + // 斜体 *text*(需避免匹配粗体中的 *) + text = text.replaceAll("(? + * 在终端底部持续显示:模型名、Token 用量/费用、工作目录等状态信息。 + * 使用 ANSI 转义序列控制光标位置,在每次输出后刷新状态行。 + *

+ * 注意:仅在非 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(); + } + + /** + * 刷新底部状态行显示。 + *

+ * 使用 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; + } +} diff --git a/src/main/java/com/claudecode/repl/ReplSession.java b/src/main/java/com/claudecode/repl/ReplSession.java index 694098e..7d36429 100644 --- a/src/main/java/com/claudecode/repl/ReplSession.java +++ b/src/main/java/com/claudecode/repl/ReplSession.java @@ -51,6 +51,7 @@ public class ReplSession { private final MarkdownRenderer markdownRenderer; private final SpinnerAnimation spinner; private final ThinkingRenderer thinkingRenderer; + private final StatusLine statusLine; /** 对话摘要(取第一次用户输入的前40字) */ private String conversationSummary = ""; @@ -76,6 +77,7 @@ public class ReplSession { this.markdownRenderer = new MarkdownRenderer(out); this.spinner = new SpinnerAnimation(out); this.thinkingRenderer = new ThinkingRenderer(out); + this.statusLine = new StatusLine(out); setupAgentCallbacks(); setupToolContextCallbacks(); @@ -178,6 +180,12 @@ public class ReplSession { // 设置活跃的 reader,供 AskUser 和权限确认使用 this.activeReader = reader; + // 非 dumb 终端启用底部状态行 + boolean isDumb2 = "dumb".equals(terminal.getType()); + if (!isDumb2) { + statusLine.enable(providerInfo.model(), agentLoop.getTokenTracker()); + } + CommandContext cmdContext = new CommandContext(agentLoop, toolRegistry, out, () -> running = false); while (running) { @@ -299,6 +307,11 @@ public class ReplSession { spinner.stop(); out.println(); // 流式输出结束后换行 + + // 刷新底部状态行(显示最新 token 用量) + if (statusLine.isEnabled()) { + out.println(statusLine.renderInline()); + } out.println(); } catch (Exception e) { spinner.stop();