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>
This commit is contained in:
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user