diff --git a/pom.xml b/pom.xml index 9305c4b..1aeec7c 100644 --- a/pom.xml +++ b/pom.xml @@ -56,6 +56,13 @@ spring-ai-starter-model-openai + + + io.mybatis.jink + jink + 0.3.0-SNAPSHOT + + org.jline diff --git a/src/main/java/com/claudecode/cli/ClaudeCodeRunner.java b/src/main/java/com/claudecode/cli/ClaudeCodeRunner.java index 278acb8..d37547a 100644 --- a/src/main/java/com/claudecode/cli/ClaudeCodeRunner.java +++ b/src/main/java/com/claudecode/cli/ClaudeCodeRunner.java @@ -1,6 +1,7 @@ package com.claudecode.cli; import com.claudecode.repl.ReplSession; +import com.claudecode.tui.JinkReplSession; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.CommandLineRunner; @@ -9,22 +10,39 @@ import org.springframework.stereotype.Component; /** * 启动编排器 —— 对应 claude-code/src/main.tsx 的初始化逻辑。 *

- * 在 Spring Boot 启动完成后执行,初始化并启动 REPL 会话。 + * 优先使用 jink TUI 模式,失败时降级到传统 JLine REPL。 */ @Component public class ClaudeCodeRunner implements CommandLineRunner { private static final Logger log = LoggerFactory.getLogger(ClaudeCodeRunner.class); + private final JinkReplSession jinkReplSession; private final ReplSession replSession; - public ClaudeCodeRunner(ReplSession replSession) { + public ClaudeCodeRunner(JinkReplSession jinkReplSession, ReplSession replSession) { + this.jinkReplSession = jinkReplSession; this.replSession = replSession; } @Override public void run(String... args) { log.info("Claude Code (Java) starting..."); - replSession.start(); + + // 检查是否强制使用旧模式 + String tuiMode = System.getenv("CLAUDE_CODE_TUI"); + if ("legacy".equalsIgnoreCase(tuiMode)) { + log.info("Legacy TUI mode requested via CLAUDE_CODE_TUI=legacy"); + replSession.start(); + return; + } + + // 优先使用 jink TUI + try { + jinkReplSession.start(); + } catch (Exception e) { + log.warn("Jink TUI failed, falling back to legacy mode: {}", e.getMessage()); + replSession.start(); + } } } diff --git a/src/main/java/com/claudecode/config/AppConfig.java b/src/main/java/com/claudecode/config/AppConfig.java index ab2f33a..1968526 100644 --- a/src/main/java/com/claudecode/config/AppConfig.java +++ b/src/main/java/com/claudecode/config/AppConfig.java @@ -16,6 +16,7 @@ import com.claudecode.permission.PermissionSettings; import com.claudecode.plugin.OutputStylePlugin; import com.claudecode.plugin.PluginManager; import com.claudecode.repl.ReplSession; +import com.claudecode.tui.JinkReplSession; import com.claudecode.tool.ToolContext; import com.claudecode.tool.ToolRegistry; import com.claudecode.tool.impl.*; @@ -265,6 +266,13 @@ public class AppConfig { return mainLoop; } + @Bean + public JinkReplSession jinkReplSession(AgentLoop agentLoop, ToolRegistry toolRegistry, + CommandRegistry commandRegistry, ProviderInfo providerInfo, + TokenTracker tokenTracker) { + return new JinkReplSession(agentLoop, toolRegistry, commandRegistry, providerInfo, tokenTracker); + } + @Bean public ReplSession replSession(AgentLoop agentLoop, ToolRegistry toolRegistry, CommandRegistry commandRegistry, ProviderInfo providerInfo) { diff --git a/src/main/java/com/claudecode/tui/ClaudeCodeComponent.java b/src/main/java/com/claudecode/tui/ClaudeCodeComponent.java new file mode 100644 index 0000000..3e7a0c1 --- /dev/null +++ b/src/main/java/com/claudecode/tui/ClaudeCodeComponent.java @@ -0,0 +1,677 @@ +package com.claudecode.tui; + +import com.claudecode.command.CommandContext; +import com.claudecode.command.CommandRegistry; +import com.claudecode.console.BannerPrinter; +import com.claudecode.console.MarkdownRenderer; +import com.claudecode.core.AgentLoop; +import com.claudecode.core.TokenTracker; +import com.claudecode.tui.UIMessage.*; +import io.mybatis.jink.component.*; +import io.mybatis.jink.input.Key; +import io.mybatis.jink.style.*; +import io.mybatis.jink.util.StringWidth; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +/** + * Claude Code 主界面组件 —— 使用 jink 框架实现全屏 TUI。 + *

+ * 布局结构(从上到下): + *

+ * ╭─── Claude Code Java v0.1.0 ───────────────────╮  ← 标题框
+ * │  ...                                           │
+ * ╰────────────────────────────────────────────────╯
+ *  ● System message...                                ← 消息列表
+ *  ● User: hello                                      (带虚拟滚动)
+ *  ● AI response...
+ *                                                     ← 弹性空白
+ *  path/to/dir                    model info           ← 状态栏
+ * ────────────────────────────────────────────────────  ← 上分隔线
+ *  ❯ user input here                                   ← 输入区
+ * ────────────────────────────────────────────────────  ← 下分隔线
+ *  ↑↓ history   wheel messages          tokens: xxx   ← 快捷键栏
+ * 
+ */ +public class ClaudeCodeComponent extends Component { + + private static final int PROMPT_WIDTH = 2; // "❯ " + + /** TUI 全局状态 */ + record TuiState( + String inputText, + List messages, + int scrollOffset, + boolean thinking, + String thinkingText + ) { + static TuiState empty() { + return new TuiState("", List.of(), 0, false, ""); + } + } + + // --- 外部依赖(通过构造器注入) --- + private final AgentLoop agentLoop; + private final CommandRegistry commandRegistry; + private final String provider; + private final String model; + private final String baseUrl; + private final int toolCount; + private final int cmdCount; + private final TokenTracker tokenTracker; + private final MarkdownRenderer markdownRenderer; + private final Runnable onExit; + + // --- 内部状态 --- + private final List inputHistory = new ArrayList<>(); + private int historyIndex = -1; + private String savedInput = ""; + private final AtomicBoolean agentRunning = new AtomicBoolean(false); + + /** 权限确认回调(由权限请求设置,用户输入后调用) */ + private volatile Consumer permissionCallback; + + /** 流式 Markdown 渲染状态 */ + private MarkdownRenderer.StreamState streamMdState = new MarkdownRenderer.StreamState(); + + public ClaudeCodeComponent(AgentLoop agentLoop, + CommandRegistry commandRegistry, + String provider, String model, String baseUrl, + int toolCount, int cmdCount, + TokenTracker tokenTracker, + Runnable onExit) { + super(TuiState.empty()); + this.agentLoop = agentLoop; + this.commandRegistry = commandRegistry; + this.provider = provider; + this.model = model; + this.baseUrl = baseUrl; + this.toolCount = toolCount; + this.cmdCount = cmdCount; + this.tokenTracker = tokenTracker; + this.markdownRenderer = new MarkdownRenderer(null); // 不直接打印,用 renderLine() + this.onExit = onExit; + } + + // ==================== 渲染 ==================== + + @Override + public Renderable render() { + TuiState s = getState(); + int w = getColumns(); + int h = getRows(); + + // 计算输入区行数 + int inputLineCount = 1; + String lastLine = s.inputText; + 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); + + int headerHeight = 7; + int bottomHeight = 4 + inputLineCount; + int messagePaddingTop = 1; + int maxMessageLines = h - headerHeight - bottomHeight - messagePaddingTop; + + return Box.of( + headerBox(w), + messagesArea(s, maxMessageLines), + Spacer.create(), + statusBar(w, h), + separator(w), + inputArea(s, w), + separator(w), + shortcutBar(w) + ).flexDirection(FlexDirection.COLUMN).width(w).height(h); + } + + /** 标题框(圆角洋红色边框) */ + private Renderable headerBox(int w) { + return Box.of( + Text.of( + Text.of("☕").color(Color.BRIGHT_YELLOW), + Text.of(" "), + Text.of("Claude Code").color(Color.BRIGHT_MAGENTA).bold(), + Text.of(" (Java)").color(Color.WHITE), + Text.of(" v" + BannerPrinter.getVersion()).dimmed() + ), + Text.of( + Text.of("▸ ").color(Color.BRIGHT_CYAN), + Text.of("API: ").dimmed(), + Text.of(baseUrl).color(Color.BRIGHT_CYAN) + ), + Text.of( + Text.of("▸ ").color(Color.BRIGHT_CYAN), + Text.of("Provider: ").dimmed(), + Text.of(provider.toUpperCase()).color(Color.BRIGHT_GREEN), + Text.of(" Model: ").dimmed(), + Text.of(model).color(Color.BRIGHT_GREEN) + ), + Text.of(" "), + Text.of( + Text.of("Tip: ").dimmed(), + Text.of("/help").color(Color.BRIGHT_CYAN).bold(), + Text.of(" for commands • ").dimmed(), + Text.of("Ctrl+D").color(Color.BRIGHT_CYAN).bold(), + Text.of(" to exit").dimmed() + ) + ).flexDirection(FlexDirection.COLUMN) + .borderStyle(BorderStyle.ROUND) + .borderColor(Color.BRIGHT_MAGENTA) + .paddingX(1); + } + + /** 消息列表(带虚拟滚动) */ + private Renderable messagesArea(TuiState s, int maxLines) { + List allItems = new ArrayList<>(); + + // 初始系统消息 + allItems.add(msgLine(Color.BRIGHT_BLUE, + "Tools: " + toolCount + " | Commands: " + cmdCount + " | Work Dir: " + System.getProperty("user.dir"))); + + // 渲染所有消息 + for (UIMessage msg : s.messages) { + 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() + )); + } + + // 虚拟滚动 + List visibleItems; + if (maxLines > 0 && allItems.size() > maxLines) { + int endIdx = allItems.size() - s.scrollOffset; + int startIdx = Math.max(0, endIdx - maxLines); + endIdx = Math.min(allItems.size(), startIdx + maxLines); + visibleItems = allItems.subList(startIdx, endIdx); + } else { + visibleItems = allItems; + } + + return Box.of(visibleItems.toArray(new Renderable[0])) + .flexDirection(FlexDirection.COLUMN) + .paddingTop(1) + .paddingX(1); + } + + /** 将 UIMessage 渲染为 Renderable 列表(一条消息可能产生多行) */ + private List renderMessage(UIMessage msg) { + return switch (msg) { + case UserMsg m -> List.of(Text.of( + Text.of("❯ ").color(Color.BRIGHT_GREEN).bold(), + Text.of(m.text()).color(Color.WHITE).bold() + )); + + case AssistantMsg m -> { + List lines = new ArrayList<>(); + lines.add(Text.of( + Text.of("● ").color(Color.BRIGHT_CYAN), + Text.of(m.streaming() ? m.text() + "▌" : m.text()).color(Color.WHITE) + )); + yield lines; + } + + case ToolCallMsg m -> { + List lines = new ArrayList<>(); + String argPreview = m.args() != null && m.args().length() > 60 + ? m.args().substring(0, 60) + "..." + : (m.args() != null ? m.args() : ""); + if (m.running()) { + lines.add(Text.of( + Text.of(" ● ").color(Color.BRIGHT_BLUE), + Text.of(m.toolName()).color(Color.BRIGHT_CYAN).bold(), + Text.of(" " + argPreview).dimmed() + )); + } else { + lines.add(Text.of( + Text.of(" ● ").color(Color.BRIGHT_GREEN), + Text.of(m.toolName()).color(Color.BRIGHT_CYAN), + Text.of(" ✓").color(Color.BRIGHT_GREEN) + )); + if (m.result() != null && !m.result().isBlank()) { + String preview = m.result().length() > 200 + ? m.result().substring(0, 200) + "..." + : m.result(); + for (String line : preview.split("\n")) { + lines.add(Text.of( + Text.of(" ⎿ ").dimmed(), + Text.of(line).dimmed() + )); + } + } + } + yield lines; + } + + case ThinkingMsg m -> List.of(Text.of( + Text.of(" ◐ ").color(Color.BRIGHT_MAGENTA), + Text.of(m.text().length() > 100 ? m.text().substring(0, 100) + "..." : m.text()) + .color(Color.BRIGHT_MAGENTA).dimmed() + )); + + case SystemMsg m -> List.of(msgLine(m.color(), m.text())); + + case TimingMsg m -> List.of(Text.of( + Text.of(" ✻ ").dimmed(), + Text.of("Worked for " + m.seconds() + "s").dimmed() + )); + + case PermissionMsg m -> { + List lines = new ArrayList<>(); + lines.add(Text.of( + m.dangerous() + ? Text.of("⚠ DANGEROUS Operation").color(Color.BRIGHT_RED).bold() + : Text.of("⚠ Permission Required").color(Color.BRIGHT_YELLOW).bold() + )); + lines.add(Text.of( + Text.of(" Tool: ").bold(), + Text.of(m.toolName()).color(Color.BRIGHT_CYAN) + )); + lines.add(Text.of( + Text.of(" Action: "), + Text.of(m.action()).color(Color.WHITE) + )); + 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; + } + + case CommandOutputMsg m -> { + List lines = new ArrayList<>(); + for (String line : m.text().split("\n")) { + lines.add(Text.of(Text.of(" " + line).dimmed())); + } + yield lines; + } + }; + } + + /** 单条状态消息行 */ + private Renderable msgLine(Color dotColor, String text) { + return Text.of( + Text.of("● ").color(dotColor), + Text.of(text).color(Color.WHITE) + ); + } + + /** 状态栏 */ + private Renderable statusBar(int w, int h) { + String left = System.getProperty("user.dir", ".") + " (" + w + "×" + h + ")"; + String right = model + " | " + provider.toUpperCase(); + + return Box.of( + Text.of(left).dimmed(), + Spacer.create(), + Text.of(right).dimmed() + ).paddingX(1); + } + + /** 分隔线 */ + private Renderable separator(int w) { + return Box.of( + Text.of("─".repeat(Math.max(0, w - 2))).color(Color.BRIGHT_BLACK) + ).paddingX(1); + } + + /** 输入区 */ + private Renderable inputArea(TuiState s, int 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 正在运行 + content = Text.of(s.thinking ? "◐ Thinking..." : "● Processing...").color(Color.BRIGHT_CYAN).dimmed(); + prompt = Text.of(" ").dimmed(); + } else if (s.inputText.isEmpty()) { + content = Text.of("Type a message, / for commands, or Ctrl+D to exit").dimmed(); + } else { + String indent = " ".repeat(PROMPT_WIDTH); + String displayText = s.inputText.replace("\n", "\n" + indent); + content = Text.of(displayText).color(Color.WHITE); + } + + return Box.of( + Text.of(prompt, content) + ).paddingX(1); + } + + /** 快捷键栏 */ + private Renderable shortcutBar(int w) { + // Token 统计 + String tokenInfo = ""; + if (tokenTracker != null) { + long input = tokenTracker.getInputTokens(); + long output = tokenTracker.getOutputTokens(); + if (input > 0 || output > 0) { + tokenInfo = "↑" + formatTokens(input) + " ↓" + formatTokens(output); + } + } + + return Box.of( + Text.of( + Text.of("↑↓").dimmed(), + Text.of(" history").dimmed(), + Text.of(" "), + Text.of("wheel").dimmed(), + Text.of(" scroll").dimmed(), + Text.of(" "), + Text.of("Ctrl+D").dimmed(), + Text.of(" exit").dimmed() + ), + Spacer.create(), + Text.of(tokenInfo).color(Color.BRIGHT_GREEN) + ).paddingX(1); + } + + private String formatTokens(long tokens) { + if (tokens >= 1_000_000) return String.format("%.1fM", tokens / 1_000_000.0); + if (tokens >= 1_000) return String.format("%.1fK", tokens / 1_000.0); + return String.valueOf(tokens); + } + + // ==================== 输入处理 ==================== + + @Override + public void onInput(String input, Key key) { + TuiState s = getState(); + + // Ctrl+D: 退出 + if (key.ctrl() && "d".equals(input)) { + if (onExit != null) onExit.run(); + return; + } + + // Ctrl+C: 取消当前输入或中断 Agent + if (key.ctrl() && "c".equals(input)) { + if (agentRunning.get()) { + // TODO: 中断 Agent 运行 + addMessage(new SystemMsg("^C (interrupt)", Color.BRIGHT_YELLOW)); + } else { + setState(new TuiState("", s.messages, s.scrollOffset, false, "")); + } + return; + } + + // 权限确认模式 + if (permissionCallback != null) { + handlePermissionInput(input, key, s); + return; + } + + // AI 运行中时忽略大部分输入(但允许滚动) + if (agentRunning.get()) { + handleScrollInput(key, s); + return; + } + + if (key.return_() && key.meta()) { + // Shift+Enter: 多行换行 + setState(new TuiState(s.inputText + "\n", s.messages, 0, false, "")); + } else if (key.return_()) { + // Enter: 发送 + if (!s.inputText.isEmpty()) { + submitInput(s.inputText, s); + } + } else if (key.backspace()) { + if (!s.inputText.isEmpty()) { + abandonHistoryPreview(); + String newText = s.inputText.substring(0, s.inputText.length() - 1); + setState(new TuiState(newText, s.messages, s.scrollOffset, false, "")); + } + } else if (key.upArrow()) { + browseHistoryUp(s); + } else if (key.downArrow()) { + browseHistoryDown(s); + } else if (key.scrollUp()) { + scroll(s, 3); + } else if (key.scrollDown()) { + scroll(s, -3); + } else if (key.pageUp()) { + scroll(s, 10); + } else if (key.pageDown()) { + scroll(s, -10); + } else if (key.escape()) { + // Esc: 清空输入 + 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, "")); + } + } + + /** 处理权限确认输入 */ + private void handlePermissionInput(String input, Key key, TuiState s) { + if (key.return_()) { + String answer = s.inputText.isEmpty() ? "y" : s.inputText; + Consumer cb = permissionCallback; + permissionCallback = null; + setState(new TuiState("", s.messages, 0, false, "")); + if (cb != null) cb.accept(answer); + } 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, "")); + } + } + + /** 处理滚动输入 */ + private void handleScrollInput(Key key, TuiState s) { + if (key.scrollUp()) scroll(s, 3); + else if (key.scrollDown()) scroll(s, -3); + else if (key.pageUp()) scroll(s, 10); + else if (key.pageDown()) scroll(s, -10); + } + + /** 提交用户输入 */ + private void submitInput(String text, TuiState s) { + inputHistory.add(text); + historyIndex = -1; + savedInput = ""; + + // 斜杠命令 + if (commandRegistry != null && commandRegistry.isCommand(text)) { + addMessage(new UserMsg(text)); + CommandContext cmdCtx = new CommandContext(agentLoop, null, commandRegistry, + new java.io.PrintStream(java.io.OutputStream.nullOutputStream()), () -> { + if (onExit != null) onExit.run(); + }); + Optional result = commandRegistry.dispatch(text, cmdCtx); + result.ifPresent(r -> addMessage(new CommandOutputMsg(r))); + setState(new TuiState("", getState().messages, 0, false, "")); + return; + } + + // Agent 调用 + addMessage(new UserMsg(text)); + setState(new TuiState("", getState().messages, 0, true, "")); + runAgent(text); + } + + /** 在后台线程运行 Agent 循环 */ + private void runAgent(String userInput) { + agentRunning.set(true); + streamMdState = new MarkdownRenderer.StreamState(); + + Thread.startVirtualThread(() -> { + long startTime = System.currentTimeMillis(); + try { + agentLoop.runStreaming(userInput, token -> { + // 流式 token 追加到最后一个 AssistantMsg + appendToStreamingMessage(token); + }); + + // 完成当前流式消息 + finishStreamingMessage(); + + // 显示耗时 + long elapsed = (System.currentTimeMillis() - startTime) / 1000; + if (elapsed > 0) { + addMessage(new TimingMsg(elapsed)); + } + } catch (Exception e) { + addMessage(new SystemMsg("Error: " + e.getMessage(), Color.BRIGHT_RED)); + } finally { + agentRunning.set(false); + TuiState cs = getState(); + setState(new TuiState(cs.inputText, cs.messages, 0, false, "")); + } + }); + } + + // ==================== 消息管理 ==================== + + /** 添加一条消息 */ + public void addMessage(UIMessage msg) { + TuiState s = getState(); + List newMsgs = new ArrayList<>(s.messages); + newMsgs.add(msg); + setState(new TuiState(s.inputText, Collections.unmodifiableList(newMsgs), + 0, s.thinking, s.thinkingText)); + } + + /** 追加 token 到当前流式助手消息 */ + private void appendToStreamingMessage(String token) { + TuiState s = getState(); + List msgs = new ArrayList<>(s.messages); + + // 查找最后一个 streaming AssistantMsg + if (!msgs.isEmpty() && msgs.getLast() instanceof AssistantMsg am && am.streaming()) { + msgs.set(msgs.size() - 1, am.appendText(token)); + } else { + msgs.add(new AssistantMsg(token, true)); + } + + setState(new TuiState(s.inputText, Collections.unmodifiableList(msgs), + 0, s.thinking, s.thinkingText)); + } + + /** 完成当前流式消息(公开给 JinkReplSession 使用) */ + public void finishStreamingMessage() { + TuiState s = getState(); + List msgs = new ArrayList<>(s.messages); + + if (!msgs.isEmpty() && msgs.getLast() instanceof AssistantMsg am && am.streaming()) { + msgs.set(msgs.size() - 1, am.finish()); + setState(new TuiState(s.inputText, Collections.unmodifiableList(msgs), + 0, s.thinking, s.thinkingText)); + } + } + + /** 更新最后一个工具调用消息的结果 */ + public void completeLastToolCall(String result) { + TuiState s = getState(); + List msgs = new ArrayList<>(s.messages); + + for (int i = msgs.size() - 1; i >= 0; i--) { + if (msgs.get(i) instanceof ToolCallMsg tcm && tcm.running()) { + msgs.set(i, tcm.complete(result)); + break; + } + } + + setState(new TuiState(s.inputText, Collections.unmodifiableList(msgs), + s.scrollOffset, s.thinking, s.thinkingText)); + } + + /** 设置权限确认回调 */ + public void requestPermission(Consumer callback) { + this.permissionCallback = callback; + } + + /** 设置 thinking 状态 */ + public void setThinking(boolean thinking, String text) { + TuiState s = getState(); + setState(new TuiState(s.inputText, s.messages, s.scrollOffset, thinking, text)); + } + + // ==================== 历史导航 ==================== + + private void browseHistoryUp(TuiState s) { + if (inputHistory.isEmpty()) return; + if (historyIndex == -1) { + savedInput = s.inputText; + historyIndex = inputHistory.size() - 1; + } else if (historyIndex > 0) { + historyIndex--; + } + setState(new TuiState(inputHistory.get(historyIndex), s.messages, s.scrollOffset, false, "")); + } + + private void browseHistoryDown(TuiState s) { + if (historyIndex < 0) return; + historyIndex++; + if (historyIndex >= inputHistory.size()) { + historyIndex = -1; + setState(new TuiState(savedInput, s.messages, s.scrollOffset, false, "")); + savedInput = ""; + return; + } + setState(new TuiState(inputHistory.get(historyIndex), s.messages, s.scrollOffset, false, "")); + } + + private void abandonHistoryPreview() { + if (historyIndex >= 0) { + historyIndex = -1; + savedInput = ""; + } + } + + // ==================== 滚动 ==================== + + private void scroll(TuiState s, int delta) { + int totalMessages = s.messages.size() + 1; // +1 for initial system msg + int maxOffset = Math.max(0, totalMessages - 1); + int newOffset = Math.max(0, Math.min(s.scrollOffset + delta, maxOffset)); + setState(new TuiState(s.inputText, s.messages, newOffset, s.thinking, s.thinkingText)); + } + + // ==================== 工具方法 ==================== + + 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; + if (key.pageUp() || key.pageDown() || key.home() || key.end()) return false; + if (key.escape() || key.tab() || key.delete()) return false; + if (key.scrollUp() || key.scrollDown()) return false; + if (input.length() == 1 && input.charAt(0) < 0x20) return false; + return true; + } + + /** 获取 Agent 运行状态 */ + public boolean isAgentRunning() { + return agentRunning.get(); + } +} diff --git a/src/main/java/com/claudecode/tui/JinkReplSession.java b/src/main/java/com/claudecode/tui/JinkReplSession.java new file mode 100644 index 0000000..75cff86 --- /dev/null +++ b/src/main/java/com/claudecode/tui/JinkReplSession.java @@ -0,0 +1,250 @@ +package com.claudecode.tui; + +import com.claudecode.config.AppConfig.ProviderInfo; +import com.claudecode.command.CommandRegistry; +import com.claudecode.core.AgentLoop; +import com.claudecode.core.ConversationPersistence; +import com.claudecode.core.TokenTracker; +import com.claudecode.permission.PermissionTypes.PermissionChoice; +import com.claudecode.tui.UIMessage.*; +import com.claudecode.tool.ToolRegistry; +import com.claudecode.tool.impl.AskUserQuestionTool; +import io.mybatis.jink.Ink; +import io.mybatis.jink.style.Color; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; + +/** + * 基于 jink 的 REPL 会话 —— 替代原有的 JLine readLine() 模式。 + *

+ * 使用 jink 的 Component 模型实现全屏 TUI: + *

    + *
  • 全屏渲染(alternate screen buffer)
  • + *
  • 输入区上下有分隔线(最关键的 UI 改进)
  • + *
  • 消息列表、状态栏、快捷键栏
  • + *
  • AgentLoop 在后台线程运行,回调驱动 UI 更新
  • + *
+ */ +public class JinkReplSession { + + private static final Logger log = LoggerFactory.getLogger(JinkReplSession.class); + + private final AgentLoop agentLoop; + private final ToolRegistry toolRegistry; + private final CommandRegistry commandRegistry; + private final ProviderInfo providerInfo; + private final ConversationPersistence persistence; + private final TokenTracker tokenTracker; + + private ClaudeCodeComponent component; + private Ink.Instance inkApp; + private String conversationSummary = ""; + + public JinkReplSession(AgentLoop agentLoop, + ToolRegistry toolRegistry, + CommandRegistry commandRegistry, + ProviderInfo providerInfo, + TokenTracker tokenTracker) { + this.agentLoop = agentLoop; + this.toolRegistry = toolRegistry; + this.commandRegistry = commandRegistry; + this.providerInfo = providerInfo; + this.persistence = new ConversationPersistence(); + this.tokenTracker = tokenTracker; + } + + /** + * 启动 jink TUI 会话。 + */ + public void start() { + try { + startJink(); + } catch (Exception e) { + log.error("Jink TUI startup failed: {}", e.getMessage(), e); + System.err.println("TUI startup failed: " + e.getMessage()); + System.err.println("Please use a terminal that supports ANSI escape codes."); + } + } + + private void startJink() { + // 创建主组件 + component = new ClaudeCodeComponent( + agentLoop, + commandRegistry, + providerInfo.provider(), + providerInfo.model(), + providerInfo.baseUrl(), + toolRegistry.size(), + commandRegistry.getCommands().size(), + tokenTracker, + this::exit + ); + + // 注册 AgentLoop 回调 + setupAgentCallbacks(); + setupToolContextCallbacks(); + + // 启动 jink 渲染 + inkApp = Ink.render(component); + + // 阻塞等待退出 + inkApp.waitUntilExit(); + + // 退出后保存对话 + saveConversation(); + } + + /** 注册 AgentLoop 事件回调,驱动 TUI 更新 */ + private void setupAgentCallbacks() { + // 工具调用事件 + agentLoop.setOnToolEvent(event -> { + switch (event.phase()) { + case START -> { + // 完成当前流式消息(如果有) + finishCurrentStreaming(); + component.addMessage(new ToolCallMsg( + event.toolName(), + event.arguments(), + null, + true + )); + } + case END -> { + component.completeLastToolCall(event.result()); + } + } + }); + + // 流式输出第一个 token + agentLoop.setOnStreamStart(() -> { + component.setThinking(false, ""); + }); + + // 阻塞模式回调(流式模式不使用) + agentLoop.setOnAssistantMessage(text -> {}); + + // 权限确认回调 + agentLoop.setOnPermissionRequest(request -> { + return promptPermissionInTui(request); + }); + + // Thinking 内容回调 + agentLoop.setOnThinkingContent(thinkingText -> { + component.setThinking(true, thinkingText); + component.addMessage(new ThinkingMsg(thinkingText)); + }); + } + + /** 注册 ToolContext 回调(AskUser) */ + private void setupToolContextCallbacks() { + var toolContext = agentLoop.getToolContext(); + if (toolContext != null) { + toolContext.set(AskUserQuestionTool.USER_INPUT_CALLBACK, + (Function) this::askUserInTui); + } + } + + /** 在 TUI 中请求权限确认 */ + private PermissionChoice promptPermissionInTui(AgentLoop.PermissionRequest request) { + // 完成当前流式消息 + finishCurrentStreaming(); + + // 添加权限请求消息 + boolean isDangerous = request.decision() != null + && request.decision().reason() != null + && request.decision().reason().startsWith("⚠ DANGEROUS"); + + String suggestedRule = null; + if (request.decision() != null && request.decision().commandPrefix() != null) { + suggestedRule = request.toolName() + "(" + request.decision().commandPrefix() + ":*)"; + } + + component.addMessage(new PermissionMsg( + request.toolName(), + request.activityDescription(), + request.arguments(), + isDangerous, + suggestedRule, + false + )); + + // 使用 CompletableFuture 阻塞等待用户输入 + CompletableFuture future = new CompletableFuture<>(); + component.requestPermission(future::complete); + + try { + String answer = future.get(); + answer = answer.strip().toLowerCase(); + + return switch (answer) { + case "a", "always" -> { + component.addMessage(new SystemMsg( + "✓ Rule saved: always allow " + (suggestedRule != null ? suggestedRule : request.toolName()), + 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; + } + default -> PermissionChoice.ALLOW_ONCE; + }; + } catch (Exception e) { + log.error("Permission prompt interrupted", e); + return PermissionChoice.DENY_ONCE; + } + } + + /** 在 TUI 中请求用户输入(AskUser 工具) */ + private String askUserInTui(String prompt) { + finishCurrentStreaming(); + component.addMessage(new SystemMsg(prompt, Color.BRIGHT_CYAN)); + + CompletableFuture future = new CompletableFuture<>(); + component.requestPermission(future::complete); + + try { + return future.get(); + } catch (Exception e) { + return "(User cancelled)"; + } + } + + /** 完成当前流式消息(如果存在) */ + private void finishCurrentStreaming() { + component.finishStreamingMessage(); + } + + /** 退出并清理 */ + private void exit() { + saveConversation(); + if (inkApp != null) { + inkApp.exit(); + } + } + + /** 保存对话历史 */ + private void saveConversation() { + var history = agentLoop.getMessageHistory(); + if (history.size() > 2) { + var file = persistence.save(history, conversationSummary); + if (file != null) { + log.info("Conversation saved: {}", file.getFileName()); + } + } + } + + /** 获取对话持久化管理器 */ + public ConversationPersistence getPersistence() { + return persistence; + } +} diff --git a/src/main/java/com/claudecode/tui/UIMessage.java b/src/main/java/com/claudecode/tui/UIMessage.java new file mode 100644 index 0000000..35355ac --- /dev/null +++ b/src/main/java/com/claudecode/tui/UIMessage.java @@ -0,0 +1,56 @@ +package com.claudecode.tui; + +import io.mybatis.jink.style.Color; + +import java.util.List; + +/** + * TUI 消息模型 —— 对应 Claude Code 界面中显示的各类消息。 + *

+ * 使用 sealed interface 确保消息类型完备。 + */ +public sealed interface UIMessage { + + /** 用户输入消息 */ + record UserMsg(String text) implements UIMessage {} + + /** AI 助手回复(支持流式追加) */ + record AssistantMsg(String text, boolean streaming) implements UIMessage { + public AssistantMsg appendText(String token) { + return new AssistantMsg(text + token, streaming); + } + + public AssistantMsg finish() { + return new AssistantMsg(text, false); + } + } + + /** 工具调用消息 */ + record ToolCallMsg(String toolName, String args, String result, boolean running) implements UIMessage { + public ToolCallMsg complete(String result) { + return new ToolCallMsg(toolName, args, result, false); + } + } + + /** AI 思考过程 */ + record ThinkingMsg(String text) implements UIMessage {} + + /** 系统状态消息(启动提示、警告等) */ + record SystemMsg(String text, Color color) implements UIMessage {} + + /** 耗时统计 */ + record TimingMsg(long seconds) implements UIMessage {} + + /** 权限确认请求(内联显示) */ + record PermissionMsg( + String toolName, + String action, + String args, + boolean dangerous, + String suggestedRule, + boolean answered + ) implements UIMessage {} + + /** 命令输出消息 */ + record CommandOutputMsg(String text) implements UIMessage {} +}