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>
This commit is contained in:
@@ -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();
|
abandonHistoryPreview();
|
||||||
String newText = s.inputText.substring(0, s.inputText.length() - 1);
|
String newText = s.inputText.substring(0, pos - 1) + s.inputText.substring(pos);
|
||||||
|
inputCursorPos = 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.delete()) {
|
||||||
|
// Delete: 删除光标处字符
|
||||||
|
int pos = Math.min(inputCursorPos, s.inputText.length());
|
||||||
|
if (pos < s.inputText.length()) {
|
||||||
|
abandonHistoryPreview();
|
||||||
|
String newText = s.inputText.substring(0, pos) + s.inputText.substring(pos + 1);
|
||||||
|
updateCommandSuggestions(newText);
|
||||||
|
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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user