diff --git a/src/main/java/com/claudecode/tool/impl/AskUserQuestionTool.java b/src/main/java/com/claudecode/tool/impl/AskUserQuestionTool.java index 2b12a40..3aa407a 100644 --- a/src/main/java/com/claudecode/tool/impl/AskUserQuestionTool.java +++ b/src/main/java/com/claudecode/tool/impl/AskUserQuestionTool.java @@ -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 options = null; + if (input.containsKey("options")) { + options = (java.util.List) 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>) 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) 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) { diff --git a/src/main/java/com/claudecode/tui/ClaudeCodeComponent.java b/src/main/java/com/claudecode/tui/ClaudeCodeComponent.java index a420699..5c94e69 100644 --- a/src/main/java/com/claudecode/tui/ClaudeCodeComponent.java +++ b/src/main/java/com/claudecode/tui/ClaudeCodeComponent.java @@ -85,6 +85,12 @@ public class ClaudeCodeComponent extends Component /** 权限确认回调(由权限请求设置,用户输入后调用) */ private volatile Consumer permissionCallback; + /** AskUser 交互模式状态 */ + private volatile List askOptions; // 可选项列表 + private volatile int askSelectedIndex = 0; // 当前选中索引 + private volatile boolean askInputMode = false; // 是否在自由输入模式(选择"其他"后) + private volatile String askQuestion; // 当前问题文本 + /** 首次用户输入回调(用于 conversation summary) */ private Consumer onFirstUserInput; @@ -119,16 +125,29 @@ public class ClaudeCodeComponent extends Component // 计算输入区行数 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 /** 输入区 */ 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 ).paddingX(1); } + /** 渲染 AskUser 选项列表 */ + private Renderable renderAskUserArea(TuiState s, int w) { + List 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 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 } } + /** 处理 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 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,7 +864,24 @@ public class ClaudeCodeComponent extends Component /** 设置权限确认回调 */ public void requestPermission(Consumer callback) { + this.askOptions = null; + this.askInputMode = false; + this.askQuestion = null; + this.permissionCallback = callback; + } + + /** 设置 AskUser 交互模式(带可选列表) */ + public void requestAskUser(String question, List options, Consumer 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 状态 */ diff --git a/src/main/java/com/claudecode/tui/JinkReplSession.java b/src/main/java/com/claudecode/tui/JinkReplSession.java index 968a88b..649a248 100644 --- a/src/main/java/com/claudecode/tui/JinkReplSession.java +++ b/src/main/java/com/claudecode/tui/JinkReplSession.java @@ -151,8 +151,12 @@ public class JinkReplSession { private void setupToolContextCallbacks() { var toolContext = agentLoop.getToolContext(); if (toolContext != null) { + // 简单文本回调(兜底) toolContext.set(AskUserQuestionTool.USER_INPUT_CALLBACK, (Function) this::askUserInTui); + // 结构化回调(支持交互式选择) + toolContext.set(AskUserQuestionTool.ASK_USER_STRUCTURED_CALLBACK, + (java.util.function.BiFunction, 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 options) { + finishCurrentStreaming(); + + // 添加问题到消息列表 + component.addMessage(new SystemMsg("🤔 " + question, Color.BRIGHT_CYAN)); + + CompletableFuture 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();