feat: cursor movement in input field

- Left/Right arrow keys move cursor within text
- Home/End jump to line start/end
- Insert text at cursor position (not just at end)
- Backspace/Delete work at cursor position
- Paste inserts at cursor position
- History browsing sets cursor to end
- Visual cursor: inverse style on character under cursor
- Hide terminal hardware cursor in normal input (use rendered cursor)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
pull/1/head
abel533 1 month ago
parent 8946ca93f2
commit 3b47c5e818
  1. 128
      src/main/java/com/claudecode/tui/ClaudeCodeComponent.java

@ -110,6 +110,9 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
private volatile int lastRenderedItemCount = 0; private volatile int lastRenderedItemCount = 0;
private volatile int lastMaxVisibleLines = 20; private volatile int lastMaxVisibleLines = 20;
/** 输入光标位置(0 = 最左端, inputText.length() = 最右端/末尾) */
private volatile int inputCursorPos = 0;
/** Ctrl+C 双击退出:上次按下时间 */ /** Ctrl+C 双击退出:上次按下时间 */
private volatile long lastCtrlCTime = 0; private volatile long lastCtrlCTime = 0;
private static final long CTRL_C_EXIT_WINDOW_MS = 800; // 800ms内再按一次退出(匹配官方) private static final long CTRL_C_EXIT_WINDOW_MS = 800; // 800ms内再按一次退出(匹配官方)
@ -181,17 +184,24 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
// 计算输入区行数 // 计算输入区行数
int inputLineCount = 1; int inputLineCount = 1;
String lastLine = s.inputText; // 计算光标所在行的光标前文本(用于终端光标定位)
int cursorPos = Math.min(inputCursorPos, s.inputText.length());
String textBeforeCursor = s.inputText.substring(0, cursorPos);
String cursorLine; // 光标所在行中,光标之前的文本
if (snapAskOptions != null && !snapAskOptions.isEmpty() && snapHasCallback) { if (snapAskOptions != null && !snapAskOptions.isEmpty() && snapHasCallback) {
// AskUser 模式:选项数 + 提示行
inputLineCount = snapAskOptions.size() + 1; inputLineCount = snapAskOptions.size() + 1;
cursorLine = s.inputText;
} else if (snapPermOptions != null && !snapPermOptions.isEmpty() && snapHasCallback) { } else if (snapPermOptions != null && !snapPermOptions.isEmpty() && snapHasCallback) {
// 权限选择模式:标题行 + 选项数 + 提示行
inputLineCount = snapPermOptions.size() + 2; inputLineCount = snapPermOptions.size() + 2;
cursorLine = s.inputText;
} 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]; // 光标所在行:从光标前文本中提取最后一行
int lastNewline = textBeforeCursor.lastIndexOf('\n');
cursorLine = (lastNewline >= 0) ? textBeforeCursor.substring(lastNewline + 1) : textBeforeCursor;
} else {
cursorLine = "";
} }
// 光标定位:仅在需要文本输入时显示光标,选择模式和 agent 运行时隐藏 // 光标定位:仅在需要文本输入时显示光标,选择模式和 agent 运行时隐藏
@ -216,10 +226,9 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
int cursorCol = 1 + 20 + StringWidth.width(historySearchQuery); int cursorCol = 1 + 20 + StringWidth.width(historySearchQuery);
setCursorPosition(cursorRow, cursorCol); setCursorPosition(cursorRow, cursorCol);
} else { } else {
// 正常输入模式:光标在输入文本末尾 // 正常输入模式:光标在 inputCursorPos 位置
int cursorRow = Math.max(0, h - 3); // 隐藏终端硬件光标(用渲染的 inverse 字符作为可见光标)
int cursorCol = 1 + PROMPT_WIDTH + StringWidth.width(lastLine); setCursorPosition(-1, -1);
setCursorPosition(cursorRow, cursorCol);
} }
int bottomHeight = 4 + inputLineCount; int bottomHeight = 4 + inputLineCount;
@ -564,29 +573,43 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
Text content; Text content;
if (agentRunning.get()) { if (agentRunning.get()) {
// AI 运行中 — 输入区只显示提示符 + 块光标
content = Text.of("█").color(Color.BRIGHT_WHITE); content = Text.of("█").color(Color.BRIGHT_WHITE);
} else if (s.inputText.isEmpty()) { } else if (s.inputText.isEmpty()) {
// 空输入 — 块光标 + 占位提示
content = Text.of( content = Text.of(
Text.of("█").color(Color.BRIGHT_WHITE), Text.of(" ").inverse(),
Text.of(" Type a message, / for commands").dimmed() Text.of(" Type a message, / for commands").dimmed()
); );
} else { } else {
// 有文字 — 文字 + 块光标 + ghost text(命令补全提示) // 光标位置(clamp 防止越界)
int pos = Math.min(inputCursorPos, s.inputText.length());
String indent = " ".repeat(PROMPT_WIDTH); String indent = " ".repeat(PROMPT_WIDTH);
if (pos >= s.inputText.length()) {
// 光标在末尾 — 文字 + ghost text + 块光标
String displayText = s.inputText.replace("\n", "\n" + indent); String displayText = s.inputText.replace("\n", "\n" + indent);
String ghost = getGhostText(s.inputText); String ghost = getGhostText(s.inputText);
if (!ghost.isEmpty()) { if (!ghost.isEmpty()) {
content = Text.of( content = Text.of(
Text.of(displayText).color(Color.WHITE), Text.of(displayText).color(Color.WHITE),
Text.of(ghost).dimmed(), Text.of(ghost).dimmed(),
Text.of("█").color(Color.BRIGHT_WHITE) Text.of(" ").inverse()
); );
} else { } else {
content = Text.of( content = Text.of(
Text.of(displayText).color(Color.WHITE), Text.of(displayText).color(Color.WHITE),
Text.of("█").color(Color.BRIGHT_WHITE) Text.of(" ").inverse()
);
}
} else {
// 光标在中间 — [before] + [char under cursor inverted] + [after]
String before = s.inputText.substring(0, pos);
String cursorChar = String.valueOf(s.inputText.charAt(pos));
String after = s.inputText.substring(pos + 1);
String displayBefore = before.replace("\n", "\n" + indent);
String displayAfter = after.replace("\n", "\n" + indent);
content = Text.of(
Text.of(displayBefore).color(Color.WHITE),
Text.of(cursorChar).inverse(),
Text.of(displayAfter).color(Color.WHITE)
); );
} }
} }
@ -747,6 +770,7 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
} else { } else {
// 第一次 Ctrl+C → 清空输入 + 提示再按一次退出 // 第一次 Ctrl+C → 清空输入 + 提示再按一次退出
lastCtrlCTime = now; lastCtrlCTime = now;
inputCursorPos = 0;
setState(new TuiState("", s.messages, s.scrollOffset, false, "")); setState(new TuiState("", s.messages, s.scrollOffset, false, ""));
// 启动定时器,超时后清除提示 // 启动定时器,超时后清除提示
Thread.startVirtualThread(() -> { Thread.startVirtualThread(() -> {
@ -805,23 +829,67 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
} }
if (key.return_() && key.meta()) { if (key.return_() && key.meta()) {
// Shift+Enter: 多行换行 // Shift+Enter: 在光标位置插入换行
setState(new TuiState(s.inputText + "\n", s.messages, 0, false, "")); int pos = Math.min(inputCursorPos, s.inputText.length());
String newText = s.inputText.substring(0, pos) + "\n" + s.inputText.substring(pos);
inputCursorPos = pos + 1;
setState(new TuiState(newText, s.messages, 0, false, ""));
} else if (key.tab() && !key.shift()) { } else if (key.tab() && !key.shift()) {
// Tab: 命令自动补全 // Tab: 命令自动补全
handleTabCompletion(s); handleTabCompletion(s);
} else if (key.return_()) { } else if (key.return_()) {
// Enter: 发送 // Enter: 发送
if (!s.inputText.isEmpty()) { if (!s.inputText.isEmpty()) {
inputCursorPos = 0;
submitInput(s.inputText, s); submitInput(s.inputText, s);
} }
} else if (key.backspace()) { } else if (key.backspace()) {
if (!s.inputText.isEmpty()) { int pos = Math.min(inputCursorPos, s.inputText.length());
if (pos > 0) {
abandonHistoryPreview();
String newText = s.inputText.substring(0, pos - 1) + s.inputText.substring(pos);
inputCursorPos = pos - 1;
updateCommandSuggestions(newText);
setState(new TuiState(newText, s.messages, s.scrollOffset, false, ""));
}
} else if (key.delete()) {
// Delete: 删除光标处字符
int pos = Math.min(inputCursorPos, s.inputText.length());
if (pos < s.inputText.length()) {
abandonHistoryPreview(); abandonHistoryPreview();
String newText = s.inputText.substring(0, s.inputText.length() - 1); String newText = s.inputText.substring(0, pos) + s.inputText.substring(pos + 1);
updateCommandSuggestions(newText); updateCommandSuggestions(newText);
setState(new TuiState(newText, s.messages, s.scrollOffset, false, "")); setState(new TuiState(newText, s.messages, s.scrollOffset, false, ""));
} }
} else if (key.leftArrow()) {
// ← 左移光标
if (inputCursorPos > 0) {
inputCursorPos--;
setState(new TuiState(s.inputText, s.messages, s.scrollOffset, s.thinking, s.thinkingText));
}
} else if (key.rightArrow()) {
// → 右移光标
if (inputCursorPos < s.inputText.length()) {
inputCursorPos++;
setState(new TuiState(s.inputText, s.messages, s.scrollOffset, s.thinking, s.thinkingText));
}
} else if (key.home() && !key.ctrl()) {
// Home: 光标移到行首
int pos = Math.min(inputCursorPos, s.inputText.length());
int lineStart = s.inputText.lastIndexOf('\n', pos - 1) + 1;
if (inputCursorPos != lineStart) {
inputCursorPos = lineStart;
setState(new TuiState(s.inputText, s.messages, s.scrollOffset, s.thinking, s.thinkingText));
}
} else if (key.end() && !key.ctrl()) {
// End: 光标移到行尾
int pos = Math.min(inputCursorPos, s.inputText.length());
int lineEnd = s.inputText.indexOf('\n', pos);
if (lineEnd < 0) lineEnd = s.inputText.length();
if (inputCursorPos != lineEnd) {
inputCursorPos = lineEnd;
setState(new TuiState(s.inputText, s.messages, s.scrollOffset, s.thinking, s.thinkingText));
}
} else if (key.upArrow()) { } else if (key.upArrow()) {
browseHistoryUp(s); browseHistoryUp(s);
} else if (key.downArrow()) { } else if (key.downArrow()) {
@ -842,11 +910,15 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
scroll(s, -10); scroll(s, -10);
} else if (key.escape()) { } else if (key.escape()) {
// Esc: 清空输入 // Esc: 清空输入
inputCursorPos = 0;
updateCommandSuggestions(""); updateCommandSuggestions("");
setState(new TuiState("", s.messages, s.scrollOffset, false, "")); setState(new TuiState("", s.messages, s.scrollOffset, false, ""));
} else if (!input.isEmpty() && isPrintableInput(input, key)) { } else if (!input.isEmpty() && isPrintableInput(input, key)) {
// 在光标位置插入文本
abandonHistoryPreview(); abandonHistoryPreview();
String newText = s.inputText + input; int pos = Math.min(inputCursorPos, s.inputText.length());
String newText = s.inputText.substring(0, pos) + input + s.inputText.substring(pos);
inputCursorPos = pos + input.length();
updateCommandSuggestions(newText); updateCommandSuggestions(newText);
setState(new TuiState(newText, s.messages, s.scrollOffset, false, "")); setState(new TuiState(newText, s.messages, s.scrollOffset, false, ""));
} }
@ -859,7 +931,10 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
if (agentRunning.get() || text == null || text.isEmpty()) return; if (agentRunning.get() || text == null || text.isEmpty()) return;
TuiState s = getState(); TuiState s = getState();
abandonHistoryPreview(); abandonHistoryPreview();
setState(new TuiState(s.inputText + text, s.messages, s.scrollOffset, false, "")); int pos = Math.min(inputCursorPos, s.inputText.length());
String newText = s.inputText.substring(0, pos) + text + s.inputText.substring(pos);
inputCursorPos = pos + text.length();
setState(new TuiState(newText, s.messages, s.scrollOffset, false, ""));
} }
} }
@ -1076,14 +1151,14 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
} }
if (suggestions.size() == 1) { if (suggestions.size() == 1) {
// 唯一匹配 → 直接补全 + 空格(准备输入参数)
String completed = "/" + suggestions.getFirst(); String completed = "/" + suggestions.getFirst();
inputCursorPos = completed.length();
updateCommandSuggestions(completed); updateCommandSuggestions(completed);
setState(new TuiState(completed, s.messages, s.scrollOffset, false, "")); setState(new TuiState(completed, s.messages, s.scrollOffset, false, ""));
} else { } else {
// 多个匹配 → 循环选择
tabCompletionIndex = (tabCompletionIndex + 1) % suggestions.size(); tabCompletionIndex = (tabCompletionIndex + 1) % suggestions.size();
String completed = "/" + suggestions.get(tabCompletionIndex); String completed = "/" + suggestions.get(tabCompletionIndex);
inputCursorPos = completed.length();
setState(new TuiState(completed, s.messages, s.scrollOffset, false, "")); setState(new TuiState(completed, s.messages, s.scrollOffset, false, ""));
} }
} }
@ -1383,7 +1458,9 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
} else if (historyIndex > 0) { } else if (historyIndex > 0) {
historyIndex--; historyIndex--;
} }
setState(new TuiState(inputHistory.get(historyIndex), s.messages, s.scrollOffset, false, "")); String historyText = inputHistory.get(historyIndex);
inputCursorPos = historyText.length();
setState(new TuiState(historyText, s.messages, s.scrollOffset, false, ""));
} }
private void browseHistoryDown(TuiState s) { private void browseHistoryDown(TuiState s) {
@ -1391,11 +1468,14 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
historyIndex++; historyIndex++;
if (historyIndex >= inputHistory.size()) { if (historyIndex >= inputHistory.size()) {
historyIndex = -1; historyIndex = -1;
inputCursorPos = savedInput.length();
setState(new TuiState(savedInput, s.messages, s.scrollOffset, false, "")); setState(new TuiState(savedInput, s.messages, s.scrollOffset, false, ""));
savedInput = ""; savedInput = "";
return; return;
} }
setState(new TuiState(inputHistory.get(historyIndex), s.messages, s.scrollOffset, false, "")); String historyText = inputHistory.get(historyIndex);
inputCursorPos = historyText.length();
setState(new TuiState(historyText, s.messages, s.scrollOffset, false, ""));
} }
private void abandonHistoryPreview() { private void abandonHistoryPreview() {

Loading…
Cancel
Save