From bd98dea6b35670e773c7937d1e7ed615ac4e05cc Mon Sep 17 00:00:00 2001 From: abel533 Date: Sun, 5 Apr 2026 09:24:04 +0800 Subject: [PATCH] feat: console rendering enhancements (Phase 1B.4) - MarkdownRenderer: table parsing and box-drawing character rendering - ToolStatusRenderer: permission dialog beautification, progress bar, duration tracking - SpinnerAnimation: multiple animation styles, elapsed time display, stopAndGetElapsed() Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../claudecode/console/MarkdownRenderer.java | 158 ++++++++++++++++-- .../claudecode/console/SpinnerAnimation.java | 52 +++++- .../console/ToolStatusRenderer.java | 123 ++++++++++++-- 3 files changed, 305 insertions(+), 28 deletions(-) diff --git a/src/main/java/com/claudecode/console/MarkdownRenderer.java b/src/main/java/com/claudecode/console/MarkdownRenderer.java index 3e7d793..c7f7936 100644 --- a/src/main/java/com/claudecode/console/MarkdownRenderer.java +++ b/src/main/java/com/claudecode/console/MarkdownRenderer.java @@ -87,10 +87,18 @@ public class MarkdownRenderer { boolean inCodeBlock = false; String codeBlockLang = ""; + boolean inTable = false; + java.util.List tableLines = new java.util.ArrayList<>(); for (String line : markdown.lines().toList()) { // 代码块 if (line.stripLeading().startsWith("```")) { + // Flush any pending table + if (inTable) { + renderTable(tableLines); + tableLines.clear(); + inTable = false; + } if (!inCodeBlock) { codeBlockLang = line.stripLeading().substring(3).strip().toLowerCase(); inCodeBlock = true; @@ -110,6 +118,20 @@ public class MarkdownRenderer { continue; } + // Table detection: lines containing | separators + String stripped = line.stripLeading(); + if (stripped.contains("|") && (stripped.startsWith("|") || isTableLine(stripped))) { + if (!inTable) { + inTable = true; + } + tableLines.add(stripped); + continue; + } else if (inTable) { + renderTable(tableLines); + tableLines.clear(); + inTable = false; + } + // 标题 if (line.startsWith("### ")) { out.println(AnsiStyle.bold(AnsiStyle.CYAN + " " + line.substring(4)) + AnsiStyle.RESET); @@ -119,12 +141,12 @@ public class MarkdownRenderer { out.println(AnsiStyle.bold(AnsiStyle.MAGENTA + " " + line.substring(2)) + AnsiStyle.RESET); } // 引用块 - else if (line.stripLeading().startsWith("> ")) { - String quoteText = line.stripLeading().substring(2); + else if (stripped.startsWith("> ")) { + String quoteText = stripped.substring(2); out.println(" " + AnsiStyle.DIM + "┃" + AnsiStyle.RESET + " " + AnsiStyle.ITALIC + renderInline(quoteText) + AnsiStyle.RESET); } // 有序列表 - else if (line.stripLeading().matches("^\\d+\\.\\s+.*")) { + else if (stripped.matches("^\\d+\\.\\s+.*")) { Matcher m = Pattern.compile("^(\\s*)(\\d+)\\.\\s+(.*)").matcher(line); if (m.matches()) { String indent = m.group(1); @@ -136,19 +158,19 @@ public class MarkdownRenderer { } } // 无序列表 - else if (line.stripLeading().startsWith("- ") || line.stripLeading().startsWith("* ")) { - int indent = line.length() - line.stripLeading().length(); + else if (stripped.startsWith("- ") || stripped.startsWith("* ")) { + int indent = line.length() - stripped.length(); String prefix = " ".repeat(indent); - out.println(" " + prefix + AnsiStyle.CYAN + "•" + AnsiStyle.RESET + " " + renderInline(line.stripLeading().substring(2))); + out.println(" " + prefix + AnsiStyle.CYAN + "•" + AnsiStyle.RESET + " " + renderInline(stripped.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 (stripped.startsWith("- [ ] ")) { + out.println(" " + AnsiStyle.DIM + "☐" + AnsiStyle.RESET + " " + renderInline(stripped.substring(6))); + } else if (stripped.startsWith("- [x] ") || stripped.startsWith("- [X] ")) { + out.println(" " + AnsiStyle.GREEN + "☑" + AnsiStyle.RESET + " " + renderInline(stripped.substring(6))); } // 分隔线 - else if (line.strip().matches("^[-*]{3,}$")) { + else if (stripped.matches("^[-*]{3,}$")) { out.println(AnsiStyle.dim(" " + "─".repeat(42))); } // 普通文本 @@ -156,6 +178,120 @@ public class MarkdownRenderer { out.println(" " + renderInline(line)); } } + + // Flush any remaining table + if (inTable) { + renderTable(tableLines); + } + } + + // ==================== 表格渲染 ==================== + + /** + * 检测行是否是表格行(包含至少一个 | 且有文本内容)。 + */ + private boolean isTableLine(String line) { + if (!line.contains("|")) return false; + // Separator lines like |---|---| + if (line.matches("^\\|?[\\s:-]+\\|[\\s|:-]*$")) return true; + // Content lines + String[] parts = line.split("\\|"); + return parts.length >= 2; + } + + /** + * 渲染 Markdown 表格为对齐的终端输出。 + * 支持标准 Markdown 表格格式(带/不带首尾 |)。 + */ + private void renderTable(java.util.List tableLines) { + if (tableLines.isEmpty()) return; + + // Parse all rows + var rows = new java.util.ArrayList(); + int separatorIdx = -1; + + for (int i = 0; i < tableLines.size(); i++) { + String line = tableLines.get(i).strip(); + // Remove leading/trailing | + if (line.startsWith("|")) line = line.substring(1); + if (line.endsWith("|")) line = line.substring(0, line.length() - 1); + + // Check if separator line + if (line.matches("[\\s:-]+\\|[\\s|:-]*") || line.matches("[\\s:-]+")) { + separatorIdx = i; + continue; + } + + String[] cells = line.split("\\|"); + for (int j = 0; j < cells.length; j++) { + cells[j] = cells[j].strip(); + } + rows.add(cells); + } + + if (rows.isEmpty()) return; + + // Calculate column widths + int maxCols = rows.stream().mapToInt(r -> r.length).max().orElse(0); + int[] widths = new int[maxCols]; + for (String[] row : rows) { + for (int j = 0; j < row.length && j < maxCols; j++) { + widths[j] = Math.max(widths[j], stripAnsi(row[j]).length()); + } + } + // Ensure minimum width + for (int j = 0; j < widths.length; j++) { + widths[j] = Math.max(widths[j], 3); + } + + // Render + boolean isHeader = true; + for (String[] row : rows) { + StringBuilder sb = new StringBuilder(" "); + if (isHeader) { + // Header with bold + sb.append("│ "); + for (int j = 0; j < maxCols; j++) { + String cell = j < row.length ? row[j] : ""; + sb.append(AnsiStyle.bold(padRight(cell, widths[j]))); + if (j < maxCols - 1) sb.append(" │ "); + } + sb.append(" │"); + out.println(sb); + + // Separator + StringBuilder sep = new StringBuilder(" ├─"); + for (int j = 0; j < maxCols; j++) { + sep.append("─".repeat(widths[j])); + if (j < maxCols - 1) sep.append("─┼─"); + } + sep.append("─┤"); + out.println(AnsiStyle.dim(sep.toString())); + isHeader = false; + } else { + sb.append("│ "); + for (int j = 0; j < maxCols; j++) { + String cell = j < row.length ? row[j] : ""; + sb.append(padRight(renderInline(cell), widths[j] + (renderInline(cell).length() - stripAnsi(renderInline(cell)).length()))); + if (j < maxCols - 1) sb.append(" │ "); + } + sb.append(" │"); + out.println(sb); + } + } + } + + /** Strip ANSI escape codes for width calculation */ + private String stripAnsi(String s) { + return s.replaceAll("\u001B\\[[;\\d]*m", ""); + } + + /** Pad string to target width (considering visible length) */ + private String padRight(String s, int width) { + int visible = stripAnsi(s).length(); + int padding = width - visible; + if (padding <= 0) return s; + return s + " ".repeat(padding); } // ==================== 代码语法高亮 ==================== diff --git a/src/main/java/com/claudecode/console/SpinnerAnimation.java b/src/main/java/com/claudecode/console/SpinnerAnimation.java index dcffe44..9c636fd 100644 --- a/src/main/java/com/claudecode/console/SpinnerAnimation.java +++ b/src/main/java/com/claudecode/console/SpinnerAnimation.java @@ -6,33 +6,56 @@ import java.io.PrintStream; * 加载动画(Spinner)—— 对应 claude-code/src/components/Spinner.tsx。 *

* 在等待 AI 响应时显示旋转动画。 + * 增强功能:多种动画样式、进度追踪、耗时计时。 */ public class SpinnerAnimation { - private static final String[] FRAMES = {"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}; + /** 标准 braille spinner */ + private static final String[] BRAILLE_FRAMES = {"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}; + /** 简约点动画 */ + private static final String[] DOT_FRAMES = {"⠋", "⠙", "⠚", "⠒", "⠂", "⠂", "⠒", "⠖", "⠦", "⠖", "⠒", "⠂", "⠂", "⠒", "⠚", "⠙"}; + /** 箭头动画 */ + private static final String[] ARROW_FRAMES = {"▹▹▹▹▹", "▸▹▹▹▹", "▹▸▹▹▹", "▹▹▸▹▹", "▹▹▹▸▹", "▹▹▹▹▸"}; + private static final int INTERVAL_MS = 80; private final PrintStream out; private volatile boolean running; private Thread thread; private String message = "Thinking"; + private long startTimeMs; + private String[] frames = BRAILLE_FRAMES; public SpinnerAnimation(PrintStream out) { this.out = out; } + /** 设置动画样式 */ + public SpinnerAnimation withStyle(Style style) { + this.frames = switch (style) { + case BRAILLE -> BRAILLE_FRAMES; + case DOT -> DOT_FRAMES; + case ARROW -> ARROW_FRAMES; + }; + return this; + } + /** 启动 spinner */ public void start(String message) { if (running) return; this.message = message; this.running = true; + this.startTimeMs = System.currentTimeMillis(); thread = Thread.ofVirtual().name("spinner").start(() -> { int idx = 0; while (running) { + long elapsed = System.currentTimeMillis() - startTimeMs; + String timeStr = elapsed > 2000 ? " " + formatElapsed(elapsed) : ""; + out.print(AnsiStyle.clearLine()); - out.print(AnsiStyle.CYAN + " " + FRAMES[idx % FRAMES.length] - + " " + AnsiStyle.RESET + AnsiStyle.dim(this.message)); + out.print(AnsiStyle.CYAN + " " + frames[idx % frames.length] + + " " + AnsiStyle.RESET + AnsiStyle.dim(this.message + timeStr)); out.flush(); idx++; try { @@ -61,6 +84,13 @@ public class SpinnerAnimation { } } + /** 停止 spinner 并返回耗时 (ms) */ + public long stopAndGetElapsed() { + long elapsed = System.currentTimeMillis() - startTimeMs; + stop(); + return elapsed; + } + /** 更新消息 */ public void updateMessage(String newMessage) { this.message = newMessage; @@ -69,4 +99,20 @@ public class SpinnerAnimation { public boolean isRunning() { return running; } + + /** 获取已经过的时间 (ms) */ + public long getElapsedMs() { + return System.currentTimeMillis() - startTimeMs; + } + + private String formatElapsed(long ms) { + if (ms < 1000) return ms + "ms"; + if (ms < 60_000) return String.format("%.1fs", ms / 1000.0); + return String.format("%dm%ds", ms / 60_000, (ms % 60_000) / 1000); + } + + /** 动画样式 */ + public enum Style { + BRAILLE, DOT, ARROW + } } diff --git a/src/main/java/com/claudecode/console/ToolStatusRenderer.java b/src/main/java/com/claudecode/console/ToolStatusRenderer.java index 0dce39c..a3a725d 100644 --- a/src/main/java/com/claudecode/console/ToolStatusRenderer.java +++ b/src/main/java/com/claudecode/console/ToolStatusRenderer.java @@ -6,6 +6,7 @@ import java.io.PrintStream; * 工具调用状态渲染器 —— 对应 claude-code/src/components/ToolStatus.tsx。 *

* 使用彩色 ● 圆点标识工具调用状态,配合 ⎿ 显示结果(参考 Claude Code 样式)。 + * 增强功能:进度条、权限对话框美化、计时器。 */ public class ToolStatusRenderer { @@ -30,26 +31,54 @@ public class ToolStatusRenderer { out.println(AnsiStyle.dim(" running...")); } + /** 渲染工具调用开始(带进度追踪) */ + public void renderStartWithTimer(String toolName, String args) { + out.println(); + out.print(AnsiStyle.BRIGHT_BLUE + " ● " + AnsiStyle.BOLD + toolName + AnsiStyle.RESET); + + if (args != null && !args.isBlank()) { + String summary = extractSummary(toolName, args); + if (summary != null) { + out.print(AnsiStyle.dim(" " + summary)); + } + } + out.println(AnsiStyle.dim(" ⏱ running...")); + } + + /** 渲染工具调用完成(带耗时) */ + public void renderEnd(String toolName, String result, long durationMs) { + String timeStr = durationMs > 0 ? formatDuration(durationMs) : ""; + + out.println(AnsiStyle.GREEN + " ● " + AnsiStyle.BOLD + toolName + AnsiStyle.RESET + + AnsiStyle.dim(" done") + + (timeStr.isEmpty() ? "" : AnsiStyle.dim(" (" + timeStr + ")"))); + + renderResultBlock(result); + } + /** 渲染工具调用完成 */ public void renderEnd(String toolName, String result) { - // 截断长结果 + out.println(AnsiStyle.GREEN + " ● " + AnsiStyle.BOLD + toolName + AnsiStyle.RESET + + AnsiStyle.dim(" done")); + + renderResultBlock(result); + } + + /** 渲染结果输出块 */ + private void renderResultBlock(String result) { + if (result == null || result.isBlank()) return; + String display = result; - if (display != null && display.length() > 500) { + if (display.length() > 500) { display = display.substring(0, 497) + "..."; } - out.println(AnsiStyle.GREEN + " ● " + AnsiStyle.BOLD + toolName + AnsiStyle.RESET - + AnsiStyle.dim(" done")); - - if (display != null && !display.isBlank()) { - // 使用 ⎿ 前缀显示结果(Claude Code 风格) - String[] lines = display.lines().toArray(String[]::new); - for (int i = 0; i < lines.length; i++) { - if (i == 0) { - out.println(AnsiStyle.DIM + " ⎿ " + lines[i] + AnsiStyle.RESET); - } else { - out.println(AnsiStyle.DIM + " " + lines[i] + AnsiStyle.RESET); - } + String[] lines = display.lines().toArray(String[]::new); + for (int i = 0; i < lines.length; i++) { + if (i == 0) { + out.println(AnsiStyle.DIM + " ⎿ " + lines[i] + AnsiStyle.RESET); + } else { + out.println(AnsiStyle.DIM + " " + lines[i] + AnsiStyle.RESET); } } } @@ -63,6 +92,60 @@ public class ToolStatusRenderer { } } + /** 渲染权限请求对话框 */ + public void renderPermissionRequest(String toolName, String action, String detail) { + out.println(); + out.println(AnsiStyle.YELLOW + " ┌─────────────────────────────────────────────┐" + AnsiStyle.RESET); + out.println(AnsiStyle.YELLOW + " │" + AnsiStyle.RESET + " 🔐 " + AnsiStyle.bold("Permission Required") + + " " + AnsiStyle.YELLOW + "│" + AnsiStyle.RESET); + out.println(AnsiStyle.YELLOW + " ├─────────────────────────────────────────────┤" + AnsiStyle.RESET); + out.println(AnsiStyle.YELLOW + " │" + AnsiStyle.RESET + + " Tool: " + AnsiStyle.bold(toolName) + + " ".repeat(Math.max(1, 37 - toolName.length())) + + AnsiStyle.YELLOW + "│" + AnsiStyle.RESET); + out.println(AnsiStyle.YELLOW + " │" + AnsiStyle.RESET + + " Action: " + truncPad(action, 35) + + AnsiStyle.YELLOW + "│" + AnsiStyle.RESET); + if (detail != null && !detail.isBlank()) { + out.println(AnsiStyle.YELLOW + " │" + AnsiStyle.RESET + + " Detail: " + AnsiStyle.dim(truncPad(detail, 35)) + + AnsiStyle.YELLOW + "│" + AnsiStyle.RESET); + } + out.println(AnsiStyle.YELLOW + " ├─────────────────────────────────────────────┤" + AnsiStyle.RESET); + out.println(AnsiStyle.YELLOW + " │" + AnsiStyle.RESET + " " + + AnsiStyle.green("[y] Allow") + " " + + AnsiStyle.red("[n] Deny") + " " + + AnsiStyle.dim("[a] Always allow") + + " " + AnsiStyle.YELLOW + "│" + AnsiStyle.RESET); + out.println(AnsiStyle.YELLOW + " └─────────────────────────────────────────────┘" + AnsiStyle.RESET); + } + + /** 渲染进度条 */ + public void renderProgress(String label, int current, int total) { + if (total <= 0) return; + int width = 30; + int filled = (int) ((double) current / total * width); + filled = Math.min(filled, width); + + StringBuilder bar = new StringBuilder(); + bar.append(AnsiStyle.BRIGHT_BLUE); + bar.append("█".repeat(filled)); + bar.append(AnsiStyle.DIM); + bar.append("░".repeat(width - filled)); + bar.append(AnsiStyle.RESET); + + String pct = String.format("%d%%", (int) ((double) current / total * 100)); + out.print(AnsiStyle.clearLine()); + out.print(" " + label + " " + bar + " " + AnsiStyle.dim(current + "/" + total + " " + pct)); + out.flush(); + } + + /** 渲染进度条(完成后换行) */ + public void renderProgressDone(String label, int total) { + renderProgress(label, total, total); + out.println(); + } + /** 从 JSON 参数中提取人类可读的摘要 */ private String extractSummary(String toolName, String args) { try { @@ -104,4 +187,16 @@ public class ToolStatusRenderer { } return null; } + + private String formatDuration(long ms) { + if (ms < 1000) return ms + "ms"; + if (ms < 60_000) return String.format("%.1fs", ms / 1000.0); + return String.format("%dm%ds", ms / 60_000, (ms % 60_000) / 1000); + } + + private String truncPad(String s, int width) { + if (s == null) return " ".repeat(width); + if (s.length() > width) return s.substring(0, width - 3) + "..."; + return s + " ".repeat(width - s.length()); + } }