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. 78
      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 spinnerLock = new Object(); // 保护 spinner 线程的启停
private final List<String> inputHistory = new ArrayList<>();
private int historyIndex = -1;
private String savedInput = "";
@ -122,29 +123,41 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
int w = getColumns();
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;
String lastLine = s.inputText;
if (askOptions != null && !askOptions.isEmpty() && permissionCallback != null) {
if (snapAskOptions != null && !snapAskOptions.isEmpty() && snapHasCallback) {
// AskUser 模式:选项数 + 提示行
inputLineCount = askOptions.size() + 1;
inputLineCount = snapAskOptions.size() + 1;
} else if (!s.inputText.isEmpty()) {
String[] inputLines = s.inputText.split("\n", -1);
inputLineCount = inputLines.length;
lastLine = inputLines[inputLines.length - 1];
}
// 光标定位
if (askOptions != null && !askOptions.isEmpty() && permissionCallback != null) {
// AskUser 模式:隐藏光标(选项列表模式不需要)
if (askInputMode) {
int askCursorRow = h - 2 - (askOptions.size() - askSelectedIndex);
setCursorPosition(askCursorRow, 7 + StringWidth.width(s.inputText));
// 光标定位(clamp 到 >= 0 防止小终端越界)
if (snapAskOptions != null && !snapAskOptions.isEmpty() && snapHasCallback) {
if (snapAskInputMode) {
int askCursorRow = h - 2 - (snapAskOptions.size() - snapAskSelected);
setCursorPosition(Math.max(0, askCursorRow), 7 + StringWidth.width(s.inputText));
} else {
setCursorPosition(h - 2 - (askOptions.size() - askSelectedIndex), 6);
int askCursorRow = h - 2 - (snapAskOptions.size() - snapAskSelected);
setCursorPosition(Math.max(0, askCursorRow), 6);
}
} else {
int cursorRow = h - 3;
int cursorRow = Math.max(0, h - 3);
int cursorCol = 1 + PROMPT_WIDTH + StringWidth.width(lastLine);
setCursorPosition(cursorRow, cursorCol);
}
@ -152,18 +165,28 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
int headerHeight = 8; // 6 content rows + 2 border lines
int bottomHeight = 4 + inputLineCount;
int messagePaddingTop = 1;
int maxMessageLines = h - headerHeight - bottomHeight - messagePaddingTop;
int maxMessageLines = Math.max(1, h - headerHeight - bottomHeight - messagePaddingTop);
return Box.of(
headerBox(w),
messagesArea(s, maxMessageLines),
Spacer.create(),
statusBar(w, h),
separator(w),
inputArea(s, w),
separator(w),
shortcutBar(w)
).flexDirection(FlexDirection.COLUMN).width(w).height(h);
// 终端高度太小时,隐藏标题框以腾出消息空间
boolean showHeader = h >= 20;
if (!showHeader) {
maxMessageLines = Math.max(1, h - bottomHeight - messagePaddingTop);
}
List<Renderable> layout = new ArrayList<>();
if (showHeader) {
layout.add(headerBox(w));
}
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 样式(双列布局) */
@ -721,7 +744,7 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
addMessage(new UserMsg(text));
// 捕获命令输出到 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,
capturedOut, () -> {
if (onExit != null) onExit.run();
@ -738,6 +761,7 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
if (!output.isEmpty()) {
addMessage(new CommandOutputMsg(output.toString()));
}
}
setState(new TuiState("", getState().messages, 0, false, ""));
return;
}
@ -788,13 +812,14 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
/** 启动思考动画 */
private void startSpinner() {
synchronized (spinnerLock) {
stopSpinnerInternal();
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));
@ -804,9 +829,16 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
});
spinnerThread = t;
}
}
/** 停止思考动画 */
private void stopSpinner() {
synchronized (spinnerLock) {
stopSpinnerInternal();
}
}
private void stopSpinnerInternal() {
Thread t = spinnerThread;
if (t != null) {
t.interrupt();

Loading…
Cancel
Save