diff --git a/src/main/java/com/claudecode/tui/ClaudeCodeComponent.java b/src/main/java/com/claudecode/tui/ClaudeCodeComponent.java index 7e2e830..978e084 100644 --- a/src/main/java/com/claudecode/tui/ClaudeCodeComponent.java +++ b/src/main/java/com/claudecode/tui/ClaudeCodeComponent.java @@ -80,8 +80,13 @@ public class ClaudeCodeComponent extends Component /** 思考动画帧 */ private static final String[] SPINNER_FRAMES = {"◐", "◓", "◑", "◒"}; + /** 终端标题动画帧(匹配官方 TITLE_ANIMATION_FRAMES) */ + private static final String[] TITLE_ANIMATION_FRAMES = {"⠂", "⠐"}; + private static final String TITLE_STATIC_PREFIX = "✳"; private volatile int spinnerFrame = 0; private volatile Thread spinnerThread; + /** 终端标题(从首条用户消息推断) */ + private volatile String sessionTitle = null; /** 权限确认回调(由权限请求设置,用户输入后调用) */ private volatile Consumer permissionCallback; @@ -137,6 +142,8 @@ public class ClaudeCodeComponent extends Component this.toolCount = toolCount; this.tokenTracker = tokenTracker; this.onExit = onExit; + // 设置初始终端标题(匹配官方 process.title = 'claude') + setTerminalTitle(TITLE_STATIC_PREFIX + " Claude Code"); } // ==================== 渲染 ==================== @@ -722,10 +729,10 @@ public class ClaudeCodeComponent extends Component // Ctrl+C: 中断 Agent 或双击退出 if (key.ctrl() && "c".equals(input)) { if (agentRunning.get()) { - // Agent 运行中 → 取消任务 + // Agent 运行中 → 仅取消任务,不启动退出窗口 + // 官方行为:中断和退出是独立流程,中断不影响 double-press-to-exit agentLoop.cancel(); addMessageInternal(new SystemMsg("^C (interrupt)", Color.BRIGHT_YELLOW), s); - lastCtrlCTime = System.currentTimeMillis(); } else { long now = System.currentTimeMillis(); if (now - lastCtrlCTime < CTRL_C_EXIT_WINDOW_MS) { @@ -1114,6 +1121,7 @@ public class ClaudeCodeComponent extends Component onFirstUserInput.accept(text); onFirstUserInput = null; // 只触发一次 } + inferSessionTitle(text); // 从首条用户消息推断终端标题 addMessage(new UserMsg(text)); setState(new TuiState("", getState().messages, 0, true, "")); runAgent(text); @@ -1158,11 +1166,19 @@ public class ClaudeCodeComponent extends Component synchronized (spinnerLock) { stopSpinnerInternal(); spinnerFrame = 0; + setTerminalTitle(computeTerminalTitle()); // 立即更新标题 Thread t = Thread.startVirtualThread(() -> { try { + int titleUpdateCounter = 0; while (!Thread.currentThread().isInterrupted()) { Thread.sleep(120); spinnerFrame++; + titleUpdateCounter++; + // 每 ~960ms 更新一次终端标题(匹配官方 TITLE_ANIMATION_INTERVAL_MS=960) + if (titleUpdateCounter >= 8) { + titleUpdateCounter = 0; + setTerminalTitle(computeTerminalTitle()); + } synchronized (stateLock) { TuiState s = getState(); setState(new TuiState(s.inputText, s.messages, s.scrollOffset, s.thinking, s.thinkingText)); @@ -1179,6 +1195,7 @@ public class ClaudeCodeComponent extends Component synchronized (spinnerLock) { stopSpinnerInternal(); } + setTerminalTitle(computeTerminalTitle()); // 恢复静态标题 } private void stopSpinnerInternal() { @@ -1480,4 +1497,50 @@ public class ClaudeCodeComponent extends Component } catch (Exception ignored) {} return null; } + + // ==================== 终端标题 ==================== + + /** + * 设置终端标题(OSC 0 escape sequence)。 + * 匹配官方 Claude Code 的 useTerminalTitle hook 行为。 + * Windows 使用 ANSI OSC 0,兼容 Windows Terminal / ConEmu 等现代终端。 + */ + private static void setTerminalTitle(String title) { + if (title == null || title.isBlank()) return; + try { + // OSC 0: Set window title and icon name + // Format: ESC ] 0 ; BEL + System.err.print("\033]0;" + title + "\007"); + System.err.flush(); + } catch (Exception ignored) {} + } + + /** + * 根据当前状态生成终端标题文本。 + * 匹配官方 AnimatedTerminalTitle 组件的行为: + * - 空闲: "✳ Claude Code" 或 "✳ <sessionTitle>" + * - 工作中: "⠂ <title>" / "⠐ <title>" (交替动画) + */ + private String computeTerminalTitle() { + String title = sessionTitle != null ? sessionTitle : "Claude Code"; + if (agentRunning.get()) { + String frame = TITLE_ANIMATION_FRAMES[spinnerFrame % TITLE_ANIMATION_FRAMES.length]; + return frame + " " + title; + } + return TITLE_STATIC_PREFIX + " " + title; + } + + /** + * 从首条用户消息推断会话标题(简化版,不调用 AI)。 + * 官方使用 Haiku 生成 3-7 词标题,这里取前 40 字符作为简化实现。 + */ + private void inferSessionTitle(String userInput) { + if (sessionTitle != null || userInput == null || userInput.isBlank()) return; + if (userInput.startsWith("/")) return; // 跳过斜杠命令 + String trimmed = userInput.strip(); + if (trimmed.length() > 40) { + trimmed = trimmed.substring(0, 40) + "…"; + } + sessionTitle = trimmed; + } } diff --git a/src/main/java/com/claudecode/tui/MarkdownToText.java b/src/main/java/com/claudecode/tui/MarkdownToText.java index 0268d3d..3becd7a 100644 --- a/src/main/java/com/claudecode/tui/MarkdownToText.java +++ b/src/main/java/com/claudecode/tui/MarkdownToText.java @@ -24,7 +24,7 @@ import java.util.regex.Pattern; */ public class MarkdownToText { - private static final Pattern HEADER_PATTERN = Pattern.compile("^(#{1,3})\\s+(.+)$"); + private static final Pattern HEADER_PATTERN = Pattern.compile("^(#{1,6})\\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+(.+)$");