diff --git a/src/main/java/com/claudecode/tui/ClaudeCodeComponent.java b/src/main/java/com/claudecode/tui/ClaudeCodeComponent.java index e10ebc8..05290b4 100644 --- a/src/main/java/com/claudecode/tui/ClaudeCodeComponent.java +++ b/src/main/java/com/claudecode/tui/ClaudeCodeComponent.java @@ -92,6 +92,10 @@ public class ClaudeCodeComponent extends Component private volatile boolean askInputMode = false; // 是否在自由输入模式(选择"其他"后) private volatile String askQuestion; // 当前问题文本 + /** 权限确认交互模式状态 */ + private volatile List permissionOptions; // 权限选项列表 + private volatile int permissionSelectedIndex = 0; // 当前选中索引 + /** 最近一次渲染的总行数(用于滚动限制) */ private volatile int lastRenderedItemCount = 0; private volatile int lastMaxVisibleLines = 20; @@ -135,11 +139,15 @@ public class ClaudeCodeComponent extends Component final int snapAskSelected; final boolean snapAskInputMode; final boolean snapHasCallback; + final List snapPermOptions; + final int snapPermSelected; synchronized (stateLock) { snapAskOptions = askOptions != null ? List.copyOf(askOptions) : null; snapAskSelected = askSelectedIndex; snapAskInputMode = askInputMode; snapHasCallback = permissionCallback != null; + snapPermOptions = permissionOptions != null ? List.copyOf(permissionOptions) : null; + snapPermSelected = permissionSelectedIndex; } // 计算输入区行数 @@ -148,6 +156,9 @@ public class ClaudeCodeComponent extends Component if (snapAskOptions != null && !snapAskOptions.isEmpty() && snapHasCallback) { // AskUser 模式:选项数 + 提示行 inputLineCount = snapAskOptions.size() + 1; + } else if (snapPermOptions != null && !snapPermOptions.isEmpty() && snapHasCallback) { + // 权限选择模式:标题行 + 选项数 + 提示行 + inputLineCount = snapPermOptions.size() + 2; } else if (!s.inputText.isEmpty()) { String[] inputLines = s.inputText.split("\n", -1); inputLineCount = inputLines.length; @@ -163,7 +174,12 @@ public class ClaudeCodeComponent extends Component int askCursorRow = h - 2 - (snapAskOptions.size() - snapAskSelected); setCursorPosition(Math.max(0, askCursorRow), 6); } + } else if (snapPermOptions != null && !snapPermOptions.isEmpty() && snapHasCallback) { + // 权限选择模式:光标在选中选项的 ❯ 位置 + int permCursorRow = h - 2 - (snapPermOptions.size() - snapPermSelected); + setCursorPosition(Math.max(0, permCursorRow), 3); } else { + // 正常模式:光标隐藏在块光标 █ 的位置 int cursorRow = Math.max(0, h - 3); int cursorCol = 1 + PROMPT_WIDTH + StringWidth.width(lastLine); setCursorPosition(cursorRow, cursorCol); @@ -282,12 +298,26 @@ public class ClaudeCodeComponent extends Component allItems.addAll(renderMessage(msg)); } - // Thinking 状态 - if (s.thinking && !s.thinkingText.isEmpty()) { - allItems.add(Text.of( - Text.of("◐ ").color(Color.BRIGHT_MAGENTA), - Text.of("Thinking...").color(Color.BRIGHT_MAGENTA).italic() - )); + // Thinking / Processing 状态动画(显示在消息区底部) + if (agentRunning.get()) { + String spinner = SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length]; + if (s.thinking && !s.thinkingText.isEmpty()) { + allItems.add(Text.of( + Text.of(spinner + " ").color(Color.BRIGHT_YELLOW), + Text.of("Thinking...").color(Color.BRIGHT_YELLOW).italic() + )); + } else if (s.thinking) { + allItems.add(Text.of( + Text.of(spinner + " ").color(Color.BRIGHT_YELLOW), + Text.of("Thinking...").color(Color.BRIGHT_YELLOW).italic() + )); + } else { + // Agent 运行中但未进入 thinking(如执行工具、准备调用等) + allItems.add(Text.of( + Text.of(spinner + " ").color(Color.BRIGHT_CYAN), + Text.of("Processing...").color(Color.BRIGHT_CYAN).italic() + )); + } } // 记录总行数和可见行数(供 scroll() 使用) @@ -398,31 +428,23 @@ public class ClaudeCodeComponent extends Component case PermissionMsg m -> { List lines = new ArrayList<>(); + // 蓝色分隔线 + lines.add(Text.of(" ─────────────────────────────────────────").color(Color.BRIGHT_BLUE)); lines.add(Text.of( - m.dangerous() - ? Text.of("⚠ DANGEROUS Operation").color(Color.BRIGHT_RED).bold() - : Text.of("⚠ Permission Required").color(Color.BRIGHT_YELLOW).bold() + Text.of(" Tool use").color(Color.BRIGHT_RED).bold() )); + // 工具名 + 参数 + String argSummary = extractToolSummary(m.toolName(), m.args()); lines.add(Text.of( - Text.of(" Tool: ").bold(), - Text.of(m.toolName()).color(Color.BRIGHT_CYAN) + Text.of(" "), + Text.of(m.toolName()).color(Color.WHITE).bold(), + argSummary != null ? Text.of("(" + argSummary + ")").dimmed() : Text.of("") )); + // 动作描述 lines.add(Text.of( - Text.of(" Action: "), - Text.of(m.action()).color(Color.WHITE) + Text.of(" "), + Text.of(m.action()).dimmed() )); - if (!m.answered()) { - lines.add(Text.of( - Text.of(" [Y]").color(Color.BRIGHT_GREEN), - Text.of(" Allow "), - Text.of("[A]").color(Color.BRIGHT_GREEN), - Text.of(" Always "), - Text.of("[N]").color(Color.BRIGHT_RED), - Text.of(" Deny "), - Text.of("[D]").color(Color.BRIGHT_RED), - Text.of(" Always deny") - )); - } yield lines; } @@ -470,26 +492,31 @@ public class ClaudeCodeComponent extends Component return renderAskUserArea(s, w); } + // 权限确认模式 — 也使用选项列表 + if (permissionCallback != null && permissionOptions != null && !permissionOptions.isEmpty()) { + return renderPermissionSelectArea(s, w); + } + Text prompt = Text.of("❯ ").color(Color.BRIGHT_GREEN).bold(); Text content; - if (permissionCallback != null) { - // 权限确认模式 - content = Text.of(s.inputText.isEmpty() - ? "Y/a/n/d >" - : s.inputText).color(Color.BRIGHT_YELLOW); - } else if (agentRunning.get()) { - // AI 正在运行 — 使用旋转动画 - String spinner = SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length]; - String label = s.thinking ? " Thinking..." : " Processing..."; - content = Text.of(spinner + label).color(Color.BRIGHT_CYAN).dimmed(); - prompt = Text.of(" ").dimmed(); + if (agentRunning.get()) { + // AI 运行中 — 输入区只显示提示符 + 块光标 + content = Text.of("█").color(Color.BRIGHT_WHITE); } else if (s.inputText.isEmpty()) { - content = Text.of("Type a message, / for commands, or Ctrl+D to exit").dimmed(); + // 空输入 — 块光标 + 占位提示 + content = Text.of( + Text.of("█").color(Color.BRIGHT_WHITE), + Text.of(" Type a message, / for commands").dimmed() + ); } else { + // 有文字 — 文字 + 块光标 String indent = " ".repeat(PROMPT_WIDTH); String displayText = s.inputText.replace("\n", "\n" + indent); - content = Text.of(displayText).color(Color.WHITE); + content = Text.of( + Text.of(displayText).color(Color.WHITE), + Text.of("█").color(Color.BRIGHT_WHITE) + ); } return Box.of( @@ -529,6 +556,31 @@ public class ClaudeCodeComponent extends Component .paddingX(1); } + /** 渲染权限确认选项列表 */ + private Renderable renderPermissionSelectArea(TuiState s, int w) { + List lines = new ArrayList<>(); + + // "Do you want to proceed?" 提示 + lines.add(Text.of("Do you want to proceed?").bold()); + + for (int i = 0; i < permissionOptions.size(); i++) { + boolean selected = (i == permissionSelectedIndex); + String prefix = selected ? "❯ " : " "; + lines.add(Text.of( + Text.of(prefix).color(selected ? Color.BRIGHT_CYAN : null), + Text.of((i + 1) + ". ").color(selected ? Color.BRIGHT_CYAN : null), + Text.of(permissionOptions.get(i)).color(selected ? Color.BRIGHT_CYAN : null) + )); + } + + // 提示行 + lines.add(Text.of("Esc to cancel · Tab to amend").dimmed()); + + return Box.of(lines.toArray(new Renderable[0])) + .flexDirection(FlexDirection.COLUMN) + .paddingX(1); + } + /** 快捷键栏 */ private Renderable shortcutBar(int w) { // Token 统计 @@ -541,11 +593,16 @@ public class ClaudeCodeComponent extends Component } } - // Ctrl+C 双击退出提示 + // 根据当前模式显示不同的快捷键提示 boolean ctrlCPending = (System.currentTimeMillis() - lastCtrlCTime) < CTRL_C_EXIT_WINDOW_MS; - Renderable leftText = ctrlCPending - ? Text.of("Press Ctrl-C again to exit").color(Color.BRIGHT_YELLOW) - : Text.of("↑↓ history Esc interrupt").dimmed(); + Renderable leftText; + if (ctrlCPending) { + 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 { + leftText = Text.of("↑↓ history Esc interrupt").dimmed(); + } return Box.of( leftText, @@ -605,12 +662,14 @@ public class ClaudeCodeComponent extends Component return; } - // 权限确认模式 / AskUser 模式 + // 权限确认模式 / AskUser 模式 / 简单文本输入 if (permissionCallback != null) { if (askOptions != null && !askOptions.isEmpty()) { handleAskUserInput(input, key, s); - } else { + } else if (permissionOptions != null && !permissionOptions.isEmpty()) { handlePermissionInput(input, key, s); + } else { + handleTextInput(input, key, s); } return; } @@ -673,16 +732,23 @@ public class ClaudeCodeComponent extends Component } } - /** 处理权限确认输入 */ - private void handlePermissionInput(String input, Key key, TuiState s) { + /** 处理简单文本输入(无选项的 AskUser) */ + private void handleTextInput(String input, Key key, TuiState s) { if (key.return_()) { - String answer = s.inputText.isEmpty() ? "y" : s.inputText; + String answer = s.inputText; Consumer cb = permissionCallback; permissionCallback = null; setState(new TuiState("", s.messages, 0, false, "")); - if (cb != null) { + if (cb != null && !answer.isEmpty()) { Thread.startVirtualThread(() -> cb.accept(answer)); } + } else if (key.escape()) { + Consumer cb = permissionCallback; + permissionCallback = null; + setState(new TuiState("", s.messages, 0, false, "")); + if (cb != null) { + Thread.startVirtualThread(() -> cb.accept("(User cancelled)")); + } } else if (key.backspace() && !s.inputText.isEmpty()) { setState(new TuiState(s.inputText.substring(0, s.inputText.length() - 1), s.messages, s.scrollOffset, false, "")); @@ -691,6 +757,49 @@ public class ClaudeCodeComponent extends Component } } + /** 处理权限确认输入(交互选择模式) */ + private void handlePermissionInput(String input, Key key, TuiState s) { + if (permissionOptions == null || permissionOptions.isEmpty()) return; + + if (key.return_()) { + // 确认选择 → 将选中项映射为 y/a/n + String answer = switch (permissionSelectedIndex) { + case 0 -> "y"; // Yes + case 1 -> "a"; // Yes, and don't ask again (always allow) + case 2 -> "n"; // No + default -> "y"; + }; + Consumer cb = permissionCallback; + permissionCallback = null; + permissionOptions = null; + setState(new TuiState("", s.messages, 0, false, "")); + if (cb != null) { + Thread.startVirtualThread(() -> cb.accept(answer)); + } + } else if (key.escape()) { + // Esc: 取消 → 等同于 No + Consumer cb = permissionCallback; + permissionCallback = null; + permissionOptions = null; + setState(new TuiState("", s.messages, 0, false, "")); + if (cb != null) { + Thread.startVirtualThread(() -> cb.accept("n")); + } + } else if (key.upArrow()) { + permissionSelectedIndex = Math.max(0, permissionSelectedIndex - 1); + setState(new TuiState(s.inputText, s.messages, s.scrollOffset, s.thinking, s.thinkingText)); + } else if (key.downArrow()) { + permissionSelectedIndex = Math.min(permissionOptions.size() - 1, permissionSelectedIndex + 1); + setState(new TuiState(s.inputText, s.messages, s.scrollOffset, s.thinking, s.thinkingText)); + } else if ("1".equals(input) || "2".equals(input) || "3".equals(input)) { + int idx = Integer.parseInt(input) - 1; + if (idx >= 0 && idx < permissionOptions.size()) { + permissionSelectedIndex = idx; + setState(new TuiState(s.inputText, s.messages, s.scrollOffset, s.thinking, s.thinkingText)); + } + } + } + /** 处理 AskUser 交互输入(带选项列表的选择模式) */ private void handleAskUserInput(String input, Key key, TuiState s) { if (askInputMode) { @@ -952,12 +1061,37 @@ public class ClaudeCodeComponent extends Component } } - /** 设置权限确认回调 */ - public void requestPermission(Consumer callback) { + /** 设置简单文本输入回调(用于无选项的 AskUser) */ + public void requestTextInput(Consumer callback) { + this.askOptions = null; + this.askInputMode = false; + this.askQuestion = null; + this.permissionOptions = null; + this.permissionCallback = callback; + } + + /** 设置权限确认回调(交互选择模式) */ + public void requestPermission(String toolName, String suggestedRule, Consumer callback) { this.askOptions = null; this.askInputMode = false; this.askQuestion = null; + // 构建权限选项(匹配原版 Claude Code 格式) + List options = new ArrayList<>(); + options.add("Yes"); + if (suggestedRule != null) { + options.add("Yes, and don't ask again for " + toolName + " commands in " + System.getProperty("user.dir", ".")); + } else { + options.add("Yes, and don't ask again for " + toolName); + } + options.add("No"); + this.permissionOptions = options; + this.permissionSelectedIndex = 0; this.permissionCallback = callback; + // 触发重绘 + synchronized (stateLock) { + TuiState s = getState(); + setState(new TuiState("", s.messages, s.scrollOffset, s.thinking, s.thinkingText)); + } } /** 设置 AskUser 交互模式(带可选列表) */ diff --git a/src/main/java/com/claudecode/tui/JinkReplSession.java b/src/main/java/com/claudecode/tui/JinkReplSession.java index f0d143c..bc5c01c 100644 --- a/src/main/java/com/claudecode/tui/JinkReplSession.java +++ b/src/main/java/com/claudecode/tui/JinkReplSession.java @@ -183,9 +183,9 @@ public class JinkReplSession { false )); - // 使用 CompletableFuture 阻塞等待用户输入 + // 使用 CompletableFuture 阻塞等待用户选择 CompletableFuture future = new CompletableFuture<>(); - component.requestPermission(future::complete); + component.requestPermission(request.toolName(), suggestedRule, future::complete); try { String answer = future.get(); @@ -198,12 +198,6 @@ public class JinkReplSession { Color.BRIGHT_GREEN)); yield PermissionChoice.ALWAYS_ALLOW; } - case "d" -> { - component.addMessage(new SystemMsg( - "✗ Rule saved: always deny " + (suggestedRule != null ? suggestedRule : request.toolName()), - Color.BRIGHT_RED)); - yield PermissionChoice.ALWAYS_DENY; - } case "n", "no" -> { component.addMessage(new SystemMsg("✗ Operation denied", Color.BRIGHT_RED)); yield PermissionChoice.DENY_ONCE; @@ -222,7 +216,7 @@ public class JinkReplSession { component.addMessage(new SystemMsg(prompt, Color.BRIGHT_CYAN)); CompletableFuture future = new CompletableFuture<>(); - component.requestPermission(future::complete); + component.requestTextInput(future::complete); try { return future.get(); @@ -245,7 +239,7 @@ public class JinkReplSession { component.requestAskUser(question, options, future::complete); } else { // 无选项 — 使用普通输入 - component.requestPermission(future::complete); + component.requestTextInput(future::complete); } try {