style: CLI视觉样式升级 - 边框Banner+●圆点+⎿结果+耗时显示

- BannerPrinter: 带边框的启动Banner(╭╮╰╯框线+咖啡杯Logo+双列布局)
- ToolStatusRenderer: 彩色●圆点标识+⎿结果前缀(Claude Code风格)
- ThinkingRenderer: <thought>标签块显示(Copilot CLI风格)
- ReplSession: AI回复●前缀+✻耗时显示+智能Banner选择

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
pull/1/head
liuzh 1 month ago
parent 1fc7a6a495
commit c52d5313a2
  1. 111
      src/main/java/com/claudecode/console/BannerPrinter.java
  2. 15
      src/main/java/com/claudecode/console/ThinkingRenderer.java
  3. 43
      src/main/java/com/claudecode/console/ToolStatusRenderer.java
  4. 46
      src/main/java/com/claudecode/repl/ReplSession.java

@ -5,41 +5,92 @@ import java.io.PrintStream;
/**
* Banner 打印器 对应 claude-code/src/components/Banner.tsx
* <p>
* 在启动时打印 ASCII Art Logo 和版本信息
* 在启动时打印带边框的 Logo 和版本信息
* 参考 Copilot CLI / Claude Code 的边框样式
*/
public class BannerPrinter {
private static final String VERSION = "0.1.0-SNAPSHOT";
// 边框字符
private static final String TL = "╭", TR = "╮", BL = "╰", BR = "╯";
private static final String H = "─", V = "│";
/**
* 打印 claude-code-java 启动 banner
* 打印带边框的启动 Banner
*
* @param out 输出流
* @param provider API 提供者名称
* @param model 模型名称
* @param baseUrl API URL
* @param workDir 工作目录
* @param toolCount 工具数量
* @param cmdCount 命令数量
* @param termInfo 终端信息
*/
public static void print(PrintStream out) {
String banner = """
%s
%s
%s
""".formatted(AnsiStyle.BRIGHT_CYAN, AnsiStyle.BRIGHT_MAGENTA, AnsiStyle.RESET);
out.println(banner);
out.println(AnsiStyle.bold(" Claude Code (Java)") + AnsiStyle.dim(" v" + VERSION));
out.println(AnsiStyle.dim(" Powered by Spring AI • Type /help for commands"));
public static void printBoxed(PrintStream out, String provider, String model,
String baseUrl, String workDir,
int toolCount, int cmdCount, String termInfo) {
int boxWidth = 90;
String hr = H.repeat(boxWidth - 2);
// Logo(简洁的 Java 咖啡杯 + 项目名)
String[] logo = {
" ╭───╮ ",
" │ ☕ │ ",
" ╭──╰───╯──╮",
" │ CLAUDE │",
" │ CODE │",
" ╰──────────╯"
};
// 右侧信息
String titleLine = "Claude Code Java v" + VERSION;
String descLine = "Describe a task to get started.";
String tipLine = "Tip: /help for commands, Tab to complete";
// 打印顶部边框
out.println();
out.println(" " + TL + "─── " + AnsiStyle.BOLD + AnsiStyle.BRIGHT_CYAN
+ "Claude Code Java" + AnsiStyle.RESET + AnsiStyle.DIM + " v" + VERSION
+ AnsiStyle.RESET + " " + H.repeat(boxWidth - 28 - VERSION.length()) + TR);
// Logo + 右侧信息(双列布局)
int logoWidth = 16; // logo 视觉宽度
int rightStart = logoWidth + 4;
int contentWidth = boxWidth - 4; // 边框内可用宽度
String[] rightInfo = {
"",
AnsiStyle.BOLD + "Welcome!" + AnsiStyle.RESET,
"",
AnsiStyle.DIM + "Provider: " + AnsiStyle.RESET + AnsiStyle.CYAN + provider.toUpperCase() + AnsiStyle.RESET
+ AnsiStyle.DIM + " Model: " + AnsiStyle.RESET + AnsiStyle.CYAN + model + AnsiStyle.RESET,
AnsiStyle.DIM + "Work Dir: " + workDir + AnsiStyle.RESET,
AnsiStyle.DIM + "Tools: " + toolCount + " | Commands: " + cmdCount + " | " + termInfo + AnsiStyle.RESET,
};
int maxRows = Math.max(logo.length, rightInfo.length);
for (int i = 0; i < maxRows; i++) {
String leftPart = i < logo.length ? logo[i] : "";
String rightPart = i < rightInfo.length ? rightInfo[i] : "";
// 左侧 logo 部分(固定宽度,无 ANSI 所以直接 pad)
String paddedLeft = padRight(leftPart, logoWidth);
// 输出行
out.println(" " + V + " "
+ AnsiStyle.BRIGHT_CYAN + paddedLeft + AnsiStyle.RESET
+ AnsiStyle.DIM + " │ " + AnsiStyle.RESET
+ rightPart);
}
// 底部边框
out.println(" " + BL + hr + BR);
}
/**
* 精简版 banner用于窄终端
* 精简版 banner用于窄终端 Scanner 模式
*/
public static void printCompact(PrintStream out) {
out.println();
@ -48,4 +99,16 @@ public class BannerPrinter {
out.println(AnsiStyle.dim(" Type /help for commands • Ctrl+D to exit"));
out.println();
}
/** 右侧补空格到指定视觉宽度 */
private static String padRight(String s, int width) {
int len = s.length();
if (len >= width) return s;
return s + " ".repeat(width - len);
}
/** 获取版本号 */
public static String getVersion() {
return VERSION;
}
}

@ -5,7 +5,7 @@ import java.io.PrintStream;
/**
* Thinking 内容渲染器 对应 claude-code/src/components/Thinking.tsx
* <p>
* 显示 AI 模型的思考过程extended thinking
* 使用 圆点 + &lt;thought&gt; 标签样式显示 AI 的思考过程参考 Copilot CLI
*/
public class ThinkingRenderer {
@ -15,25 +15,28 @@ public class ThinkingRenderer {
this.out = out;
}
/** 渲染 thinking 内容块 */
/** 渲染 thinking 内容块(Copilot CLI 的 &lt;thought&gt; 标签风格) */
public void render(String thinkingContent) {
if (thinkingContent == null || thinkingContent.isBlank()) {
return;
}
out.println();
out.println(AnsiStyle.DIM + AnsiStyle.ITALIC + " 💭 Thinking..." + AnsiStyle.RESET);
out.println(AnsiStyle.BRIGHT_MAGENTA + " ● " + AnsiStyle.DIM + "<thought>" + AnsiStyle.RESET);
// 显示 thinking 内容(缩进并用暗色)
// 缩进显示 thinking 内容
for (String line : thinkingContent.lines().toList()) {
out.println(AnsiStyle.DIM + " " + line + AnsiStyle.RESET);
out.println(AnsiStyle.DIM + " " + line + AnsiStyle.RESET);
}
out.println(AnsiStyle.DIM + " </thought>" + AnsiStyle.RESET);
out.println();
}
/** 渲染 thinking 开始标记 */
public void renderStart() {
out.print(AnsiStyle.DIM + AnsiStyle.ITALIC + " 💭 Thinking..." + AnsiStyle.RESET);
out.print(AnsiStyle.BRIGHT_MAGENTA + " ● " + AnsiStyle.DIM + AnsiStyle.ITALIC
+ "Thinking..." + AnsiStyle.RESET);
}
/** 渲染 thinking 结束标记 */

@ -5,7 +5,7 @@ import java.io.PrintStream;
/**
* 工具调用状态渲染器 对应 claude-code/src/components/ToolStatus.tsx
* <p>
* 在终端中显示工具调用的进度和结果
* 使用彩色 圆点标识工具调用状态配合 显示结果参考 Claude Code 样式
*/
public class ToolStatusRenderer {
@ -17,16 +17,17 @@ public class ToolStatusRenderer {
/** 渲染工具调用开始 */
public void renderStart(String toolName, String args) {
out.println(AnsiStyle.dim(" ─────────────────────────────────────────"));
out.print(AnsiStyle.YELLOW + " ⚙ " + AnsiStyle.BOLD + toolName + AnsiStyle.RESET);
out.println(AnsiStyle.dim(" running..."));
// 如果有简短参数,显示
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.println(AnsiStyle.dim(" " + summary));
out.print(AnsiStyle.dim("(" + summary + ")"));
}
}
out.println(AnsiStyle.dim(" running..."));
}
/** 渲染工具调用完成 */
@ -37,36 +38,40 @@ public class ToolStatusRenderer {
display = display.substring(0, 497) + "...";
}
out.println(AnsiStyle.GREEN + " " + AnsiStyle.BOLD + toolName + AnsiStyle.RESET
out.println(AnsiStyle.GREEN + " " + AnsiStyle.BOLD + toolName + AnsiStyle.RESET
+ AnsiStyle.dim(" done"));
if (display != null && !display.isBlank()) {
// 缩进输出每一行
for (String line : display.lines().toList()) {
out.println(AnsiStyle.dim(" " + line));
// 使用 ⎿ 前缀显示结果(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);
}
}
}
out.println(AnsiStyle.dim(" ─────────────────────────────────────────"));
}
/** 渲染工具错误 */
public void renderError(String toolName, String error) {
out.println(AnsiStyle.RED + " " + AnsiStyle.BOLD + toolName + AnsiStyle.RESET
out.println(AnsiStyle.RED + " " + AnsiStyle.BOLD + toolName + AnsiStyle.RESET
+ AnsiStyle.red(" error"));
if (error != null) {
out.println(AnsiStyle.red(" " + error));
out.println(AnsiStyle.DIM + " ⎿ " + AnsiStyle.RED + error + AnsiStyle.RESET);
}
}
/** 从 JSON 参数中提取人类可读的摘要 */
private String extractSummary(String toolName, String args) {
try {
// 简单提取关键字段
if (args.contains("\"command\"")) {
int start = args.indexOf("\"command\"");
int valStart = args.indexOf("\"", start + 10) + 1;
int valEnd = args.indexOf("\"", valStart);
if (valStart > 0 && valEnd > valStart) {
String cmd = args.substring(valStart, Math.min(valEnd, valStart + 80));
String cmd = args.substring(valStart, Math.min(valEnd, valStart + 60));
return "$ " + cmd;
}
}
@ -86,6 +91,14 @@ public class ToolStatusRenderer {
return "pattern: " + args.substring(valStart, valEnd);
}
}
if (args.contains("\"query\"")) {
int start = args.indexOf("\"query\"");
int valStart = args.indexOf("\"", start + 8) + 1;
int valEnd = args.indexOf("\"", valStart);
if (valStart > 0 && valEnd > valStart) {
return "\"" + args.substring(valStart, Math.min(valEnd, valStart + 60)) + "\"";
}
}
} catch (Exception e) {
// 忽略解析错误
}

@ -220,16 +220,6 @@ public class ReplSession {
/** 打印启动 Banner(JLine 模式) */
private void printBanner(Terminal terminal) {
BannerPrinter.printCompact(out);
// 显示 API 提供者、模型和 URL
out.println(AnsiStyle.dim(" Provider: ") + AnsiStyle.cyan(providerInfo.provider().toUpperCase())
+ AnsiStyle.dim(" Model: ") + AnsiStyle.cyan(providerInfo.model()));
out.println(AnsiStyle.dim(" API URL: ") + AnsiStyle.cyan(providerInfo.baseUrl()));
out.println(AnsiStyle.dim(" Work Dir: " + System.getProperty("user.dir")));
out.println(AnsiStyle.dim(" Tools: " + toolRegistry.size() + " | Commands: " + commandRegistry.getCommands().size()));
boolean isDumb = "dumb".equals(terminal.getType());
int w = terminal.getWidth();
int h = terminal.getHeight();
@ -244,14 +234,27 @@ public class ReplSession {
termInfo += " [vim]";
}
out.println(AnsiStyle.dim(" Terminal: " + termInfo));
if (isDumb || w < 60) {
// 窄终端/dumb 模式用精简 Banner
BannerPrinter.printCompact(out);
out.println(AnsiStyle.dim(" Provider: ") + AnsiStyle.cyan(providerInfo.provider().toUpperCase())
+ AnsiStyle.dim(" Model: ") + AnsiStyle.cyan(providerInfo.model()));
out.println(AnsiStyle.dim(" Work Dir: " + System.getProperty("user.dir")));
if (isDumb) {
out.println(AnsiStyle.yellow(" ⚠ Dumb 终端模式:Tab补全和行编辑可能受限"));
out.println(AnsiStyle.yellow(" 建议在 Windows Terminal / PowerShell / cmd.exe 中运行"));
out.println(AnsiStyle.yellow(" ⚠ Dumb 终端模式:建议在 Windows Terminal / PowerShell 中运行"));
}
} else {
out.println(AnsiStyle.dim(" Tip: Tab to complete commands, ↑↓ to browse history, Ctrl+D to exit"));
// 标准终端用带边框的 Banner
BannerPrinter.printBoxed(out,
providerInfo.provider(),
providerInfo.model(),
providerInfo.baseUrl(),
System.getProperty("user.dir"),
toolRegistry.size(),
commandRegistry.getCommands().size(),
termInfo);
}
out.println();
}
@ -312,6 +315,11 @@ public class ReplSession {
spinner.start("Thinking...");
out.println(); // 换行准备输出区域
long startTime = System.currentTimeMillis();
// AI 回复前的 ● 标识
out.println(AnsiStyle.BRIGHT_CYAN + " ● " + AnsiStyle.RESET);
// 流式回调:逐 token 输出到终端
String response = agentLoop.runStreaming(input, token -> {
out.print(token);
@ -321,6 +329,12 @@ public class ReplSession {
spinner.stop();
out.println(); // 流式输出结束后换行
// 显示耗时
long elapsed = (System.currentTimeMillis() - startTime) / 1000;
if (elapsed > 0) {
out.println(AnsiStyle.DIM + " ✻ Worked for " + elapsed + "s" + AnsiStyle.RESET);
}
// 刷新底部状态行(显示最新 token 用量)
if (statusLine.isEnabled()) {
out.println(statusLine.renderInline());
@ -328,7 +342,7 @@ public class ReplSession {
out.println();
} catch (Exception e) {
spinner.stop();
out.println(AnsiStyle.red("\n ✗ Error: " + e.getMessage()));
out.println(AnsiStyle.RED + "\n ● Error: " + AnsiStyle.RESET + e.getMessage());
log.error("Agent 循环异常", e);
out.println();
}

Loading…
Cancel
Save