diff --git a/src/main/java/com/claudecode/console/MarkdownRenderer.java b/src/main/java/com/claudecode/console/MarkdownRenderer.java index 60a99c6..3e7d793 100644 --- a/src/main/java/com/claudecode/console/MarkdownRenderer.java +++ b/src/main/java/com/claudecode/console/MarkdownRenderer.java @@ -267,4 +267,93 @@ public class MarkdownRenderer { text = text.replaceAll("\\[(.+?)]\\((.+?)\\)", AnsiStyle.UNDERLINE + "$1" + AnsiStyle.RESET + AnsiStyle.DIM + " ($2)" + AnsiStyle.RESET); return text; } + + // ==================== 流式 Markdown 渲染 ==================== + + /** + * 流式渲染状态 —— 跨行追踪代码块等多行结构。 + * 每次 REPL 对话轮次创建一个新实例。 + */ + public static class StreamState { + boolean inCodeBlock = false; + String codeLang = ""; + } + + /** + * 渲染单行流式文本(无前缀缩进,由调用方处理)。 + *
+ * 支持:代码块(带语法高亮)、标题、列表、引用、行内格式(粗体/斜体/代码)。 + * 代码块状态通过 {@link StreamState} 跨行维护。 + * + * @param line 一行完整文本(不含换行符) + * @param state 跨行状态(代码块追踪) + * @return 带 ANSI 样式的渲染结果 + */ + public String renderStreamingLine(String line, StreamState state) { + String stripped = line.stripLeading(); + + // 代码块边界 + if (stripped.startsWith("```")) { + if (!state.inCodeBlock) { + state.codeLang = stripped.substring(3).strip().toLowerCase(); + state.inCodeBlock = true; + String langLabel = state.codeLang.isEmpty() ? "code" : state.codeLang; + return AnsiStyle.dim("┌─" + langLabel + "─" + "─".repeat(Math.max(0, 40 - langLabel.length()))); + } else { + state.inCodeBlock = false; + state.codeLang = ""; + return AnsiStyle.dim("└" + "─".repeat(42)); + } + } + + // 代码块内容(语法高亮) + if (state.inCodeBlock) { + return AnsiStyle.DIM + "│" + AnsiStyle.RESET + " " + highlightCode(line, state.codeLang); + } + + // 标题 + if (stripped.startsWith("### ")) { + return AnsiStyle.bold(AnsiStyle.CYAN + stripped.substring(4)) + AnsiStyle.RESET; + } else if (stripped.startsWith("## ")) { + return AnsiStyle.bold(AnsiStyle.BLUE + stripped.substring(3)) + AnsiStyle.RESET; + } else if (stripped.startsWith("# ")) { + return AnsiStyle.bold(AnsiStyle.MAGENTA + stripped.substring(2)) + AnsiStyle.RESET; + } + + // 引用块 + if (stripped.startsWith("> ")) { + return AnsiStyle.DIM + "┃" + AnsiStyle.RESET + " " + AnsiStyle.ITALIC + renderInline(stripped.substring(2)) + AnsiStyle.RESET; + } + + // 复选框列表(在无序列表前检测) + if (stripped.startsWith("- [ ] ")) { + return AnsiStyle.DIM + "☐" + AnsiStyle.RESET + " " + renderInline(stripped.substring(6)); + } + if (stripped.startsWith("- [x] ") || stripped.startsWith("- [X] ")) { + return AnsiStyle.GREEN + "☑" + AnsiStyle.RESET + " " + renderInline(stripped.substring(6)); + } + + // 无序列表 + if (stripped.startsWith("- ") || stripped.startsWith("* ")) { + int indent = line.length() - stripped.length(); + String prefix = " ".repeat(indent); + return prefix + AnsiStyle.CYAN + "•" + AnsiStyle.RESET + " " + renderInline(stripped.substring(2)); + } + + // 有序列表 + if (stripped.matches("^\\d+\\.\\s+.*")) { + Matcher m = Pattern.compile("^(\\s*)(\\d+)\\.\\s+(.*)").matcher(line); + if (m.matches()) { + return m.group(1) + AnsiStyle.CYAN + m.group(2) + "." + AnsiStyle.RESET + " " + renderInline(m.group(3)); + } + } + + // 分隔线 + if (stripped.matches("^[-*]{3,}$")) { + return AnsiStyle.dim("─".repeat(42)); + } + + // 普通文本(行内格式渲染) + return renderInline(line); + } } diff --git a/src/main/java/com/claudecode/repl/ReplSession.java b/src/main/java/com/claudecode/repl/ReplSession.java index d906f22..a5180c2 100644 --- a/src/main/java/com/claudecode/repl/ReplSession.java +++ b/src/main/java/com/claudecode/repl/ReplSession.java @@ -329,31 +329,47 @@ public class ReplSession { conversationSummary = input.length() > 40 ? input.substring(0, 40) : input; } - // Agent 循环(流式输出) + // Agent 循环(流式输出 + 行缓冲 Markdown 渲染) try { spinner.start("Thinking..."); streamNewLine = true; // spinner 停止后 onStreamStart 会打印 ● 前缀 long startTime = System.currentTimeMillis(); - // 流式回调:逐 token 输出到终端,自动在每行开头加缩进 + // 行缓冲:累积 token 直到换行,再用 MarkdownRenderer 渲染整行输出 + StringBuilder lineBuffer = new StringBuilder(); + MarkdownRenderer.StreamState mdState = new MarkdownRenderer.StreamState(); + String response = agentLoop.runStreaming(input, token -> { for (int i = 0; i < token.length(); i++) { char c = token.charAt(i); if (c == '\n') { - out.println(); - streamNewLine = true; - } else { + // 行完成 → 渲染 Markdown 并输出 if (streamNewLine) { out.print(" "); // 续行缩进(与 ● 后文本对齐) streamNewLine = false; } - out.print(c); + String rendered = markdownRenderer.renderStreamingLine(lineBuffer.toString(), mdState); + out.println(rendered); + lineBuffer.setLength(0); + streamNewLine = true; + } else { + lineBuffer.append(c); } } out.flush(); }); + // 刷新残留缓冲(最后一行可能无 \n 结尾) + if (!lineBuffer.isEmpty()) { + if (streamNewLine) { + out.print(" "); + streamNewLine = false; + } + String rendered = markdownRenderer.renderStreamingLine(lineBuffer.toString(), mdState); + out.print(rendered); + } + spinner.stop(); out.println(); // 流式输出结束后换行