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>
This commit is contained in:
@@ -87,10 +87,18 @@ public class MarkdownRenderer {
|
||||
|
||||
boolean inCodeBlock = false;
|
||||
String codeBlockLang = "";
|
||||
boolean inTable = false;
|
||||
java.util.List<String> 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<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。
|
||||
* <p>
|
||||
* 在等待 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import java.io.PrintStream;
|
||||
* 工具调用状态渲染器 —— 对应 claude-code/src/components/ToolStatus.tsx。
|
||||
* <p>
|
||||
* 使用彩色 ● 圆点标识工具调用状态,配合 ⎿ 显示结果(参考 Claude Code 样式)。
|
||||
* 增强功能:进度条、权限对话框美化、计时器。
|
||||
*/
|
||||
public class ToolStatusRenderer {
|
||||
|
||||
@@ -30,19 +31,48 @@ public class ToolStatusRenderer {
|
||||
out.println(AnsiStyle.dim(" running..."));
|
||||
}
|
||||
|
||||
/** 渲染工具调用完成 */
|
||||
public void renderEnd(String toolName, String result) {
|
||||
// 截断长结果
|
||||
String display = result;
|
||||
if (display != null && display.length() > 500) {
|
||||
display = display.substring(0, 497) + "...";
|
||||
/** 渲染工具调用开始(带进度追踪) */
|
||||
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"));
|
||||
|
||||
if (display != null && !display.isBlank()) {
|
||||
// 使用 ⎿ 前缀显示结果(Claude Code 风格)
|
||||
renderResultBlock(result);
|
||||
}
|
||||
|
||||
/** 渲染结果输出块 */
|
||||
private void renderResultBlock(String result) {
|
||||
if (result == null || result.isBlank()) return;
|
||||
|
||||
String display = result;
|
||||
if (display.length() > 500) {
|
||||
display = display.substring(0, 497) + "...";
|
||||
}
|
||||
|
||||
String[] lines = display.lines().toArray(String[]::new);
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
if (i == 0) {
|
||||
@@ -52,7 +82,6 @@ public class ToolStatusRenderer {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 渲染工具错误 */
|
||||
public void renderError(String toolName, String error) {
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user