feat: interactive AskUser with arrow-key selection

- Add interactive option selection for AskUserQuestion tool
  (arrow keys ↑↓, Enter confirm, 1-9 quick select, Esc cancel)
- Last option with '其他/Other' enters free text input mode
- New ASK_USER_STRUCTURED_CALLBACK in AskUserQuestionTool
  accepts (question, options) BiFunction for structured interaction
- ClaudeCodeComponent renders option list with ❯ indicator
- Cursor position adjusts for variable-height ask area
- Falls back to simple text prompt when no options provided

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
pull/1/head
abel533 1 month ago
parent 2f645b9d85
commit 65bd5b5d9a
  1. 36
      src/main/java/com/claudecode/tool/impl/AskUserQuestionTool.java
  2. 154
      src/main/java/com/claudecode/tui/ClaudeCodeComponent.java
  3. 30
      src/main/java/com/claudecode/tui/JinkReplSession.java

@ -24,6 +24,9 @@ public class AskUserQuestionTool implements Tool {
/** ToolContext 中用于读取用户输入的回调 Key */ /** ToolContext 中用于读取用户输入的回调 Key */
public static final String USER_INPUT_CALLBACK = "ask_user_input_callback"; public static final String USER_INPUT_CALLBACK = "ask_user_input_callback";
/** ToolContext 中用于结构化 AskUser 的回调 Key(question, options → answer) */
public static final String ASK_USER_STRUCTURED_CALLBACK = "ask_user_structured_callback";
@Override @Override
public String name() { public String name() {
return "AskUserQuestion"; return "AskUserQuestion";
@ -75,10 +78,31 @@ public class AskUserQuestionTool implements Tool {
return "Error: question parameter is required"; return "Error: question parameter is required";
} }
// 获取用户输入回调 // 解析选项
java.util.List<String> options = null;
if (input.containsKey("options")) {
options = (java.util.List<String>) input.get("options");
}
// 优先使用结构化回调(支持交互式选择)
Object structuredCb = context.get(ASK_USER_STRUCTURED_CALLBACK);
if (structuredCb instanceof java.util.function.BiFunction<?, ?, ?> biFn) {
try {
var askFn = (java.util.function.BiFunction<String, java.util.List<String>, String>) biFn;
String userResponse = askFn.apply(question, options);
if (userResponse == null || userResponse.isBlank()) {
return "(User provided no response)";
}
return "User response: " + userResponse;
} catch (Exception e) {
log.debug("Structured callback failed, falling back", e);
}
}
// 回退到简单文本回调
Object callback = context.get(USER_INPUT_CALLBACK); Object callback = context.get(USER_INPUT_CALLBACK);
if (callback == null) { if (callback == null) {
log.warn("User input callback not registered (USER_INPUT_CALLBACK), returning default response"); log.warn("User input callback not registered, returning default response");
return "Error: User input not available in current environment"; return "Error: User input not available in current environment";
} }
@ -95,27 +119,19 @@ public class AskUserQuestionTool implements Tool {
prompt.append(" ").append("─".repeat(50)).append("\n"); prompt.append(" ").append("─".repeat(50)).append("\n");
prompt.append(" ").append(question).append("\n"); prompt.append(" ").append(question).append("\n");
// 如果有选项
if (input.containsKey("options")) {
var options = (java.util.List<String>) input.get("options");
if (options != null && !options.isEmpty()) { if (options != null && !options.isEmpty()) {
prompt.append("\n Options:\n"); prompt.append("\n Options:\n");
for (int i = 0; i < options.size(); i++) { for (int i = 0; i < options.size(); i++) {
prompt.append(" ").append(i + 1).append(". ").append(options.get(i)).append("\n"); prompt.append(" ").append(i + 1).append(". ").append(options.get(i)).append("\n");
} }
} }
}
prompt.append(" ").append("─".repeat(50)).append("\n"); prompt.append(" ").append("─".repeat(50)).append("\n");
// 调用回调获取用户输入
String userResponse = askUser.apply(prompt.toString()); String userResponse = askUser.apply(prompt.toString());
if (userResponse == null || userResponse.isBlank()) { if (userResponse == null || userResponse.isBlank()) {
return "(User provided no response)"; return "(User provided no response)";
} }
log.debug("User response: {}", userResponse);
return "User response: " + userResponse; return "User response: " + userResponse;
} catch (Exception e) { } catch (Exception e) {

@ -85,6 +85,12 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
/** 权限确认回调(由权限请求设置,用户输入后调用) */ /** 权限确认回调(由权限请求设置,用户输入后调用) */
private volatile Consumer<String> permissionCallback; private volatile Consumer<String> permissionCallback;
/** AskUser 交互模式状态 */
private volatile List<String> askOptions; // 可选项列表
private volatile int askSelectedIndex = 0; // 当前选中索引
private volatile boolean askInputMode = false; // 是否在自由输入模式(选择"其他"后)
private volatile String askQuestion; // 当前问题文本
/** 首次用户输入回调(用于 conversation summary) */ /** 首次用户输入回调(用于 conversation summary) */
private Consumer<String> onFirstUserInput; private Consumer<String> onFirstUserInput;
@ -119,16 +125,29 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
// 计算输入区行数 // 计算输入区行数
int inputLineCount = 1; int inputLineCount = 1;
String lastLine = s.inputText; String lastLine = s.inputText;
if (!s.inputText.isEmpty()) { if (askOptions != null && !askOptions.isEmpty() && permissionCallback != null) {
// AskUser 模式:选项数 + 提示行
inputLineCount = askOptions.size() + 1;
} 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];
} }
// 光标定位:底部结构 shortcutBar(1) + separator(1) + input(N) + separator(1) + statusBar(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));
} else {
setCursorPosition(h - 2 - (askOptions.size() - askSelectedIndex), 6);
}
} else {
int cursorRow = h - 3; int cursorRow = h - 3;
int cursorCol = 1 + PROMPT_WIDTH + StringWidth.width(lastLine); int cursorCol = 1 + PROMPT_WIDTH + StringWidth.width(lastLine);
setCursorPosition(cursorRow, cursorCol); setCursorPosition(cursorRow, cursorCol);
}
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;
@ -401,6 +420,11 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
/** 输入区 */ /** 输入区 */
private Renderable inputArea(TuiState s, int w) { private Renderable inputArea(TuiState s, int w) {
// AskUser 交互模式 — 显示选项列表
if (permissionCallback != null && askOptions != null && !askOptions.isEmpty()) {
return renderAskUserArea(s, w);
}
Text prompt = Text.of("❯ ").color(Color.BRIGHT_GREEN).bold(); Text prompt = Text.of("❯ ").color(Color.BRIGHT_GREEN).bold();
Text content; Text content;
@ -428,6 +452,38 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
).paddingX(1); ).paddingX(1);
} }
/** 渲染 AskUser 选项列表 */
private Renderable renderAskUserArea(TuiState s, int w) {
List<Renderable> lines = new ArrayList<>();
for (int i = 0; i < askOptions.size(); i++) {
boolean selected = (i == askSelectedIndex);
String option = askOptions.get(i);
if (selected && askInputMode) {
// 自由输入模式
lines.add(Text.of(
Text.of(" ❯ " + (i + 1) + ". ").color(Color.BRIGHT_CYAN),
Text.of(s.inputText + "█").color(Color.BRIGHT_CYAN)
));
} else {
String prefix = selected ? " ❯ " : " ";
lines.add(Text.of(prefix + (i + 1) + ". " + option)
.color(selected ? Color.BRIGHT_CYAN : null));
}
}
// 提示行
String hint = askInputMode
? "Type your answer · Enter confirm · Esc back"
: "↑↓ select · Enter confirm · 1-9 quick select · Esc cancel";
lines.add(Text.of(" " + hint).dimmed());
return Box.of(lines.toArray(new Renderable[0]))
.flexDirection(FlexDirection.COLUMN)
.paddingX(1);
}
/** 快捷键栏 */ /** 快捷键栏 */
private Renderable shortcutBar(int w) { private Renderable shortcutBar(int w) {
// Token 统计 // Token 统计
@ -477,9 +533,13 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
return; return;
} }
// 权限确认模式 // 权限确认模式 / AskUser 模式
if (permissionCallback != null) { if (permissionCallback != null) {
if (askOptions != null && !askOptions.isEmpty()) {
handleAskUserInput(input, key, s);
} else {
handlePermissionInput(input, key, s); handlePermissionInput(input, key, s);
}
return; return;
} }
@ -551,6 +611,77 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
} }
} }
/** 处理 AskUser 交互输入(带选项列表的选择模式) */
private void handleAskUserInput(String input, Key key, TuiState s) {
if (askInputMode) {
// 自由输入模式(选择了"其他"之后)
if (key.return_()) {
if (!s.inputText.isEmpty()) {
confirmAskUser(s.inputText);
}
} else if (key.escape()) {
// 返回选择模式
askInputMode = false;
setState(new TuiState("", s.messages, s.scrollOffset, false, ""));
} else if (key.backspace() && !s.inputText.isEmpty()) {
setState(new TuiState(s.inputText.substring(0, s.inputText.length() - 1),
s.messages, s.scrollOffset, false, ""));
} else if (!input.isEmpty() && isPrintableInput(input, key)) {
setState(new TuiState(s.inputText + input, s.messages, s.scrollOffset, false, ""));
}
} else {
// 列表选择模式
if (key.upArrow()) {
askSelectedIndex = askSelectedIndex == 0 ? askOptions.size() - 1 : askSelectedIndex - 1;
setState(new TuiState(s.inputText, s.messages, s.scrollOffset, s.thinking, s.thinkingText));
} else if (key.downArrow()) {
askSelectedIndex = (askSelectedIndex + 1) % askOptions.size();
setState(new TuiState(s.inputText, s.messages, s.scrollOffset, s.thinking, s.thinkingText));
} else if (key.return_()) {
String selected = askOptions.get(askSelectedIndex);
// 最后一个选项如果包含"其他"或"Other",切换到输入模式
if (askSelectedIndex == askOptions.size() - 1 &&
(selected.contains("其他") || selected.toLowerCase().contains("other"))) {
askInputMode = true;
setState(new TuiState("", s.messages, s.scrollOffset, false, ""));
} else {
confirmAskUser(selected);
}
} else if (key.escape()) {
confirmAskUser("(cancelled)");
} else if (input.length() == 1 && Character.isDigit(input.charAt(0))) {
// 数字键快速选择
int idx = input.charAt(0) - '1';
if (idx >= 0 && idx < askOptions.size()) {
askSelectedIndex = idx;
String selected = askOptions.get(idx);
if (idx == askOptions.size() - 1 &&
(selected.contains("其他") || selected.toLowerCase().contains("other"))) {
askInputMode = true;
setState(new TuiState("", s.messages, s.scrollOffset, false, ""));
} else {
confirmAskUser(selected);
}
}
}
}
}
/** 确认 AskUser 选择并回调 */
private void confirmAskUser(String answer) {
Consumer<String> cb = permissionCallback;
permissionCallback = null;
askOptions = null;
askQuestion = null;
askInputMode = false;
askSelectedIndex = 0;
synchronized (stateLock) {
TuiState s = getState();
setState(new TuiState("", s.messages, 0, false, ""));
}
if (cb != null) cb.accept(answer);
}
/** 处理滚动输入 */ /** 处理滚动输入 */
private void handleScrollInput(Key key, TuiState s) { private void handleScrollInput(Key key, TuiState s) {
if (key.scrollUp()) scroll(s, 3); if (key.scrollUp()) scroll(s, 3);
@ -733,7 +864,24 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
/** 设置权限确认回调 */ /** 设置权限确认回调 */
public void requestPermission(Consumer<String> callback) { public void requestPermission(Consumer<String> callback) {
this.askOptions = null;
this.askInputMode = false;
this.askQuestion = null;
this.permissionCallback = callback;
}
/** 设置 AskUser 交互模式(带可选列表) */
public void requestAskUser(String question, List<String> options, Consumer<String> callback) {
this.askQuestion = question;
this.askOptions = options;
this.askSelectedIndex = 0;
this.askInputMode = false;
this.permissionCallback = callback; this.permissionCallback = callback;
// 触发重绘
synchronized (stateLock) {
TuiState s = getState();
setState(new TuiState("", s.messages, s.scrollOffset, s.thinking, s.thinkingText));
}
} }
/** 设置 thinking 状态 */ /** 设置 thinking 状态 */

@ -151,8 +151,12 @@ public class JinkReplSession {
private void setupToolContextCallbacks() { private void setupToolContextCallbacks() {
var toolContext = agentLoop.getToolContext(); var toolContext = agentLoop.getToolContext();
if (toolContext != null) { if (toolContext != null) {
// 简单文本回调(兜底)
toolContext.set(AskUserQuestionTool.USER_INPUT_CALLBACK, toolContext.set(AskUserQuestionTool.USER_INPUT_CALLBACK,
(Function<String, String>) this::askUserInTui); (Function<String, String>) this::askUserInTui);
// 结构化回调(支持交互式选择)
toolContext.set(AskUserQuestionTool.ASK_USER_STRUCTURED_CALLBACK,
(java.util.function.BiFunction<String, java.util.List<String>, String>) this::askUserStructured);
} }
} }
@ -213,7 +217,7 @@ public class JinkReplSession {
} }
} }
/** 在 TUI 中请求用户输入(AskUser 工具) */ /** 在 TUI 中请求用户输入(AskUser 工具 — 简单文本模式) */
private String askUserInTui(String prompt) { private String askUserInTui(String prompt) {
finishCurrentStreaming(); finishCurrentStreaming();
component.addMessage(new SystemMsg(prompt, Color.BRIGHT_CYAN)); component.addMessage(new SystemMsg(prompt, Color.BRIGHT_CYAN));
@ -228,6 +232,30 @@ public class JinkReplSession {
} }
} }
/** 在 TUI 中请求用户输入(结构化模式 — 支持交互式选择) */
private String askUserStructured(String question, java.util.List<String> options) {
finishCurrentStreaming();
// 添加问题到消息列表
component.addMessage(new SystemMsg("🤔 " + question, Color.BRIGHT_CYAN));
CompletableFuture<String> future = new CompletableFuture<>();
if (options != null && !options.isEmpty()) {
// 有选项 — 使用交互式选择
component.requestAskUser(question, options, future::complete);
} else {
// 无选项 — 使用普通输入
component.requestPermission(future::complete);
}
try {
return future.get();
} catch (Exception e) {
return "(User cancelled)";
}
}
/** 完成当前流式消息(如果存在) */ /** 完成当前流式消息(如果存在) */
private void finishCurrentStreaming() { private void finishCurrentStreaming() {
component.finishStreamingMessage(); component.finishStreamingMessage();

Loading…
Cancel
Save