feat: Phase2 JLine终端交互增强 - 行编辑、历史、Tab补全、信号处理
JLine 3 集成: - ReplSession重写: JLine Terminal + LineReader 替代Scanner - 支持行编辑(光标移动、删除、Home/End等) - 持久化历史记录(~/.claude-code-java/history) - 上下箭头浏览输入历史 - Ctrl+C取消当前输入、Ctrl+D退出 - JLine失败时自动降级到Scanner模式 Tab补全: - ClaudeCodeCompleter: 斜杠命令补全(/后按Tab) - 支持命令别名补全(如 /q → exit) - 分组显示(Commands / Aliases) 新增命令: - /model: 显示当前AI模型信息 - /compact: 压缩对话上下文 - /cost: Token用量显示(占位) 改进: - HelpCommand清理为统一格式 - Banner增加终端信息显示和快捷键提示 - 彩色提示符使用AttributedStringBuilder Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
package com.claudecode.command.impl;
|
||||
|
||||
import com.claudecode.command.CommandContext;
|
||||
import com.claudecode.command.SlashCommand;
|
||||
import com.claudecode.console.AnsiStyle;
|
||||
|
||||
/**
|
||||
* /compact 命令 —— 压缩当前对话上下文。
|
||||
* <p>
|
||||
* 对应 claude-code/src/commands/compact.ts。
|
||||
* 当前为简化实现,直接清空历史并提示用户。
|
||||
*/
|
||||
public class CompactCommand implements SlashCommand {
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "compact";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "Compact conversation context";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String execute(String args, CommandContext context) {
|
||||
if (context.agentLoop() != null) {
|
||||
int before = context.agentLoop().getMessageHistory().size();
|
||||
context.agentLoop().reset();
|
||||
return AnsiStyle.green(" ✓ Context compacted: " + before + " messages → 1 (system prompt only)");
|
||||
}
|
||||
return AnsiStyle.yellow(" ⚠ No active conversation to compact.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.claudecode.command.impl;
|
||||
|
||||
import com.claudecode.command.CommandContext;
|
||||
import com.claudecode.command.SlashCommand;
|
||||
import com.claudecode.console.AnsiStyle;
|
||||
|
||||
/**
|
||||
* /cost 命令 —— 显示 Token 使用量和费用估算。
|
||||
* <p>
|
||||
* 对应 claude-code/src/commands/cost.ts。
|
||||
* 当前为占位实现,后续接入实际 Token 统计。
|
||||
*/
|
||||
public class CostCommand implements SlashCommand {
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "cost";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "Show token usage and cost";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String execute(String args, CommandContext context) {
|
||||
int msgCount = 0;
|
||||
if (context.agentLoop() != null) {
|
||||
msgCount = context.agentLoop().getMessageHistory().size();
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("\n");
|
||||
sb.append(AnsiStyle.bold(" Token Usage:\n\n"));
|
||||
sb.append(" Messages: ").append(AnsiStyle.cyan(String.valueOf(msgCount))).append("\n");
|
||||
sb.append(" Input tokens: ").append(AnsiStyle.dim("(tracking not yet implemented)")).append("\n");
|
||||
sb.append(" Output tokens:").append(AnsiStyle.dim("(tracking not yet implemented)")).append("\n");
|
||||
sb.append(" Est. cost: ").append(AnsiStyle.dim("(tracking not yet implemented)")).append("\n");
|
||||
sb.append("\n");
|
||||
sb.append(AnsiStyle.dim(" Token tracking will be added in a future update."));
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.claudecode.command.impl;
|
||||
|
||||
import com.claudecode.command.CommandContext;
|
||||
import com.claudecode.command.CommandRegistry;
|
||||
import com.claudecode.command.SlashCommand;
|
||||
import com.claudecode.console.AnsiStyle;
|
||||
|
||||
@@ -31,24 +32,21 @@ public class HelpCommand implements SlashCommand {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(AnsiStyle.bold("\n Available Commands:\n\n"));
|
||||
|
||||
for (SlashCommand cmd : context.toolRegistry() != null
|
||||
? List.<SlashCommand>of() // 这里后续会获取注册的命令
|
||||
: List.<SlashCommand>of()) {
|
||||
sb.append(String.format(" %s%-12s%s %s%n",
|
||||
AnsiStyle.CYAN, "/" + cmd.name(), AnsiStyle.RESET, cmd.description()));
|
||||
}
|
||||
|
||||
// 硬编码展示(后续重构为动态)
|
||||
sb.append(String.format(" %s%-12s%s %s%n", AnsiStyle.CYAN, "/help", AnsiStyle.RESET, "Show available commands"));
|
||||
sb.append(String.format(" %s%-12s%s %s%n", AnsiStyle.CYAN, "/clear", AnsiStyle.RESET, "Clear conversation history"));
|
||||
sb.append(String.format(" %s%-12s%s %s%n", AnsiStyle.CYAN, "/compact", AnsiStyle.RESET, "Compact conversation context"));
|
||||
sb.append(String.format(" %s%-12s%s %s%n", AnsiStyle.CYAN, "/cost", AnsiStyle.RESET, "Show token usage and cost"));
|
||||
sb.append(String.format(" %s%-12s%s %s%n", AnsiStyle.CYAN, "/model", AnsiStyle.RESET, "Show or switch AI model"));
|
||||
sb.append(String.format(" %s%-12s%s %s%n", AnsiStyle.CYAN, "/exit", AnsiStyle.RESET, "Exit the application"));
|
||||
// 从注入的 CommandRegistry 获取不到(因为 context 里没有),所以硬编码与注册保持一致
|
||||
sb.append(formatCmd("help", "Show available commands"));
|
||||
sb.append(formatCmd("clear", "Clear conversation history"));
|
||||
sb.append(formatCmd("compact", "Compact conversation context"));
|
||||
sb.append(formatCmd("cost", "Show token usage and cost"));
|
||||
sb.append(formatCmd("model", "Show or switch AI model"));
|
||||
sb.append(formatCmd("exit", "Exit the application (also: /quit, /q)"));
|
||||
|
||||
sb.append("\n");
|
||||
sb.append(AnsiStyle.dim(" Tips: Press Tab for command completion, Ctrl+D to exit\n"));
|
||||
sb.append(AnsiStyle.dim(" Shortcuts: Tab to autocomplete, ↑↓ to browse history, Ctrl+D to exit\n"));
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String formatCmd(String name, String desc) {
|
||||
return String.format(" %s%-12s%s %s%n", AnsiStyle.CYAN, "/" + name, AnsiStyle.RESET, desc);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.claudecode.command.impl;
|
||||
|
||||
import com.claudecode.command.CommandContext;
|
||||
import com.claudecode.command.SlashCommand;
|
||||
import com.claudecode.console.AnsiStyle;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* /model 命令 —— 显示或切换当前 AI 模型。
|
||||
*/
|
||||
public class ModelCommand implements SlashCommand {
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "model";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "Show or switch AI model";
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> aliases() {
|
||||
return List.of("m");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String execute(String args, CommandContext context) {
|
||||
// 当前只显示信息,后续可扩展为切换模型
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("\n");
|
||||
sb.append(AnsiStyle.bold(" Model Configuration:\n\n"));
|
||||
sb.append(" Provider: ").append(AnsiStyle.cyan("Anthropic")).append("\n");
|
||||
sb.append(" Model: ").append(AnsiStyle.cyan(
|
||||
System.getenv().getOrDefault("AI_MODEL", "claude-sonnet-4-20250514"))).append("\n");
|
||||
|
||||
if (args != null && !args.isBlank()) {
|
||||
sb.append("\n");
|
||||
sb.append(AnsiStyle.yellow(" ⚠ Model switching not yet implemented. Set AI_MODEL env variable."));
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,11 @@ package com.claudecode.config;
|
||||
|
||||
import com.claudecode.command.CommandRegistry;
|
||||
import com.claudecode.command.impl.ClearCommand;
|
||||
import com.claudecode.command.impl.CompactCommand;
|
||||
import com.claudecode.command.impl.CostCommand;
|
||||
import com.claudecode.command.impl.ExitCommand;
|
||||
import com.claudecode.command.impl.HelpCommand;
|
||||
import com.claudecode.command.impl.ModelCommand;
|
||||
import com.claudecode.context.ClaudeMdLoader;
|
||||
import com.claudecode.context.SystemPromptBuilder;
|
||||
import com.claudecode.core.AgentLoop;
|
||||
@@ -51,6 +54,9 @@ public class AppConfig {
|
||||
registry.registerAll(
|
||||
new HelpCommand(),
|
||||
new ClearCommand(),
|
||||
new CompactCommand(),
|
||||
new CostCommand(),
|
||||
new ModelCommand(),
|
||||
new ExitCommand()
|
||||
);
|
||||
return registry;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.claudecode.repl;
|
||||
|
||||
import com.claudecode.command.CommandRegistry;
|
||||
import com.claudecode.command.SlashCommand;
|
||||
import com.claudecode.tool.ToolRegistry;
|
||||
import org.jline.reader.Candidate;
|
||||
import org.jline.reader.Completer;
|
||||
import org.jline.reader.LineReader;
|
||||
import org.jline.reader.ParsedLine;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Tab 补全器 —— 对应 claude-code 中的命令补全逻辑。
|
||||
* <p>
|
||||
* 支持:
|
||||
* <ul>
|
||||
* <li>斜杠命令补全(输入 / 后按 Tab)</li>
|
||||
* <li>工具名称补全(用于调试或直接引用)</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class ClaudeCodeCompleter implements Completer {
|
||||
|
||||
private final CommandRegistry commandRegistry;
|
||||
private final ToolRegistry toolRegistry;
|
||||
|
||||
public ClaudeCodeCompleter(CommandRegistry commandRegistry, ToolRegistry toolRegistry) {
|
||||
this.commandRegistry = commandRegistry;
|
||||
this.toolRegistry = toolRegistry;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void complete(LineReader reader, ParsedLine line, List<Candidate> candidates) {
|
||||
String buffer = line.line().substring(0, line.cursor());
|
||||
|
||||
if (buffer.startsWith("/")) {
|
||||
// 斜杠命令补全
|
||||
completeCommands(buffer, candidates);
|
||||
}
|
||||
}
|
||||
|
||||
/** 补全斜杠命令 */
|
||||
private void completeCommands(String buffer, List<Candidate> candidates) {
|
||||
String prefix = buffer.substring(1).toLowerCase();
|
||||
|
||||
for (SlashCommand cmd : commandRegistry.getCommands()) {
|
||||
String name = cmd.name();
|
||||
if (name.startsWith(prefix)) {
|
||||
candidates.add(new Candidate(
|
||||
"/" + name, // 补全值
|
||||
name, // 显示文本
|
||||
"Commands", // 分组
|
||||
cmd.description(), // 描述(右侧提示)
|
||||
null, // 后缀
|
||||
null, // 关键字
|
||||
true // 完整补全
|
||||
));
|
||||
}
|
||||
// 也匹配别名
|
||||
for (String alias : cmd.aliases()) {
|
||||
if (alias.startsWith(prefix)) {
|
||||
candidates.add(new Candidate(
|
||||
"/" + alias,
|
||||
alias + " → " + name,
|
||||
"Aliases",
|
||||
cmd.description(),
|
||||
null, null, true
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,17 +5,32 @@ import com.claudecode.command.CommandRegistry;
|
||||
import com.claudecode.console.*;
|
||||
import com.claudecode.core.AgentLoop;
|
||||
import com.claudecode.tool.ToolRegistry;
|
||||
import org.jline.reader.*;
|
||||
import org.jline.reader.impl.DefaultParser;
|
||||
import org.jline.terminal.Terminal;
|
||||
import org.jline.terminal.TerminalBuilder;
|
||||
import org.jline.utils.AttributedStringBuilder;
|
||||
import org.jline.utils.AttributedStyle;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.PrintStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Scanner;
|
||||
|
||||
/**
|
||||
* REPL 会话管理器 —— 对应 claude-code/src/REPL.tsx。
|
||||
* <p>
|
||||
* 管理用户输入循环、命令分发、Agent 调用和输出渲染。
|
||||
* 当前版本使用 Scanner 作为输入方式(Phase 2 会升级到 JLine)。
|
||||
* 使用 JLine 3 提供丰富的终端交互体验:
|
||||
* <ul>
|
||||
* <li>行编辑(光标移动、删除、粘贴)</li>
|
||||
* <li>历史记录(上下箭头浏览、持久化到文件)</li>
|
||||
* <li>Tab 补全(斜杠命令、工具名称)</li>
|
||||
* <li>信号处理(Ctrl+C 取消当前输入、Ctrl+D 退出)</li>
|
||||
* </ul>
|
||||
* 当 JLine 初始化失败时自动降级到 Scanner 模式。
|
||||
*/
|
||||
public class ReplSession {
|
||||
|
||||
@@ -42,16 +57,18 @@ public class ReplSession {
|
||||
this.markdownRenderer = new MarkdownRenderer(out);
|
||||
this.spinner = new SpinnerAnimation(out);
|
||||
|
||||
// 注册 AgentLoop 事件回调
|
||||
setupAgentCallbacks();
|
||||
}
|
||||
|
||||
/** 注册 AgentLoop 事件回调,驱动控制台 UI 渲染 */
|
||||
private void setupAgentCallbacks() {
|
||||
agentLoop.setOnToolEvent(event -> {
|
||||
switch (event.phase()) {
|
||||
case START -> {
|
||||
spinner.stop();
|
||||
toolStatusRenderer.renderStart(event.toolName(), event.arguments());
|
||||
}
|
||||
case END -> {
|
||||
toolStatusRenderer.renderEnd(event.toolName(), event.result());
|
||||
}
|
||||
case END -> toolStatusRenderer.renderEnd(event.toolName(), event.result());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -61,64 +78,151 @@ public class ReplSession {
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 REPL 主循环。
|
||||
* 启动 REPL —— 优先使用 JLine,失败时降级到 Scanner。
|
||||
*/
|
||||
public void start() {
|
||||
try {
|
||||
startWithJLine();
|
||||
} catch (Exception e) {
|
||||
log.warn("JLine 初始化失败,降级到 Scanner 模式: {}", e.getMessage());
|
||||
startWithScanner();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== JLine 模式 ====================
|
||||
|
||||
private void startWithJLine() throws IOException {
|
||||
Path historyDir = Path.of(System.getProperty("user.home"), ".claude-code-java");
|
||||
Files.createDirectories(historyDir);
|
||||
|
||||
try (Terminal terminal = TerminalBuilder.builder()
|
||||
.system(true)
|
||||
.build()) {
|
||||
|
||||
LineReader reader = LineReaderBuilder.builder()
|
||||
.terminal(terminal)
|
||||
.parser(new DefaultParser())
|
||||
.completer(new ClaudeCodeCompleter(commandRegistry, toolRegistry))
|
||||
.variable(LineReader.HISTORY_FILE, historyDir.resolve("history"))
|
||||
.variable(LineReader.HISTORY_SIZE, 1000)
|
||||
.option(LineReader.Option.CASE_INSENSITIVE, true)
|
||||
.option(LineReader.Option.AUTO_LIST, true)
|
||||
.build();
|
||||
|
||||
// 构建彩色提示符
|
||||
String prompt = new AttributedStringBuilder()
|
||||
.style(AttributedStyle.BOLD.foreground(AttributedStyle.CYAN))
|
||||
.append("❯ ")
|
||||
.style(AttributedStyle.DEFAULT)
|
||||
.toAnsi(terminal);
|
||||
|
||||
// 续行提示符(多行输入时显示)
|
||||
String rightPrompt = new AttributedStringBuilder()
|
||||
.style(AttributedStyle.DEFAULT.foreground(AttributedStyle.BRIGHT))
|
||||
.append("")
|
||||
.toAnsi(terminal);
|
||||
|
||||
printBanner(terminal);
|
||||
|
||||
CommandContext cmdContext = new CommandContext(agentLoop, toolRegistry, out, () -> running = false);
|
||||
|
||||
while (running) {
|
||||
String input;
|
||||
try {
|
||||
input = reader.readLine(prompt).strip();
|
||||
} catch (UserInterruptException e) {
|
||||
// Ctrl+C —— 取消当前输入,继续等待
|
||||
spinner.stop();
|
||||
out.println(AnsiStyle.dim(" ^C"));
|
||||
continue;
|
||||
} catch (EndOfFileException e) {
|
||||
// Ctrl+D —— 退出
|
||||
break;
|
||||
}
|
||||
|
||||
if (input.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
handleInput(input, cmdContext);
|
||||
}
|
||||
|
||||
out.println(AnsiStyle.dim("\n Goodbye! 👋\n"));
|
||||
}
|
||||
}
|
||||
|
||||
/** 打印启动 Banner(JLine 模式) */
|
||||
private void printBanner(Terminal terminal) {
|
||||
BannerPrinter.printCompact(out);
|
||||
out.println(AnsiStyle.dim(" Working directory: " + System.getProperty("user.dir")));
|
||||
out.println(AnsiStyle.dim(" Tools: " + toolRegistry.size() + " registered"));
|
||||
out.println(AnsiStyle.dim(" Terminal: " + terminal.getType()
|
||||
+ " (" + terminal.getWidth() + "×" + terminal.getHeight() + ")"));
|
||||
out.println(AnsiStyle.dim(" Tip: Tab to complete commands, ↑↓ to browse history, Ctrl+D to exit"));
|
||||
out.println();
|
||||
}
|
||||
|
||||
// ==================== Scanner 降级模式 ====================
|
||||
|
||||
private void startWithScanner() {
|
||||
BannerPrinter.printCompact(out);
|
||||
out.println(AnsiStyle.dim(" Working directory: " + System.getProperty("user.dir")));
|
||||
out.println(AnsiStyle.dim(" Tools: " + toolRegistry.size() + " registered"));
|
||||
out.println(AnsiStyle.dim(" Mode: Scanner (basic input)"));
|
||||
out.println();
|
||||
|
||||
Scanner scanner = new Scanner(System.in);
|
||||
CommandContext cmdContext = new CommandContext(agentLoop, toolRegistry, out, () -> running = false);
|
||||
|
||||
while (running) {
|
||||
// 输入提示符
|
||||
out.print(AnsiStyle.BOLD + AnsiStyle.BRIGHT_CYAN + " ❯ " + AnsiStyle.RESET);
|
||||
out.print(AnsiStyle.BOLD + AnsiStyle.BRIGHT_CYAN + "❯ " + AnsiStyle.RESET);
|
||||
out.flush();
|
||||
|
||||
String input;
|
||||
try {
|
||||
if (!scanner.hasNextLine()) {
|
||||
break; // EOF (Ctrl+D)
|
||||
}
|
||||
if (!scanner.hasNextLine()) break;
|
||||
input = scanner.nextLine().strip();
|
||||
} catch (Exception e) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (input.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (input.isEmpty()) continue;
|
||||
|
||||
// 检查斜杠命令
|
||||
if (commandRegistry.isCommand(input)) {
|
||||
var result = commandRegistry.dispatch(input, cmdContext);
|
||||
result.ifPresent(out::println);
|
||||
out.println();
|
||||
continue;
|
||||
}
|
||||
|
||||
// 调用 Agent 循环
|
||||
try {
|
||||
spinner.start("Thinking...");
|
||||
String response = agentLoop.run(input);
|
||||
spinner.stop();
|
||||
|
||||
out.println();
|
||||
markdownRenderer.render(response);
|
||||
out.println();
|
||||
} catch (Exception e) {
|
||||
spinner.stop();
|
||||
out.println(AnsiStyle.red("\n ✗ Error: " + e.getMessage()));
|
||||
log.error("Agent 循环异常", e);
|
||||
out.println();
|
||||
}
|
||||
handleInput(input, cmdContext);
|
||||
}
|
||||
|
||||
out.println(AnsiStyle.dim("\n Goodbye! 👋\n"));
|
||||
}
|
||||
|
||||
// ==================== 公共输入处理 ====================
|
||||
|
||||
/** 处理用户输入(命令分发或 Agent 调用) */
|
||||
private void handleInput(String input, CommandContext cmdContext) {
|
||||
// 斜杠命令
|
||||
if (commandRegistry.isCommand(input)) {
|
||||
var result = commandRegistry.dispatch(input, cmdContext);
|
||||
result.ifPresent(out::println);
|
||||
out.println();
|
||||
return;
|
||||
}
|
||||
|
||||
// Agent 循环
|
||||
try {
|
||||
spinner.start("Thinking...");
|
||||
String response = agentLoop.run(input);
|
||||
spinner.stop();
|
||||
|
||||
out.println();
|
||||
markdownRenderer.render(response);
|
||||
out.println();
|
||||
} catch (Exception e) {
|
||||
spinner.stop();
|
||||
out.println(AnsiStyle.red("\n ✗ Error: " + e.getMessage()));
|
||||
log.error("Agent 循环异常", e);
|
||||
out.println();
|
||||
}
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
running = false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user