fix: 修复代码审查发现的线程安全和边界问题

- render() 中对 volatile 字段取快照,避免跨线程竞争
- 光标位置 clamp 到 >= 0,防止小终端越界
- spinner 线程使用独立 spinnerLock 保护启停操作
- PrintStream 使用 try-with-resources 避免资源泄漏
- 终端高度 < 20 时隐藏标题框腾出空间

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

@ -72,6 +72,7 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
// --- 内部状态 --- // --- 内部状态 ---
private final Object stateLock = new Object(); // 保护 getState/setState 的读-改-写操作 private final Object stateLock = new Object(); // 保护 getState/setState 的读-改-写操作
private final Object spinnerLock = new Object(); // 保护 spinner 线程的启停
private final List<String> inputHistory = new ArrayList<>(); private final List<String> inputHistory = new ArrayList<>();
private int historyIndex = -1; private int historyIndex = -1;
private String savedInput = ""; private String savedInput = "";
@ -122,29 +123,41 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
int w = getColumns(); int w = getColumns();
int h = getRows(); int h = getRows();
// 快照 volatile 字段(避免 render 过程中被其他线程修改)
final List<String> snapAskOptions;
final int snapAskSelected;
final boolean snapAskInputMode;
final boolean snapHasCallback;
synchronized (stateLock) {
snapAskOptions = askOptions != null ? List.copyOf(askOptions) : null;
snapAskSelected = askSelectedIndex;
snapAskInputMode = askInputMode;
snapHasCallback = permissionCallback != null;
}
// 计算输入区行数 // 计算输入区行数
int inputLineCount = 1; int inputLineCount = 1;
String lastLine = s.inputText; String lastLine = s.inputText;
if (askOptions != null && !askOptions.isEmpty() && permissionCallback != null) { if (snapAskOptions != null && !snapAskOptions.isEmpty() && snapHasCallback) {
// AskUser 模式:选项数 + 提示行 // AskUser 模式:选项数 + 提示行
inputLineCount = askOptions.size() + 1; inputLineCount = snapAskOptions.size() + 1;
} else if (!s.inputText.isEmpty()) { } else if (!s.inputText.isEmpty()) {
String[] inputLines = s.inputText.split("\n", -1); String[] inputLines = s.inputText.split("\n", -1);
inputLineCount = inputLines.length; inputLineCount = inputLines.length;
lastLine = inputLines[inputLines.length - 1]; lastLine = inputLines[inputLines.length - 1];
} }
// 光标定位 // 光标定位(clamp 到 >= 0 防止小终端越界)
if (askOptions != null && !askOptions.isEmpty() && permissionCallback != null) { if (snapAskOptions != null && !snapAskOptions.isEmpty() && snapHasCallback) {
// AskUser 模式:隐藏光标(选项列表模式不需要) if (snapAskInputMode) {
if (askInputMode) { int askCursorRow = h - 2 - (snapAskOptions.size() - snapAskSelected);
int askCursorRow = h - 2 - (askOptions.size() - askSelectedIndex); setCursorPosition(Math.max(0, askCursorRow), 7 + StringWidth.width(s.inputText));
setCursorPosition(askCursorRow, 7 + StringWidth.width(s.inputText));
} else { } else {
setCursorPosition(h - 2 - (askOptions.size() - askSelectedIndex), 6); int askCursorRow = h - 2 - (snapAskOptions.size() - snapAskSelected);
setCursorPosition(Math.max(0, askCursorRow), 6);
} }
} else { } else {
int cursorRow = h - 3; int cursorRow = Math.max(0, h - 3);
int cursorCol = 1 + PROMPT_WIDTH + StringWidth.width(lastLine); int cursorCol = 1 + PROMPT_WIDTH + StringWidth.width(lastLine);
setCursorPosition(cursorRow, cursorCol); setCursorPosition(cursorRow, cursorCol);
} }
@ -152,18 +165,28 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
int headerHeight = 8; // 6 content rows + 2 border lines 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 = Math.max(1, h - headerHeight - bottomHeight - messagePaddingTop);
return Box.of( // 终端高度太小时,隐藏标题框以腾出消息空间
headerBox(w), boolean showHeader = h >= 20;
messagesArea(s, maxMessageLines), if (!showHeader) {
Spacer.create(), maxMessageLines = Math.max(1, h - bottomHeight - messagePaddingTop);
statusBar(w, h), }
separator(w),
inputArea(s, w), List<Renderable> layout = new ArrayList<>();
separator(w), if (showHeader) {
shortcutBar(w) layout.add(headerBox(w));
).flexDirection(FlexDirection.COLUMN).width(w).height(h); }
layout.add(messagesArea(s, maxMessageLines));
layout.add(Spacer.create());
layout.add(statusBar(w, h));
layout.add(separator(w));
layout.add(inputArea(s, w));
layout.add(separator(w));
layout.add(shortcutBar(w));
return Box.of(layout.toArray(new Renderable[0]))
.flexDirection(FlexDirection.COLUMN).width(w).height(h);
} }
/** 标题框 — 保留原始 ASCII Logo 样式(双列布局) */ /** 标题框 — 保留原始 ASCII Logo 样式(双列布局) */
@ -721,22 +744,23 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
addMessage(new UserMsg(text)); addMessage(new UserMsg(text));
// 捕获命令输出到 ByteArrayOutputStream // 捕获命令输出到 ByteArrayOutputStream
var baos = new ByteArrayOutputStream(); var baos = new ByteArrayOutputStream();
var capturedOut = new PrintStream(baos, true, StandardCharsets.UTF_8); try (var capturedOut = new PrintStream(baos, true, StandardCharsets.UTF_8)) {
CommandContext cmdCtx = new CommandContext(agentLoop, toolRegistry, commandRegistry, CommandContext cmdCtx = new CommandContext(agentLoop, toolRegistry, commandRegistry,
capturedOut, () -> { capturedOut, () -> {
if (onExit != null) onExit.run(); if (onExit != null) onExit.run();
}); });
Optional<String> result = commandRegistry.dispatch(text, cmdCtx); Optional<String> result = commandRegistry.dispatch(text, cmdCtx);
// 合并 dispatch 返回值和 capturedOut 的内容 // 合并 dispatch 返回值和 capturedOut 的内容
StringBuilder output = new StringBuilder(); StringBuilder output = new StringBuilder();
result.ifPresent(output::append); result.ifPresent(output::append);
String captured = baos.toString(StandardCharsets.UTF_8); String captured = baos.toString(StandardCharsets.UTF_8);
if (!captured.isBlank()) { if (!captured.isBlank()) {
if (!output.isEmpty()) output.append("\n"); if (!output.isEmpty()) output.append("\n");
output.append(captured); output.append(captured);
} }
if (!output.isEmpty()) { if (!output.isEmpty()) {
addMessage(new CommandOutputMsg(output.toString())); addMessage(new CommandOutputMsg(output.toString()));
}
} }
setState(new TuiState("", getState().messages, 0, false, "")); setState(new TuiState("", getState().messages, 0, false, ""));
return; return;
@ -788,25 +812,33 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
/** 启动思考动画 */ /** 启动思考动画 */
private void startSpinner() { private void startSpinner() {
spinnerFrame = 0; synchronized (spinnerLock) {
Thread t = Thread.startVirtualThread(() -> { stopSpinnerInternal();
try { spinnerFrame = 0;
while (!Thread.currentThread().isInterrupted()) { Thread t = Thread.startVirtualThread(() -> {
Thread.sleep(120); try {
spinnerFrame++; while (!Thread.currentThread().isInterrupted()) {
// 触发重绘:读取当前状态并重新设置(内容不变,但 spinner 帧已更新) Thread.sleep(120);
synchronized (stateLock) { spinnerFrame++;
TuiState s = getState(); synchronized (stateLock) {
setState(new TuiState(s.inputText, s.messages, s.scrollOffset, s.thinking, s.thinkingText)); TuiState s = getState();
setState(new TuiState(s.inputText, s.messages, s.scrollOffset, s.thinking, s.thinkingText));
}
} }
} } catch (InterruptedException ignored) {}
} catch (InterruptedException ignored) {} });
}); spinnerThread = t;
spinnerThread = t; }
} }
/** 停止思考动画 */ /** 停止思考动画 */
private void stopSpinner() { private void stopSpinner() {
synchronized (spinnerLock) {
stopSpinnerInternal();
}
}
private void stopSpinnerInternal() {
Thread t = spinnerThread; Thread t = spinnerThread;
if (t != null) { if (t != null) {
t.interrupt(); t.interrupt();

Loading…
Cancel
Save