fix: markdown ####, Ctrl+C exit bug, terminal title

- MarkdownToText: support heading levels 1-6 (was 1-3, causing #### raw display)
- Ctrl+C: agent cancel no longer starts exit window (matching official double-press flow)
- Terminal title: dynamic OSC 0 title with animated spinner during work, static idle prefix
  - Matches official useTerminalTitle hook + AnimatedTerminalTitle component
  - Session title inferred from first user message (simplified vs AI generation)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
pull/1/head
abel533 1 month ago
parent 43e62fb8fc
commit 4523e7e4d3
  1. 67
      src/main/java/com/claudecode/tui/ClaudeCodeComponent.java
  2. 2
      src/main/java/com/claudecode/tui/MarkdownToText.java

@ -80,8 +80,13 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
/** 思考动画帧 */ /** 思考动画帧 */
private static final String[] SPINNER_FRAMES = {"◐", "◓", "◑", "◒"}; 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 int spinnerFrame = 0;
private volatile Thread spinnerThread; private volatile Thread spinnerThread;
/** 终端标题(从首条用户消息推断) */
private volatile String sessionTitle = null;
/** 权限确认回调(由权限请求设置,用户输入后调用) */ /** 权限确认回调(由权限请求设置,用户输入后调用) */
private volatile Consumer<String> permissionCallback; private volatile Consumer<String> permissionCallback;
@ -137,6 +142,8 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
this.toolCount = toolCount; this.toolCount = toolCount;
this.tokenTracker = tokenTracker; this.tokenTracker = tokenTracker;
this.onExit = onExit; this.onExit = onExit;
// 设置初始终端标题(匹配官方 process.title = 'claude')
setTerminalTitle(TITLE_STATIC_PREFIX + " Claude Code");
} }
// ==================== 渲染 ==================== // ==================== 渲染 ====================
@ -722,10 +729,10 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
// Ctrl+C: 中断 Agent 或双击退出 // Ctrl+C: 中断 Agent 或双击退出
if (key.ctrl() && "c".equals(input)) { if (key.ctrl() && "c".equals(input)) {
if (agentRunning.get()) { if (agentRunning.get()) {
// Agent 运行中 → 取消任务 // Agent 运行中 → 仅取消任务,不启动退出窗口
// 官方行为:中断和退出是独立流程,中断不影响 double-press-to-exit
agentLoop.cancel(); agentLoop.cancel();
addMessageInternal(new SystemMsg("^C (interrupt)", Color.BRIGHT_YELLOW), s); addMessageInternal(new SystemMsg("^C (interrupt)", Color.BRIGHT_YELLOW), s);
lastCtrlCTime = System.currentTimeMillis();
} else { } else {
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
if (now - lastCtrlCTime < CTRL_C_EXIT_WINDOW_MS) { if (now - lastCtrlCTime < CTRL_C_EXIT_WINDOW_MS) {
@ -1114,6 +1121,7 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
onFirstUserInput.accept(text); onFirstUserInput.accept(text);
onFirstUserInput = null; // 只触发一次 onFirstUserInput = null; // 只触发一次
} }
inferSessionTitle(text); // 从首条用户消息推断终端标题
addMessage(new UserMsg(text)); addMessage(new UserMsg(text));
setState(new TuiState("", getState().messages, 0, true, "")); setState(new TuiState("", getState().messages, 0, true, ""));
runAgent(text); runAgent(text);
@ -1158,11 +1166,19 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
synchronized (spinnerLock) { synchronized (spinnerLock) {
stopSpinnerInternal(); stopSpinnerInternal();
spinnerFrame = 0; spinnerFrame = 0;
setTerminalTitle(computeTerminalTitle()); // 立即更新标题
Thread t = Thread.startVirtualThread(() -> { Thread t = Thread.startVirtualThread(() -> {
try { try {
int titleUpdateCounter = 0;
while (!Thread.currentThread().isInterrupted()) { while (!Thread.currentThread().isInterrupted()) {
Thread.sleep(120); Thread.sleep(120);
spinnerFrame++; spinnerFrame++;
titleUpdateCounter++;
// 每 ~960ms 更新一次终端标题(匹配官方 TITLE_ANIMATION_INTERVAL_MS=960)
if (titleUpdateCounter >= 8) {
titleUpdateCounter = 0;
setTerminalTitle(computeTerminalTitle());
}
synchronized (stateLock) { synchronized (stateLock) {
TuiState s = getState(); TuiState s = getState();
setState(new TuiState(s.inputText, s.messages, s.scrollOffset, s.thinking, s.thinkingText)); setState(new TuiState(s.inputText, s.messages, s.scrollOffset, s.thinking, s.thinkingText));
@ -1179,6 +1195,7 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
synchronized (spinnerLock) { synchronized (spinnerLock) {
stopSpinnerInternal(); stopSpinnerInternal();
} }
setTerminalTitle(computeTerminalTitle()); // 恢复静态标题
} }
private void stopSpinnerInternal() { private void stopSpinnerInternal() {
@ -1480,4 +1497,50 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
} catch (Exception ignored) {} } catch (Exception ignored) {}
return null; 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 ; <title> 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;
}
} }

@ -24,7 +24,7 @@ import java.util.regex.Pattern;
*/ */
public class MarkdownToText { 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 BOLD_PATTERN = Pattern.compile("\\*\\*(.+?)\\*\\*");
private static final Pattern INLINE_CODE_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 UNORDERED_LIST_PATTERN = Pattern.compile("^\\s*[-*]\\s+(.+)$");

Loading…
Cancel
Save