diff --git a/src/main/java/com/claudecode/tui/ClaudeCodeComponent.java b/src/main/java/com/claudecode/tui/ClaudeCodeComponent.java index 855c87a..7abc5f7 100644 --- a/src/main/java/com/claudecode/tui/ClaudeCodeComponent.java +++ b/src/main/java/com/claudecode/tui/ClaudeCodeComponent.java @@ -77,6 +77,11 @@ public class ClaudeCodeComponent extends Component private String savedInput = ""; 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 permissionCallback; @@ -125,7 +130,7 @@ public class ClaudeCodeComponent extends Component int cursorCol = 1 + PROMPT_WIDTH + StringWidth.width(lastLine); setCursorPosition(cursorRow, cursorCol); - int headerHeight = 7; + int headerHeight = 8; // 6 content rows + 2 border lines int bottomHeight = 4 + inputLineCount; int messagePaddingTop = 1; int maxMessageLines = h - headerHeight - bottomHeight - messagePaddingTop; @@ -142,40 +147,57 @@ public class ClaudeCodeComponent extends Component ).flexDirection(FlexDirection.COLUMN).width(w).height(h); } - /** 标题框(圆角洋红色边框) */ + /** 标题框 — 保留原始 ASCII Logo 样式(双列布局) */ private Renderable headerBox(int w) { - return Box.of( - Text.of( - Text.of("☕").color(Color.BRIGHT_YELLOW), - Text.of(" "), - Text.of("Claude Code").color(Color.BRIGHT_MAGENTA).bold(), - Text.of(" (Java)").color(Color.WHITE), - Text.of(" v" + BannerPrinter.getVersion()).dimmed() - ), - Text.of( - Text.of("▸ ").color(Color.BRIGHT_CYAN), - Text.of("API: ").dimmed(), - Text.of(baseUrl).color(Color.BRIGHT_CYAN) - ), - Text.of( - Text.of("▸ ").color(Color.BRIGHT_CYAN), - Text.of("Provider: ").dimmed(), - Text.of(provider.toUpperCase()).color(Color.BRIGHT_GREEN), - Text.of(" Model: ").dimmed(), - Text.of(model).color(Color.BRIGHT_GREEN) - ), - Text.of(" "), - Text.of( - Text.of("Tip: ").dimmed(), - Text.of("/help").color(Color.BRIGHT_CYAN).bold(), - Text.of(" for commands • ").dimmed(), - Text.of("Ctrl+D").color(Color.BRIGHT_CYAN).bold(), - Text.of(" to exit").dimmed() - ) - ).flexDirection(FlexDirection.COLUMN) + // ASCII 冒烟咖啡杯 + String[] logo = { + " ) ) ) ", + " ╭────────╮ ", + " │ ~~~~~~ │─╮ ", + " │ CLAUDE │ │ ", + " │ CODE │─╯ ", + " ╰─┬────┬─╯ " + }; + // 右侧信息 + String[] info = { + "", + "Welcome!", + "API: " + baseUrl, + "Protocol: " + provider.toUpperCase() + " Model: " + model, + "Work Dir: " + System.getProperty("user.dir", "."), + "Tools: " + toolCount + " | Commands: " + cmdCount + }; + + // 构建双列文本行 + int logoWidth = 19; + int sepWidth = 3; // " │ " + int rightWidth = Math.max(0, w - 4 - logoWidth - sepWidth - 2); // 4=border+padding + + List rows = new ArrayList<>(); + int maxRows = Math.max(logo.length, info.length); + for (int i = 0; i < maxRows; i++) { + String left = i < logo.length ? logo[i] : ""; + 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) .borderColor(Color.BRIGHT_MAGENTA) - .paddingX(1); + .paddingX(1) + .width(w); } /** 消息列表(带虚拟滚动) */ @@ -236,30 +258,35 @@ public class ClaudeCodeComponent extends Component } yield lines; } - // 将多行文本拆分为单独的行 - String[] textLines = text.split("\n", -1); - for (int i = 0; i < textLines.length; i++) { - String line = textLines[i]; - if (i == 0) { - // 首行带 ● 前缀 - String displayLine = line; - if (m.streaming() && i == textLines.length - 1) { - displayLine += "▌"; + + if (!m.streaming()) { + // 已完成的消息 — 使用 Markdown 渲染 + List 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))); } - lines.add(Text.of( - Text.of("● ").color(Color.BRIGHT_CYAN), - Text.of(displayLine).color(Color.WHITE) - )); - } else { - // 续行缩进对齐 - String displayLine = line; - if (m.streaming() && i == textLines.length - 1) { - displayLine += "▌"; + } + } else { + // 流式中 — 直接按行显示带光标 + String[] textLines = text.split("\n", -1); + for (int i = 0; i < textLines.length; i++) { + String line = textLines[i]; + String displayLine = (m.streaming() && i == textLines.length - 1) + ? line + "▌" : line; + if (i == 0) { + lines.add(Text.of( + Text.of("● ").color(Color.BRIGHT_CYAN), + Text.of(displayLine).color(Color.WHITE) + )); + } else { + lines.add(Text.of( + Text.of(" ").dimmed(), + Text.of(displayLine).color(Color.WHITE) + )); } - lines.add(Text.of( - Text.of(" ").dimmed(), - Text.of(displayLine).color(Color.WHITE) - )); } } yield lines; @@ -388,8 +415,10 @@ public class ClaudeCodeComponent extends Component ? "Y/a/n/d >" : s.inputText).color(Color.BRIGHT_YELLOW); } else if (agentRunning.get()) { - // AI 正在运行 - content = Text.of(s.thinking ? "◐ Thinking..." : "● Processing...").color(Color.BRIGHT_CYAN).dimmed(); + // AI 正在运行 — 使用旋转动画 + 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(); } else if (s.inputText.isEmpty()) { content = Text.of("Type a message, / for commands, or Ctrl+D to exit").dimmed(); @@ -417,19 +446,10 @@ public class ClaudeCodeComponent extends Component } return Box.of( - Text.of( - 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() - ), + Text.of("↑↓ history wheel scroll Ctrl+D exit").dimmed(), Spacer.create(), Text.of(tokenInfo).color(Color.BRIGHT_GREEN) - ).paddingX(1); + ).paddingX(1).height(1); } private String formatTokens(long tokens) { @@ -589,6 +609,7 @@ public class ClaudeCodeComponent extends Component /** 在后台线程运行 Agent 循环 */ private void runAgent(String userInput) { agentRunning.set(true); + startSpinner(); Thread.startVirtualThread(() -> { long startTime = System.currentTimeMillis(); @@ -609,6 +630,7 @@ public class ClaudeCodeComponent extends Component } catch (Exception e) { addMessage(new SystemMsg("Error: " + e.getMessage(), Color.BRIGHT_RED)); } finally { + stopSpinner(); agentRunning.set(false); synchronized (stateLock) { TuiState cs = getState(); @@ -618,6 +640,34 @@ public class ClaudeCodeComponent extends Component }); } + /** 启动思考动画 */ + 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; + } + } + // ==================== 消息管理 ==================== /** 添加一条消息 */ diff --git a/src/main/java/com/claudecode/tui/MarkdownToText.java b/src/main/java/com/claudecode/tui/MarkdownToText.java new file mode 100644 index 0000000..0268d3d --- /dev/null +++ b/src/main/java/com/claudecode/tui/MarkdownToText.java @@ -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 转换器。 + *

+ * 支持: + * - 标题(# ## ###) + * - 粗体(**text**) + * - 行内代码(`code`) + * - 代码块(```...```) + * - 列表项(- item, * item, 数字列表) + *

+ * 注意: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 convert(String markdown) { + List 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 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()); + } +}