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();