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 {}
+}