feat: add jink TUI framework integration
- Add jink dependency (io.mybatis.jink:jink:0.3.0-SNAPSHOT) - Create UIMessage sealed interface for TUI message model - Create ClaudeCodeComponent (main jink Component with full layout) - Header box with rounded magenta border - Message list with virtual scrolling - Separator lines above/below input area - Input area with prompt and placeholder - Status bar and shortcut key bar - Create JinkReplSession (jink-based REPL replacing JLine readLine loop) - AgentLoop integration via callbacks -> setState - Permission confirmation inline in TUI - Streaming token display - Update ClaudeCodeRunner to prefer jink TUI with legacy fallback - Update AppConfig with JinkReplSession bean Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -56,6 +56,13 @@
|
||||
<artifactId>spring-ai-starter-model-openai</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Jink: React-like Terminal UI 框架(基于 JLine,提供组件化全屏渲染) -->
|
||||
<dependency>
|
||||
<groupId>io.mybatis.jink</groupId>
|
||||
<artifactId>jink</artifactId>
|
||||
<version>0.3.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
|
||||
<!-- JLine 3: 终端交互(行编辑、历史、Tab补全、ANSI样式) -->
|
||||
<dependency>
|
||||
<groupId>org.jline</groupId>
|
||||
|
||||
@@ -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 的初始化逻辑。
|
||||
* <p>
|
||||
* 在 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...");
|
||||
|
||||
// 检查是否强制使用旧模式
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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。
|
||||
* <p>
|
||||
* 布局结构(从上到下):
|
||||
* <pre>
|
||||
* ╭─── 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 ← 快捷键栏
|
||||
* </pre>
|
||||
*/
|
||||
public class ClaudeCodeComponent extends Component<ClaudeCodeComponent.TuiState> {
|
||||
|
||||
private static final int PROMPT_WIDTH = 2; // "❯ "
|
||||
|
||||
/** TUI 全局状态 */
|
||||
record TuiState(
|
||||
String inputText,
|
||||
List<UIMessage> 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<String> inputHistory = new ArrayList<>();
|
||||
private int historyIndex = -1;
|
||||
private String savedInput = "";
|
||||
private final AtomicBoolean agentRunning = new AtomicBoolean(false);
|
||||
|
||||
/** 权限确认回调(由权限请求设置,用户输入后调用) */
|
||||
private volatile Consumer<String> 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<Renderable> 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<Renderable> 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<Renderable> 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<Renderable> 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<Renderable> 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<Renderable> 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<Renderable> 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<String> 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<String> 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<UIMessage> 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<UIMessage> 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<UIMessage> 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<UIMessage> 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<String> 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();
|
||||
}
|
||||
}
|
||||
@@ -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() 模式。
|
||||
* <p>
|
||||
* 使用 jink 的 Component 模型实现全屏 TUI:
|
||||
* <ul>
|
||||
* <li>全屏渲染(alternate screen buffer)</li>
|
||||
* <li>输入区上下有分隔线(最关键的 UI 改进)</li>
|
||||
* <li>消息列表、状态栏、快捷键栏</li>
|
||||
* <li>AgentLoop 在后台线程运行,回调驱动 UI 更新</li>
|
||||
* </ul>
|
||||
*/
|
||||
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<String, String>) 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<String> 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<String> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.claudecode.tui;
|
||||
|
||||
import io.mybatis.jink.style.Color;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* TUI 消息模型 —— 对应 Claude Code 界面中显示的各类消息。
|
||||
* <p>
|
||||
* 使用 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 {}
|
||||
}
|
||||
Reference in New Issue
Block a user