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>
This commit is contained in:
@@ -24,6 +24,9 @@ public class AskUserQuestionTool implements Tool {
|
||||
/** ToolContext 中用于读取用户输入的回调 Key */
|
||||
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
|
||||
public String name() {
|
||||
return "AskUserQuestion";
|
||||
@@ -75,10 +78,31 @@ public class AskUserQuestionTool implements Tool {
|
||||
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);
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -95,27 +119,19 @@ public class AskUserQuestionTool implements Tool {
|
||||
prompt.append(" ").append("─".repeat(50)).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()) {
|
||||
prompt.append("\n Options:\n");
|
||||
for (int i = 0; i < options.size(); i++) {
|
||||
prompt.append(" ").append(i + 1).append(". ").append(options.get(i)).append("\n");
|
||||
}
|
||||
if (options != null && !options.isEmpty()) {
|
||||
prompt.append("\n Options:\n");
|
||||
for (int i = 0; i < options.size(); i++) {
|
||||
prompt.append(" ").append(i + 1).append(". ").append(options.get(i)).append("\n");
|
||||
}
|
||||
}
|
||||
|
||||
prompt.append(" ").append("─".repeat(50)).append("\n");
|
||||
|
||||
// 调用回调获取用户输入
|
||||
String userResponse = askUser.apply(prompt.toString());
|
||||
|
||||
if (userResponse == null || userResponse.isBlank()) {
|
||||
return "(User provided no response)";
|
||||
}
|
||||
|
||||
log.debug("User response: {}", userResponse);
|
||||
return "User response: " + userResponse;
|
||||
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -85,6 +85,12 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
|
||||
/** 权限确认回调(由权限请求设置,用户输入后调用) */
|
||||
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) */
|
||||
private Consumer<String> onFirstUserInput;
|
||||
|
||||
@@ -119,16 +125,29 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
|
||||
// 计算输入区行数
|
||||
int inputLineCount = 1;
|
||||
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);
|
||||
inputLineCount = inputLines.length;
|
||||
lastLine = inputLines[inputLines.length - 1];
|
||||
}
|
||||
|
||||
// 光标定位:底部结构 shortcutBar(1) + separator(1) + input(N) + separator(1) + statusBar(1)
|
||||
int cursorRow = h - 3;
|
||||
int cursorCol = 1 + PROMPT_WIDTH + StringWidth.width(lastLine);
|
||||
setCursorPosition(cursorRow, cursorCol);
|
||||
// 光标定位
|
||||
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 cursorCol = 1 + PROMPT_WIDTH + StringWidth.width(lastLine);
|
||||
setCursorPosition(cursorRow, cursorCol);
|
||||
}
|
||||
|
||||
int headerHeight = 8; // 6 content rows + 2 border lines
|
||||
int bottomHeight = 4 + inputLineCount;
|
||||
@@ -401,6 +420,11 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
|
||||
|
||||
/** 输入区 */
|
||||
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 content;
|
||||
|
||||
@@ -428,6 +452,38 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
|
||||
).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) {
|
||||
// Token 统计
|
||||
@@ -477,9 +533,13 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
|
||||
return;
|
||||
}
|
||||
|
||||
// 权限确认模式
|
||||
// 权限确认模式 / AskUser 模式
|
||||
if (permissionCallback != null) {
|
||||
handlePermissionInput(input, key, s);
|
||||
if (askOptions != null && !askOptions.isEmpty()) {
|
||||
handleAskUserInput(input, key, s);
|
||||
} else {
|
||||
handlePermissionInput(input, key, s);
|
||||
}
|
||||
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) {
|
||||
if (key.scrollUp()) scroll(s, 3);
|
||||
@@ -733,9 +864,26 @@ public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState>
|
||||
|
||||
/** 设置权限确认回调 */
|
||||
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;
|
||||
// 触发重绘
|
||||
synchronized (stateLock) {
|
||||
TuiState s = getState();
|
||||
setState(new TuiState("", s.messages, s.scrollOffset, s.thinking, s.thinkingText));
|
||||
}
|
||||
}
|
||||
|
||||
/** 设置 thinking 状态 */
|
||||
public void setThinking(boolean thinking, String text) {
|
||||
synchronized (stateLock) {
|
||||
|
||||
@@ -151,8 +151,12 @@ public class JinkReplSession {
|
||||
private void setupToolContextCallbacks() {
|
||||
var toolContext = agentLoop.getToolContext();
|
||||
if (toolContext != null) {
|
||||
// 简单文本回调(兜底)
|
||||
toolContext.set(AskUserQuestionTool.USER_INPUT_CALLBACK,
|
||||
(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) {
|
||||
finishCurrentStreaming();
|
||||
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() {
|
||||
component.finishStreamingMessage();
|
||||
|
||||
Reference in New Issue
Block a user