feat: 对齐官方快捷键和动画行为

Phase A (快速修复):
- Ctrl+C 双击窗口从 2000ms 改为 800ms(匹配官方)
- 添加 Ctrl+L 强制重绘
- 添加 Ctrl+Home/End 跳到顶部/底部

Phase B (动画改进):
- Thinking 显示持续时间: 'Thinking...' → 'Thinking (5s)...'
  (2秒后开始显示时间,匹配官方行为)
- Processing 显示输出 token 计数
- 底部自动滚动已正确实现(新消息 scrollOffset=0)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
pull/1/head
abel533 1 month ago
parent 99a4ccf059
commit f995a34d66
  1. 58
      src/main/java/com/claudecode/tui/ClaudeCodeComponent.java

@ -102,7 +102,10 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
/** Ctrl+C 双击退出:上次按下时间 */
private volatile long lastCtrlCTime = 0;
private static final long CTRL_C_EXIT_WINDOW_MS = 2000; // 2秒内再按一次退出
private static final long CTRL_C_EXIT_WINDOW_MS = 800; // 800ms内再按一次退出(匹配官方)
/** Thinking 开始时间(用于显示耗时) */
private volatile long thinkingStartTime = 0;
/** 首次用户输入回调(用于 conversation summary) */
private Consumer<String> onFirstUserInput;
@ -301,21 +304,28 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
// Thinking / Processing 状态动画(显示在消息区底部)
if (agentRunning.get()) {
String spinner = SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length];
if (s.thinking && !s.thinkingText.isEmpty()) {
allItems.add(Text.of(
Text.of(spinner + " ").color(Color.BRIGHT_YELLOW),
Text.of("Thinking...").color(Color.BRIGHT_YELLOW).italic()
));
} else if (s.thinking) {
if (s.thinking) {
// 计算 thinking 耗时
long elapsed = thinkingStartTime > 0
? (System.currentTimeMillis() - thinkingStartTime) / 1000
: 0;
String durationText = elapsed >= 2
? String.format("Thinking (%ds)...", elapsed)
: "Thinking...";
allItems.add(Text.of(
Text.of(spinner + " ").color(Color.BRIGHT_YELLOW),
Text.of("Thinking...").color(Color.BRIGHT_YELLOW).italic()
Text.of(durationText).color(Color.BRIGHT_YELLOW).italic()
));
} else {
// Agent 运行中但未进入 thinking(如执行工具、准备调用等)
// Agent 运行中但未进入 thinking(执行工具、流式输出等)
// 显示输出 token 计数
String tokenInfo = "";
if (tokenTracker != null && tokenTracker.getOutputTokens() > 0) {
tokenInfo = " (" + tokenTracker.getOutputTokens() + " tokens)";
}
allItems.add(Text.of(
Text.of(spinner + " ").color(Color.BRIGHT_CYAN),
Text.of("Processing...").color(Color.BRIGHT_CYAN).italic()
Text.of("Processing..." + tokenInfo).color(Color.BRIGHT_CYAN).italic()
));
}
}
@ -630,6 +640,12 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
return;
}
// Ctrl+L: 强制重绘
if (key.ctrl() && "l".equals(input)) {
setState(new TuiState(s.inputText, s.messages, s.scrollOffset, s.thinking, s.thinkingText));
return;
}
// Ctrl+C: 中断 Agent 或双击退出
if (key.ctrl() && "c".equals(input)) {
if (agentRunning.get()) {
@ -708,6 +724,12 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
scroll(s, 3);
} else if (key.scrollDown()) {
scroll(s, -3);
} else if (key.ctrl() && key.home()) {
// Ctrl+Home: 跳到顶部
scrollToTop(s);
} else if (key.ctrl() && key.end()) {
// Ctrl+End: 跳到底部
scrollToBottom(s);
} else if (key.pageUp()) {
scroll(s, 10);
} else if (key.pageDown()) {
@ -876,6 +898,8 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
private void handleScrollInput(Key key, TuiState s) {
if (key.scrollUp()) scroll(s, 3);
else if (key.scrollDown()) scroll(s, -3);
else if (key.ctrl() && key.home()) scrollToTop(s);
else if (key.ctrl() && key.end()) scrollToBottom(s);
else if (key.pageUp()) scroll(s, 10);
else if (key.pageDown()) scroll(s, -10);
}
@ -1110,6 +1134,11 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
/** 设置 thinking 状态 */
public void setThinking(boolean thinking, String text) {
if (thinking && thinkingStartTime == 0) {
thinkingStartTime = System.currentTimeMillis();
} else if (!thinking) {
thinkingStartTime = 0;
}
synchronized (stateLock) {
TuiState s = getState();
setState(new TuiState(s.inputText, s.messages, s.scrollOffset, thinking, text));
@ -1164,6 +1193,15 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
setState(new TuiState(s.inputText, s.messages, newOffset, s.thinking, s.thinkingText));
}
private void scrollToTop(TuiState s) {
int maxOffset = Math.max(0, lastRenderedItemCount - lastMaxVisibleLines);
setState(new TuiState(s.inputText, s.messages, maxOffset, s.thinking, s.thinkingText));
}
private void scrollToBottom(TuiState s) {
setState(new TuiState(s.inputText, s.messages, 0, s.thinking, s.thinkingText));
}
// ==================== 工具方法 ====================
private boolean isPrintableInput(String input, Key key) {

Loading…
Cancel
Save