enhance: ASCII logo header, thinking animation, markdown rendering

- Restore original ASCII coffee cup logo in header (dual-column layout)
- Fix shortcutBar to single line with height(1) constraint
- Add spinning animation for thinking indicator (◐◓◑◒ cycle at 120ms)
- Create MarkdownToText converter for assistant messages:
  - Headers (# ## ###) with distinct colors and prefixes
  - Bold (**text**) rendering
  - Inline code (\code\) in yellow
  - Code blocks with language label and │ prefix
  - Ordered/unordered lists with bullet points
- Streaming messages show raw text; completed messages use markdown

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
pull/1/head
abel533 1 month ago
parent b72fcfea79
commit 9a437889b7
  1. 162
      src/main/java/com/claudecode/tui/ClaudeCodeComponent.java
  2. 154
      src/main/java/com/claudecode/tui/MarkdownToText.java

@ -77,6 +77,11 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
private String savedInput = ""; private String savedInput = "";
private final AtomicBoolean agentRunning = new AtomicBoolean(false); private final AtomicBoolean agentRunning = new AtomicBoolean(false);
/** 思考动画帧 */
private static final String[] SPINNER_FRAMES = {"◐", "◓", "◑", "◒"};
private volatile int spinnerFrame = 0;
private volatile Thread spinnerThread;
/** 权限确认回调(由权限请求设置,用户输入后调用) */ /** 权限确认回调(由权限请求设置,用户输入后调用) */
private volatile Consumer<String> permissionCallback; private volatile Consumer<String> permissionCallback;
@ -125,7 +130,7 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
int cursorCol = 1 + PROMPT_WIDTH + StringWidth.width(lastLine); int cursorCol = 1 + PROMPT_WIDTH + StringWidth.width(lastLine);
setCursorPosition(cursorRow, cursorCol); setCursorPosition(cursorRow, cursorCol);
int headerHeight = 7; int headerHeight = 8; // 6 content rows + 2 border lines
int bottomHeight = 4 + inputLineCount; int bottomHeight = 4 + inputLineCount;
int messagePaddingTop = 1; int messagePaddingTop = 1;
int maxMessageLines = h - headerHeight - bottomHeight - messagePaddingTop; int maxMessageLines = h - headerHeight - bottomHeight - messagePaddingTop;
@ -142,40 +147,57 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
).flexDirection(FlexDirection.COLUMN).width(w).height(h); ).flexDirection(FlexDirection.COLUMN).width(w).height(h);
} }
/** 标题框(圆角洋红色边框) */ /** 标题框 — 保留原始 ASCII Logo 样式(双列布局) */
private Renderable headerBox(int w) { private Renderable headerBox(int w) {
return Box.of( // ASCII 冒烟咖啡杯
Text.of( String[] logo = {
Text.of("☕").color(Color.BRIGHT_YELLOW), " ) ) ) ",
Text.of(" "), " ╭────────╮ ",
Text.of("Claude Code").color(Color.BRIGHT_MAGENTA).bold(), " │ ~~~~~~ │─╮ ",
Text.of(" (Java)").color(Color.WHITE), " │ CLAUDE │ │ ",
Text.of(" v" + BannerPrinter.getVersion()).dimmed() " │ CODE │─╯ ",
), " ╰─┬────┬─╯ "
Text.of( };
Text.of("▸ ").color(Color.BRIGHT_CYAN), // 右侧信息
Text.of("API: ").dimmed(), String[] info = {
Text.of(baseUrl).color(Color.BRIGHT_CYAN) "",
), "Welcome!",
Text.of( "API: " + baseUrl,
Text.of("▸ ").color(Color.BRIGHT_CYAN), "Protocol: " + provider.toUpperCase() + " Model: " + model,
Text.of("Provider: ").dimmed(), "Work Dir: " + System.getProperty("user.dir", "."),
Text.of(provider.toUpperCase()).color(Color.BRIGHT_GREEN), "Tools: " + toolCount + " | Commands: " + cmdCount
Text.of(" Model: ").dimmed(), };
Text.of(model).color(Color.BRIGHT_GREEN)
), // 构建双列文本行
Text.of(" "), int logoWidth = 19;
Text.of( int sepWidth = 3; // " │ "
Text.of("Tip: ").dimmed(), int rightWidth = Math.max(0, w - 4 - logoWidth - sepWidth - 2); // 4=border+padding
Text.of("/help").color(Color.BRIGHT_CYAN).bold(),
Text.of(" for commands • ").dimmed(), List<Renderable> rows = new ArrayList<>();
Text.of("Ctrl+D").color(Color.BRIGHT_CYAN).bold(), int maxRows = Math.max(logo.length, info.length);
Text.of(" to exit").dimmed() for (int i = 0; i < maxRows; i++) {
) String left = i < logo.length ? logo[i] : "";
).flexDirection(FlexDirection.COLUMN) String right = i < info.length ? info[i] : "";
// 补齐左侧
if (left.length() < logoWidth) left = left + " ".repeat(logoWidth - left.length());
// 截断右侧
if (right.length() > rightWidth) right = right.substring(0, rightWidth);
// 右侧补齐
right = right + " ".repeat(Math.max(0, rightWidth - right.length()));
rows.add(Text.of(
Text.of(left).color(Color.BRIGHT_CYAN),
Text.of(" │ ").dimmed(),
i == 1 ? Text.of(right).bold() : Text.of(right).dimmed()
));
}
return Box.of(rows.toArray(new Renderable[0]))
.flexDirection(FlexDirection.COLUMN)
.borderStyle(BorderStyle.ROUND) .borderStyle(BorderStyle.ROUND)
.borderColor(Color.BRIGHT_MAGENTA) .borderColor(Color.BRIGHT_MAGENTA)
.paddingX(1); .paddingX(1)
.width(w);
} }
/** 消息列表(带虚拟滚动) */ /** 消息列表(带虚拟滚动) */
@ -236,32 +258,37 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
} }
yield lines; yield lines;
} }
// 将多行文本拆分为单独的行
if (!m.streaming()) {
// 已完成的消息 — 使用 Markdown 渲染
List<Renderable> mdLines = MarkdownToText.convert(text);
for (int i = 0; i < mdLines.size(); i++) {
if (i == 0) {
lines.add(Text.of(Text.of("● ").color(Color.BRIGHT_CYAN), mdLines.get(i)));
} else {
lines.add(Text.of(Text.of(" "), mdLines.get(i)));
}
}
} else {
// 流式中 — 直接按行显示带光标
String[] textLines = text.split("\n", -1); String[] textLines = text.split("\n", -1);
for (int i = 0; i < textLines.length; i++) { for (int i = 0; i < textLines.length; i++) {
String line = textLines[i]; String line = textLines[i];
String displayLine = (m.streaming() && i == textLines.length - 1)
? line + "▌" : line;
if (i == 0) { if (i == 0) {
// 首行带 ● 前缀
String displayLine = line;
if (m.streaming() && i == textLines.length - 1) {
displayLine += "▌";
}
lines.add(Text.of( lines.add(Text.of(
Text.of("● ").color(Color.BRIGHT_CYAN), Text.of("● ").color(Color.BRIGHT_CYAN),
Text.of(displayLine).color(Color.WHITE) Text.of(displayLine).color(Color.WHITE)
)); ));
} else { } else {
// 续行缩进对齐
String displayLine = line;
if (m.streaming() && i == textLines.length - 1) {
displayLine += "▌";
}
lines.add(Text.of( lines.add(Text.of(
Text.of(" ").dimmed(), Text.of(" ").dimmed(),
Text.of(displayLine).color(Color.WHITE) Text.of(displayLine).color(Color.WHITE)
)); ));
} }
} }
}
yield lines; yield lines;
} }
@ -388,8 +415,10 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
? "Y/a/n/d >" ? "Y/a/n/d >"
: s.inputText).color(Color.BRIGHT_YELLOW); : s.inputText).color(Color.BRIGHT_YELLOW);
} else if (agentRunning.get()) { } else if (agentRunning.get()) {
// AI 正在运行 // AI 正在运行 — 使用旋转动画
content = Text.of(s.thinking ? "◐ Thinking..." : "● Processing...").color(Color.BRIGHT_CYAN).dimmed(); String spinner = SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length];
String label = s.thinking ? " Thinking..." : " Processing...";
content = Text.of(spinner + label).color(Color.BRIGHT_CYAN).dimmed();
prompt = Text.of(" ").dimmed(); prompt = Text.of(" ").dimmed();
} else if (s.inputText.isEmpty()) { } else if (s.inputText.isEmpty()) {
content = Text.of("Type a message, / for commands, or Ctrl+D to exit").dimmed(); content = Text.of("Type a message, / for commands, or Ctrl+D to exit").dimmed();
@ -417,19 +446,10 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
} }
return Box.of( return Box.of(
Text.of( Text.of("↑↓ history wheel scroll Ctrl+D exit").dimmed(),
Text.of("↑↓").dimmed(),
Text.of(" history").dimmed(),
Text.of(" "),
Text.of("wheel").dimmed(),
Text.of(" scroll").dimmed(),
Text.of(" "),
Text.of("Ctrl+D").dimmed(),
Text.of(" exit").dimmed()
),
Spacer.create(), Spacer.create(),
Text.of(tokenInfo).color(Color.BRIGHT_GREEN) Text.of(tokenInfo).color(Color.BRIGHT_GREEN)
).paddingX(1); ).paddingX(1).height(1);
} }
private String formatTokens(long tokens) { private String formatTokens(long tokens) {
@ -589,6 +609,7 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
/** 在后台线程运行 Agent 循环 */ /** 在后台线程运行 Agent 循环 */
private void runAgent(String userInput) { private void runAgent(String userInput) {
agentRunning.set(true); agentRunning.set(true);
startSpinner();
Thread.startVirtualThread(() -> { Thread.startVirtualThread(() -> {
long startTime = System.currentTimeMillis(); long startTime = System.currentTimeMillis();
@ -609,6 +630,7 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
} catch (Exception e) { } catch (Exception e) {
addMessage(new SystemMsg("Error: " + e.getMessage(), Color.BRIGHT_RED)); addMessage(new SystemMsg("Error: " + e.getMessage(), Color.BRIGHT_RED));
} finally { } finally {
stopSpinner();
agentRunning.set(false); agentRunning.set(false);
synchronized (stateLock) { synchronized (stateLock) {
TuiState cs = getState(); TuiState cs = getState();
@ -618,6 +640,34 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
}); });
} }
/** 启动思考动画 */
private void startSpinner() {
spinnerFrame = 0;
Thread t = Thread.startVirtualThread(() -> {
try {
while (!Thread.currentThread().isInterrupted()) {
Thread.sleep(120);
spinnerFrame++;
// 触发重绘:读取当前状态并重新设置(内容不变,但 spinner 帧已更新)
synchronized (stateLock) {
TuiState s = getState();
setState(new TuiState(s.inputText, s.messages, s.scrollOffset, s.thinking, s.thinkingText));
}
}
} catch (InterruptedException ignored) {}
});
spinnerThread = t;
}
/** 停止思考动画 */
private void stopSpinner() {
Thread t = spinnerThread;
if (t != null) {
t.interrupt();
spinnerThread = null;
}
}
// ==================== 消息管理 ==================== // ==================== 消息管理 ====================
/** 添加一条消息 */ /** 添加一条消息 */

@ -0,0 +1,154 @@
package com.claudecode.tui;
import io.mybatis.jink.component.Renderable;
import io.mybatis.jink.component.Text;
import io.mybatis.jink.style.Color;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 简化版 Markdown jink Text 转换器
* <p>
* 支持
* - 标题# ## ###
* - 粗体**text**
* - 行内代码`code`
* - 代码块```...```
* - 列表项- item, * item, 数字列表
* <p>
* 注意jink VirtualScreen strip ANSI所以不能用 ANSI 预渲染
* 所有样式通过 jink Text API 设置
*/
public class MarkdownToText {
private static final Pattern HEADER_PATTERN = Pattern.compile("^(#{1,3})\\s+(.+)$");
private static final Pattern BOLD_PATTERN = Pattern.compile("\\*\\*(.+?)\\*\\*");
private static final Pattern INLINE_CODE_PATTERN = Pattern.compile("`([^`]+)`");
private static final Pattern UNORDERED_LIST_PATTERN = Pattern.compile("^\\s*[-*]\\s+(.+)$");
private static final Pattern ORDERED_LIST_PATTERN = Pattern.compile("^\\s*(\\d+)\\.\\s+(.+)$");
/**
* Markdown 文本转换为 jink Text 行列表
* 每个元素代表渲染后的一行
*/
public static List<Renderable> convert(String markdown) {
List<Renderable> result = new ArrayList<>();
String[] lines = markdown.split("\n", -1);
boolean inCodeBlock = false;
String codeLanguage = "";
for (String line : lines) {
// 代码块开始/结束
if (line.trim().startsWith("```")) {
if (inCodeBlock) {
inCodeBlock = false;
codeLanguage = "";
} else {
inCodeBlock = true;
codeLanguage = line.trim().substring(3).trim();
if (!codeLanguage.isEmpty()) {
result.add(Text.of(" ┌─ " + codeLanguage).color(Color.BRIGHT_BLACK));
} else {
result.add(Text.of(" ┌─").color(Color.BRIGHT_BLACK));
}
}
continue;
}
if (inCodeBlock) {
result.add(Text.of(" │ " + line).color(Color.BRIGHT_YELLOW));
continue;
}
// 空行
if (line.isBlank()) {
result.add(Text.of(" "));
continue;
}
// 标题
Matcher headerMatcher = HEADER_PATTERN.matcher(line);
if (headerMatcher.matches()) {
int level = headerMatcher.group(1).length();
String content = headerMatcher.group(2);
String prefix = switch (level) {
case 1 -> "▌ ";
case 2 -> " ▸ ";
default -> " ▹ ";
};
Color color = switch (level) {
case 1 -> Color.BRIGHT_CYAN;
case 2 -> Color.BRIGHT_GREEN;
default -> Color.BRIGHT_YELLOW;
};
result.add(Text.of(
Text.of(prefix).color(color),
Text.of(content).color(color).bold()
));
continue;
}
// 无序列表
Matcher ulMatcher = UNORDERED_LIST_PATTERN.matcher(line);
if (ulMatcher.matches()) {
result.add(renderInline(" • " + ulMatcher.group(1)));
continue;
}
// 有序列表
Matcher olMatcher = ORDERED_LIST_PATTERN.matcher(line);
if (olMatcher.matches()) {
result.add(renderInline(" " + olMatcher.group(1) + ". " + olMatcher.group(2)));
continue;
}
// 普通文本行(处理行内格式)
result.add(renderInline(line));
}
return result;
}
/**
* 渲染行内格式粗体行内代码
*/
private static Text renderInline(String text) {
List<Object> parts = new ArrayList<>();
// 交替匹配 **bold** 和 `code`
Pattern combined = Pattern.compile("(\\*\\*(.+?)\\*\\*)|(`([^`]+)`)");
Matcher m = combined.matcher(text);
int lastEnd = 0;
while (m.find()) {
// 匹配前的普通文本
if (m.start() > lastEnd) {
parts.add(Text.of(text.substring(lastEnd, m.start())).color(Color.WHITE));
}
if (m.group(2) != null) {
// 粗体
parts.add(Text.of(m.group(2)).color(Color.WHITE).bold());
} else if (m.group(4) != null) {
// 行内代码
parts.add(Text.of(m.group(4)).color(Color.BRIGHT_YELLOW));
}
lastEnd = m.end();
}
// 剩余文本
if (lastEnd < text.length()) {
parts.add(Text.of(text.substring(lastEnd)).color(Color.WHITE));
}
if (parts.isEmpty()) {
return Text.of(text).color(Color.WHITE);
}
return Text.of(parts.toArray());
}
}
Loading…
Cancel
Save