feat: 添加斜杠命令 Tab 自动补全

- 输入 / 开头时,前缀匹配所有已注册命令
- Tab 键补全唯一匹配,多个匹配时循环选择
- 输入区显示 ghost text(灰色补全后缀提示)
- 快捷键栏显示匹配命令列表(最多5个)
- 当前 Tab 选中项高亮显示
- Backspace/Esc/Submit 时同步更新建议列表

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

@ -107,6 +107,10 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
/** Thinking 开始时间(用于显示耗时) */
private volatile long thinkingStartTime = 0;
/** Tab 自动补全状态 */
private volatile List<String> commandSuggestions = List.of(); // 当前匹配的命令名列表
private volatile int tabCompletionIndex = -1; // 当前 Tab 循环索引,-1 表示未开始
/** 首次用户输入回调(用于 conversation summary) */
private Consumer<String> onFirstUserInput;
@ -520,14 +524,23 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
Text.of(" Type a message, / for commands").dimmed()
);
} else {
// 有文字 — 文字 + 块光标
// 有文字 — 文字 + 块光标 + ghost text(命令补全提示)
String indent = " ".repeat(PROMPT_WIDTH);
String displayText = s.inputText.replace("\n", "\n" + indent);
String ghost = getGhostText(s.inputText);
if (!ghost.isEmpty()) {
content = Text.of(
Text.of(displayText).color(Color.WHITE),
Text.of(ghost).dimmed(),
Text.of("█").color(Color.BRIGHT_WHITE)
);
} else {
content = Text.of(
Text.of(displayText).color(Color.WHITE),
Text.of("█").color(Color.BRIGHT_WHITE)
);
}
}
return Box.of(
Text.of(prompt, content)
@ -610,9 +623,31 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
leftText = Text.of("Press Ctrl-C again to exit").color(Color.BRIGHT_YELLOW);
} else if (agentRunning.get()) {
leftText = Text.of("esc to interrupt").dimmed();
} else {
// 检查是否在输入斜杠命令 — 显示匹配的命令列表
List<String> suggestions = commandSuggestions;
if (!suggestions.isEmpty()) {
// 在快捷键栏显示匹配命令(最多显示 5 个)
int maxShow = Math.min(suggestions.size(), 5);
List<Renderable> parts = new ArrayList<>();
parts.add(Text.of("Tab ").color(Color.BRIGHT_CYAN));
for (int i = 0; i < maxShow; i++) {
if (i > 0) parts.add(Text.of(" ").dimmed());
String cmd = "/" + suggestions.get(i);
if (i == tabCompletionIndex) {
parts.add(Text.of(cmd).color(Color.BRIGHT_CYAN).bold());
} else {
parts.add(Text.of(cmd).dimmed());
}
}
if (suggestions.size() > maxShow) {
parts.add(Text.of(" +" + (suggestions.size() - maxShow) + " more").dimmed());
}
leftText = Text.of(parts.toArray(new Renderable[0]));
} else {
leftText = Text.of("↑↓ history Esc interrupt").dimmed();
}
}
return Box.of(
leftText,
@ -705,6 +740,9 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
if (key.return_() && key.meta()) {
// Shift+Enter: 多行换行
setState(new TuiState(s.inputText + "\n", s.messages, 0, false, ""));
} else if (key.tab() && !key.shift()) {
// Tab: 命令自动补全
handleTabCompletion(s);
} else if (key.return_()) {
// Enter: 发送
if (!s.inputText.isEmpty()) {
@ -714,6 +752,7 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
if (!s.inputText.isEmpty()) {
abandonHistoryPreview();
String newText = s.inputText.substring(0, s.inputText.length() - 1);
updateCommandSuggestions(newText);
setState(new TuiState(newText, s.messages, s.scrollOffset, false, ""));
}
} else if (key.upArrow()) {
@ -736,10 +775,13 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
scroll(s, -10);
} else if (key.escape()) {
// Esc: 清空输入
updateCommandSuggestions("");
setState(new TuiState("", s.messages, s.scrollOffset, false, ""));
} else if (!input.isEmpty() && isPrintableInput(input, key)) {
abandonHistoryPreview();
setState(new TuiState(s.inputText + input, s.messages, s.scrollOffset, false, ""));
String newText = s.inputText + input;
updateCommandSuggestions(newText);
setState(new TuiState(newText, s.messages, s.scrollOffset, false, ""));
}
}
}
@ -904,11 +946,37 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
else if (key.pageDown()) scroll(s, -10);
}
/** Tab 自动补全处理 */
private void handleTabCompletion(TuiState s) {
if (!s.inputText.startsWith("/")) return;
List<String> suggestions = commandSuggestions;
if (suggestions.isEmpty()) {
// 第一次按 Tab 时可能还没计算建议
updateCommandSuggestions(s.inputText);
suggestions = commandSuggestions;
if (suggestions.isEmpty()) return;
}
if (suggestions.size() == 1) {
// 唯一匹配 → 直接补全 + 空格(准备输入参数)
String completed = "/" + suggestions.getFirst();
updateCommandSuggestions(completed);
setState(new TuiState(completed, s.messages, s.scrollOffset, false, ""));
} else {
// 多个匹配 → 循环选择
tabCompletionIndex = (tabCompletionIndex + 1) % suggestions.size();
String completed = "/" + suggestions.get(tabCompletionIndex);
setState(new TuiState(completed, s.messages, s.scrollOffset, false, ""));
}
}
/** 提交用户输入 */
private void submitInput(String text, TuiState s) {
inputHistory.add(text);
historyIndex = -1;
savedInput = "";
updateCommandSuggestions(""); // 清除命令建议
// 斜杠命令
if (commandRegistry != null && commandRegistry.isCommand(text)) {
@ -1204,6 +1272,52 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
// ==================== 工具方法 ====================
/** 计算匹配的斜杠命令(前缀匹配) */
private List<String> computeCommandSuggestions(String inputText) {
if (commandRegistry == null || !inputText.startsWith("/")) {
return List.of();
}
// 如果已经有空格,说明在输入参数,不再补全命令名
if (inputText.contains(" ")) {
return List.of();
}
String query = inputText.substring(1).toLowerCase();
if (query.isEmpty()) {
// 只输入了 "/",返回所有命令
return commandRegistry.getCommands().stream()
.map(cmd -> cmd.name())
.distinct()
.sorted()
.toList();
}
// 前缀匹配
return commandRegistry.getCommandNames().stream()
.filter(name -> name.startsWith(query))
.sorted()
.toList();
}
/** 更新命令建议列表(输入变化时调用) */
private void updateCommandSuggestions(String inputText) {
commandSuggestions = computeCommandSuggestions(inputText);
tabCompletionIndex = -1; // 重置 Tab 循环
}
/** 获取第一个建议的补全后缀(用于 ghost text 显示) */
private String getGhostText(String inputText) {
if (!inputText.startsWith("/") || inputText.contains(" ")) return "";
String query = inputText.substring(1).toLowerCase();
if (query.isEmpty()) return "";
List<String> suggestions = commandSuggestions;
if (suggestions.isEmpty()) return "";
// 返回第一个匹配项的剩余部分
String firstMatch = suggestions.getFirst();
if (firstMatch.startsWith(query) && firstMatch.length() > query.length()) {
return firstMatch.substring(query.length());
}
return "";
}
private boolean isPrintableInput(String input, Key key) {
if (key.ctrl() || key.meta()) return false;
if (key.upArrow() || key.downArrow() || key.leftArrow() || key.rightArrow()) return false;

Loading…
Cancel
Save