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>
pull/1/head
abel533 1 month ago
parent 95b45940fd
commit bd98dea6b3
  1. 158
      src/main/java/com/claudecode/console/MarkdownRenderer.java
  2. 52
      src/main/java/com/claudecode/console/SpinnerAnimation.java
  3. 123
      src/main/java/com/claudecode/console/ToolStatusRenderer.java

@ -87,10 +87,18 @@ public class MarkdownRenderer {
boolean inCodeBlock = false; boolean inCodeBlock = false;
String codeBlockLang = ""; String codeBlockLang = "";
boolean inTable = false;
java.util.List<String> tableLines = new java.util.ArrayList<>();
for (String line : markdown.lines().toList()) { for (String line : markdown.lines().toList()) {
// 代码块 // 代码块
if (line.stripLeading().startsWith("```")) { if (line.stripLeading().startsWith("```")) {
// Flush any pending table
if (inTable) {
renderTable(tableLines);
tableLines.clear();
inTable = false;
}
if (!inCodeBlock) { if (!inCodeBlock) {
codeBlockLang = line.stripLeading().substring(3).strip().toLowerCase(); codeBlockLang = line.stripLeading().substring(3).strip().toLowerCase();
inCodeBlock = true; inCodeBlock = true;
@ -110,6 +118,20 @@ public class MarkdownRenderer {
continue; 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("### ")) { if (line.startsWith("### ")) {
out.println(AnsiStyle.bold(AnsiStyle.CYAN + " " + line.substring(4)) + AnsiStyle.RESET); 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); out.println(AnsiStyle.bold(AnsiStyle.MAGENTA + " " + line.substring(2)) + AnsiStyle.RESET);
} }
// 引用块 // 引用块
else if (line.stripLeading().startsWith("> ")) { else if (stripped.startsWith("> ")) {
String quoteText = line.stripLeading().substring(2); String quoteText = stripped.substring(2);
out.println(" " + AnsiStyle.DIM + "┃" + AnsiStyle.RESET + " " + AnsiStyle.ITALIC + renderInline(quoteText) + AnsiStyle.RESET); 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); Matcher m = Pattern.compile("^(\\s*)(\\d+)\\.\\s+(.*)").matcher(line);
if (m.matches()) { if (m.matches()) {
String indent = m.group(1); String indent = m.group(1);
@ -136,19 +158,19 @@ public class MarkdownRenderer {
} }
} }
// 无序列表 // 无序列表
else if (line.stripLeading().startsWith("- ") || line.stripLeading().startsWith("* ")) { else if (stripped.startsWith("- ") || stripped.startsWith("* ")) {
int indent = line.length() - line.stripLeading().length(); int indent = line.length() - stripped.length();
String prefix = " ".repeat(indent); 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("- [ ] ")) { else if (stripped.startsWith("- [ ] ")) {
out.println(" " + AnsiStyle.DIM + "☐" + AnsiStyle.RESET + " " + renderInline(line.stripLeading().substring(6))); out.println(" " + AnsiStyle.DIM + "☐" + AnsiStyle.RESET + " " + renderInline(stripped.substring(6)));
} else if (line.stripLeading().startsWith("- [x] ") || line.stripLeading().startsWith("- [X] ")) { } else if (stripped.startsWith("- [x] ") || stripped.startsWith("- [X] ")) {
out.println(" " + AnsiStyle.GREEN + "☑" + AnsiStyle.RESET + " " + renderInline(line.stripLeading().substring(6))); 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))); out.println(AnsiStyle.dim(" " + "─".repeat(42)));
} }
// 普通文本 // 普通文本
@ -156,6 +178,120 @@ public class MarkdownRenderer {
out.println(" " + renderInline(line)); 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<String> tableLines) {
if (tableLines.isEmpty()) return;
// Parse all rows
var rows = new java.util.ArrayList<String[]>();
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);
} }
// ==================== 代码语法高亮 ==================== // ==================== 代码语法高亮 ====================

@ -6,33 +6,56 @@ import java.io.PrintStream;
* 加载动画Spinner 对应 claude-code/src/components/Spinner.tsx * 加载动画Spinner 对应 claude-code/src/components/Spinner.tsx
* <p> * <p>
* 在等待 AI 响应时显示旋转动画 * 在等待 AI 响应时显示旋转动画
* 增强功能多种动画样式进度追踪耗时计时
*/ */
public class SpinnerAnimation { 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 static final int INTERVAL_MS = 80;
private final PrintStream out; private final PrintStream out;
private volatile boolean running; private volatile boolean running;
private Thread thread; private Thread thread;
private String message = "Thinking"; private String message = "Thinking";
private long startTimeMs;
private String[] frames = BRAILLE_FRAMES;
public SpinnerAnimation(PrintStream out) { public SpinnerAnimation(PrintStream out) {
this.out = 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 */ /** 启动 spinner */
public void start(String message) { public void start(String message) {
if (running) return; if (running) return;
this.message = message; this.message = message;
this.running = true; this.running = true;
this.startTimeMs = System.currentTimeMillis();
thread = Thread.ofVirtual().name("spinner").start(() -> { thread = Thread.ofVirtual().name("spinner").start(() -> {
int idx = 0; int idx = 0;
while (running) { while (running) {
long elapsed = System.currentTimeMillis() - startTimeMs;
String timeStr = elapsed > 2000 ? " " + formatElapsed(elapsed) : "";
out.print(AnsiStyle.clearLine()); out.print(AnsiStyle.clearLine());
out.print(AnsiStyle.CYAN + " " + FRAMES[idx % FRAMES.length] out.print(AnsiStyle.CYAN + " " + frames[idx % frames.length]
+ " " + AnsiStyle.RESET + AnsiStyle.dim(this.message)); + " " + AnsiStyle.RESET + AnsiStyle.dim(this.message + timeStr));
out.flush(); out.flush();
idx++; idx++;
try { 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) { public void updateMessage(String newMessage) {
this.message = newMessage; this.message = newMessage;
@ -69,4 +99,20 @@ public class SpinnerAnimation {
public boolean isRunning() { public boolean isRunning() {
return running; 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
}
} }

@ -6,6 +6,7 @@ import java.io.PrintStream;
* 工具调用状态渲染器 对应 claude-code/src/components/ToolStatus.tsx * 工具调用状态渲染器 对应 claude-code/src/components/ToolStatus.tsx
* <p> * <p>
* 使用彩色 圆点标识工具调用状态配合 显示结果参考 Claude Code 样式 * 使用彩色 圆点标识工具调用状态配合 显示结果参考 Claude Code 样式
* 增强功能进度条权限对话框美化计时器
*/ */
public class ToolStatusRenderer { public class ToolStatusRenderer {
@ -30,26 +31,54 @@ public class ToolStatusRenderer {
out.println(AnsiStyle.dim(" running...")); 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) { 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; String display = result;
if (display != null && display.length() > 500) { if (display.length() > 500) {
display = display.substring(0, 497) + "..."; display = display.substring(0, 497) + "...";
} }
out.println(AnsiStyle.GREEN + " ● " + AnsiStyle.BOLD + toolName + AnsiStyle.RESET String[] lines = display.lines().toArray(String[]::new);
+ AnsiStyle.dim(" done")); for (int i = 0; i < lines.length; i++) {
if (i == 0) {
if (display != null && !display.isBlank()) { out.println(AnsiStyle.DIM + " ⎿ " + lines[i] + AnsiStyle.RESET);
// 使用 ⎿ 前缀显示结果(Claude Code 风格) } else {
String[] lines = display.lines().toArray(String[]::new); out.println(AnsiStyle.DIM + " " + lines[i] + AnsiStyle.RESET);
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 参数中提取人类可读的摘要 */ /** 从 JSON 参数中提取人类可读的摘要 */
private String extractSummary(String toolName, String args) { private String extractSummary(String toolName, String args) {
try { try {
@ -104,4 +187,16 @@ public class ToolStatusRenderer {
} }
return null; 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());
}
} }

Loading…
Cancel
Save