feat: P1功能增强 - 3命令+代码高亮+状态行
新增3个命令: - /resume: 恢复已保存的对话(支持list/序号选择) - /export: 导出对话为Markdown文件 - /commit: 创建Git commit(支持AI生成commit message) 代码语法高亮(MarkdownRenderer增强): - 支持Java/JS/TS/Python/Bash/SQL关键字着色 - 字符串字面量黄色、数字紫色、注释灰色斜体 - 注解(@Annotation)亮黄色 - true/false/null 红色 - 新增引用块、有序列表、复选框、链接渲染 - 代码块边框对齐 底部状态行(StatusLine): - 模型名、Token用量、费用、API调用次数、工作目录 - 非dumb终端自动启用 - 每次Agent循环后刷新显示 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,174 @@
|
|||||||
|
package com.claudecode.command.impl;
|
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext;
|
||||||
|
import com.claudecode.command.SlashCommand;
|
||||||
|
import com.claudecode.console.AnsiStyle;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /commit 命令 —— 创建 Git commit。
|
||||||
|
* <p>
|
||||||
|
* 支持多种模式:
|
||||||
|
* <ul>
|
||||||
|
* <li>/commit —— 自动生成 AI commit message(基于 git diff)</li>
|
||||||
|
* <li>/commit [message] —— 使用指定的 commit message</li>
|
||||||
|
* <li>/commit --all —— 添加所有文件并提交</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public class CommitCommand implements SlashCommand {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "commit";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() {
|
||||||
|
return "Create a git commit (with optional AI-generated message)";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(String args, CommandContext context) {
|
||||||
|
Path projectDir = Path.of(System.getProperty("user.dir"));
|
||||||
|
if (!Files.isDirectory(projectDir.resolve(".git"))) {
|
||||||
|
return AnsiStyle.yellow(" ⚠ 当前目录不是 Git 仓库");
|
||||||
|
}
|
||||||
|
|
||||||
|
args = args == null ? "" : args.strip();
|
||||||
|
|
||||||
|
try {
|
||||||
|
boolean addAll = args.contains("--all") || args.contains("-a");
|
||||||
|
String message = args.replaceAll("--all|-a", "").strip();
|
||||||
|
|
||||||
|
// --all 模式:先执行 git add -A
|
||||||
|
if (addAll) {
|
||||||
|
String addResult = runGit(projectDir, "add", "-A");
|
||||||
|
if (addResult == null) {
|
||||||
|
return AnsiStyle.red(" ✗ git add 失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有已暂存的变更
|
||||||
|
String staged = runGit(projectDir, "diff", "--cached", "--stat");
|
||||||
|
if (staged == null || staged.isBlank()) {
|
||||||
|
String status = runGit(projectDir, "status", "--short");
|
||||||
|
if (status != null && !status.isBlank()) {
|
||||||
|
return AnsiStyle.yellow(" ⚠ 没有已暂存的变更\n")
|
||||||
|
+ AnsiStyle.dim(" 使用 /commit --all 自动添加所有文件\n")
|
||||||
|
+ AnsiStyle.dim(" 或先手动执行 git add");
|
||||||
|
}
|
||||||
|
return AnsiStyle.green(" ✓ 工作区干净,无需提交");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有指定 message,使用 AI 生成
|
||||||
|
if (message.isEmpty()) {
|
||||||
|
message = generateCommitMessage(projectDir, context);
|
||||||
|
if (message == null || message.isBlank()) {
|
||||||
|
return AnsiStyle.red(" ✗ 无法生成 commit message");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行 git commit
|
||||||
|
String commitResult = runGit(projectDir, "commit", "-m", message);
|
||||||
|
if (commitResult == null) {
|
||||||
|
return AnsiStyle.red(" ✗ git commit 失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("\n").append(AnsiStyle.green(" ✓ Commit 成功\n"));
|
||||||
|
sb.append(" ").append("─".repeat(50)).append("\n");
|
||||||
|
sb.append(" ").append(AnsiStyle.bold("Message: ")).append(message).append("\n");
|
||||||
|
|
||||||
|
// 显示提交摘要
|
||||||
|
commitResult.lines().forEach(line -> sb.append(" ").append(AnsiStyle.dim(line)).append("\n"));
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
return AnsiStyle.red(" ✗ 提交失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 使用 AI 分析 git diff 生成 commit message */
|
||||||
|
private String generateCommitMessage(Path projectDir, CommandContext context) {
|
||||||
|
try {
|
||||||
|
// 获取暂存区的 diff
|
||||||
|
String diff = runGit(projectDir, "diff", "--cached");
|
||||||
|
if (diff == null || diff.isBlank()) return null;
|
||||||
|
|
||||||
|
// 截断过长的 diff
|
||||||
|
if (diff.length() > 4000) {
|
||||||
|
diff = diff.substring(0, 4000) + "\n... (diff truncated)";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 ChatModel 生成 commit message
|
||||||
|
String prompt = """
|
||||||
|
分析以下 git diff,生成一个简洁的 commit message。
|
||||||
|
要求:
|
||||||
|
1. 使用 conventional commits 格式(feat/fix/docs/refactor/chore等前缀)
|
||||||
|
2. 第一行不超过 72 个字符
|
||||||
|
3. 如果有多个变更,可以在第一行后空一行添加详细说明
|
||||||
|
4. 只返回 commit message 文本,不要添加其他说明
|
||||||
|
|
||||||
|
Git diff:
|
||||||
|
```
|
||||||
|
%s
|
||||||
|
```
|
||||||
|
""".formatted(diff);
|
||||||
|
|
||||||
|
var chatModel = context.agentLoop().getChatModel();
|
||||||
|
var response = chatModel.call(
|
||||||
|
new org.springframework.ai.chat.prompt.Prompt(prompt));
|
||||||
|
|
||||||
|
String generated = response.getResult().getOutput().getText();
|
||||||
|
if (generated != null) {
|
||||||
|
// 清理:去除可能的引号和多余空行
|
||||||
|
generated = generated.strip()
|
||||||
|
.replaceAll("^[\"'`]+|[\"'`]+$", "")
|
||||||
|
.strip();
|
||||||
|
}
|
||||||
|
return generated;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
// AI 生成失败时返回默认消息
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String runGit(Path dir, String... args) {
|
||||||
|
try {
|
||||||
|
var command = new java.util.ArrayList<String>();
|
||||||
|
command.add("git");
|
||||||
|
command.add("--no-pager");
|
||||||
|
command.addAll(java.util.List.of(args));
|
||||||
|
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(command);
|
||||||
|
pb.directory(dir.toFile());
|
||||||
|
pb.redirectErrorStream(true);
|
||||||
|
|
||||||
|
Process process = pb.start();
|
||||||
|
StringBuilder output = new StringBuilder();
|
||||||
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
output.append(line).append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean finished = process.waitFor(30, TimeUnit.SECONDS);
|
||||||
|
if (!finished) {
|
||||||
|
process.destroyForcibly();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.toString().stripTrailing();
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package com.claudecode.command.impl;
|
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext;
|
||||||
|
import com.claudecode.command.SlashCommand;
|
||||||
|
import com.claudecode.console.AnsiStyle;
|
||||||
|
import org.springframework.ai.chat.messages.*;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /export 命令 —— 将对话历史导出为 Markdown 文件。
|
||||||
|
* <p>
|
||||||
|
* 支持格式:
|
||||||
|
* <ul>
|
||||||
|
* <li>/export —— 导出到当前目录的 conversation-时间戳.md</li>
|
||||||
|
* <li>/export [路径] —— 导出到指定路径</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public class ExportCommand implements SlashCommand {
|
||||||
|
|
||||||
|
private static final DateTimeFormatter TIMESTAMP_FMT = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss");
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "export";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() {
|
||||||
|
return "Export conversation to Markdown file";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(String args, CommandContext context) {
|
||||||
|
List<Message> history = context.agentLoop().getMessageHistory();
|
||||||
|
|
||||||
|
// 至少需要系统提示 + 用户消息 + 助手回复
|
||||||
|
if (history.size() < 3) {
|
||||||
|
return AnsiStyle.yellow(" ⚠ 暂无足够的对话内容可导出");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确定输出路径
|
||||||
|
args = args == null ? "" : args.strip();
|
||||||
|
Path outputPath;
|
||||||
|
if (!args.isEmpty()) {
|
||||||
|
outputPath = Path.of(args);
|
||||||
|
if (!outputPath.isAbsolute()) {
|
||||||
|
outputPath = Path.of(System.getProperty("user.dir")).resolve(outputPath);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String timestamp = TIMESTAMP_FMT.format(LocalDateTime.now());
|
||||||
|
outputPath = Path.of(System.getProperty("user.dir"), "conversation-" + timestamp + ".md");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 Markdown 内容
|
||||||
|
String markdown = generateMarkdown(history);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Files.createDirectories(outputPath.getParent());
|
||||||
|
Files.writeString(outputPath, markdown, StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
int msgCount = history.size();
|
||||||
|
int lineCount = (int) markdown.lines().count();
|
||||||
|
return AnsiStyle.green(" ✓ 对话已导出: " + outputPath)
|
||||||
|
+ AnsiStyle.dim(" (" + msgCount + " messages, " + lineCount + " lines)");
|
||||||
|
} catch (IOException e) {
|
||||||
|
return AnsiStyle.red(" ✗ 导出失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateMarkdown(List<Message> history) {
|
||||||
|
StringBuilder md = new StringBuilder();
|
||||||
|
md.append("# Claude Code Java - Conversation Export\n\n");
|
||||||
|
md.append("- **Exported at**: ").append(LocalDateTime.now()).append("\n");
|
||||||
|
md.append("- **Working directory**: ").append(System.getProperty("user.dir")).append("\n");
|
||||||
|
md.append("- **Messages**: ").append(history.size()).append("\n\n");
|
||||||
|
md.append("---\n\n");
|
||||||
|
|
||||||
|
for (Message msg : history) {
|
||||||
|
switch (msg) {
|
||||||
|
case SystemMessage sm -> {
|
||||||
|
md.append("## 🔧 System Prompt\n\n");
|
||||||
|
md.append("<details>\n<summary>Click to expand system prompt</summary>\n\n");
|
||||||
|
md.append(sm.getText()).append("\n\n");
|
||||||
|
md.append("</details>\n\n---\n\n");
|
||||||
|
}
|
||||||
|
case UserMessage um -> {
|
||||||
|
md.append("## 👤 User\n\n");
|
||||||
|
md.append(um.getText()).append("\n\n");
|
||||||
|
}
|
||||||
|
case AssistantMessage am -> {
|
||||||
|
md.append("## 🤖 Assistant\n\n");
|
||||||
|
if (am.getText() != null && !am.getText().isBlank()) {
|
||||||
|
md.append(am.getText()).append("\n\n");
|
||||||
|
}
|
||||||
|
if (am.hasToolCalls()) {
|
||||||
|
md.append("### Tool Calls\n\n");
|
||||||
|
for (var tc : am.getToolCalls()) {
|
||||||
|
md.append("- **").append(tc.name()).append("**");
|
||||||
|
if (tc.arguments() != null) {
|
||||||
|
md.append("\n ```json\n ").append(tc.arguments()).append("\n ```");
|
||||||
|
}
|
||||||
|
md.append("\n");
|
||||||
|
}
|
||||||
|
md.append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case ToolResponseMessage trm -> {
|
||||||
|
md.append("### 🔨 Tool Results\n\n");
|
||||||
|
for (var resp : trm.getResponses()) {
|
||||||
|
md.append("**").append(resp.name()).append("**:\n");
|
||||||
|
String data = resp.responseData();
|
||||||
|
if (data != null) {
|
||||||
|
// 截断过长的工具输出
|
||||||
|
if (data.length() > 2000) {
|
||||||
|
data = data.substring(0, 2000) + "\n... (truncated)";
|
||||||
|
}
|
||||||
|
md.append("```\n").append(data).append("\n```\n\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return md.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package com.claudecode.command.impl;
|
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext;
|
||||||
|
import com.claudecode.command.SlashCommand;
|
||||||
|
import com.claudecode.console.AnsiStyle;
|
||||||
|
import com.claudecode.core.ConversationPersistence;
|
||||||
|
import com.claudecode.core.ConversationPersistence.ConversationSummary;
|
||||||
|
import org.springframework.ai.chat.messages.Message;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /resume 命令 —— 恢复之前保存的对话。
|
||||||
|
* <p>
|
||||||
|
* 从 ~/.claude-code-java/conversations/ 加载对话历史,
|
||||||
|
* 替换当前消息历史,恢复之前的上下文。
|
||||||
|
* <ul>
|
||||||
|
* <li>/resume —— 恢复最近一次对话</li>
|
||||||
|
* <li>/resume list —— 列出可恢复的对话</li>
|
||||||
|
* <li>/resume [序号] —— 恢复指定序号的对话</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public class ResumeCommand implements SlashCommand {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "resume";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() {
|
||||||
|
return "Resume a saved conversation";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(String args, CommandContext context) {
|
||||||
|
ConversationPersistence persistence = new ConversationPersistence();
|
||||||
|
List<ConversationSummary> conversations = persistence.listConversations();
|
||||||
|
|
||||||
|
args = args == null ? "" : args.strip();
|
||||||
|
|
||||||
|
if (conversations.isEmpty()) {
|
||||||
|
return AnsiStyle.yellow(" ⚠ 没有已保存的对话\n")
|
||||||
|
+ AnsiStyle.dim(" 对话在退出时自动保存到 ~/.claude-code-java/conversations/");
|
||||||
|
}
|
||||||
|
|
||||||
|
// /resume list —— 列出所有对话
|
||||||
|
if (args.equals("list")) {
|
||||||
|
return formatConversationList(conversations);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确定要恢复的对话索引
|
||||||
|
int index = 0; // 默认最近一个
|
||||||
|
if (!args.isEmpty()) {
|
||||||
|
try {
|
||||||
|
index = Integer.parseInt(args) - 1;
|
||||||
|
if (index < 0 || index >= conversations.size()) {
|
||||||
|
return AnsiStyle.red(" ✗ 无效序号(范围 1-" + conversations.size() + ")");
|
||||||
|
}
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return AnsiStyle.yellow(" ⚠ 用法: /resume [序号] 或 /resume list");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载并恢复对话
|
||||||
|
ConversationSummary summary = conversations.get(index);
|
||||||
|
Path file = persistence.getConversationsDir().resolve(summary.filename());
|
||||||
|
List<Message> messages = persistence.loadFromFile(file);
|
||||||
|
|
||||||
|
if (messages.isEmpty()) {
|
||||||
|
return AnsiStyle.red(" ✗ 加载对话失败: " + summary.filename());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 替换当前消息历史
|
||||||
|
context.agentLoop().replaceHistory(messages);
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("\n");
|
||||||
|
sb.append(AnsiStyle.green(" ✓ 对话已恢复\n"));
|
||||||
|
sb.append(" ").append("─".repeat(50)).append("\n");
|
||||||
|
sb.append(" ").append(AnsiStyle.bold("摘要: ")).append(summary.summary()).append("\n");
|
||||||
|
sb.append(" ").append(AnsiStyle.bold("时间: ")).append(summary.savedAt()).append("\n");
|
||||||
|
sb.append(" ").append(AnsiStyle.bold("消息数: ")).append(summary.messageCount()).append("\n");
|
||||||
|
sb.append(" ").append(AnsiStyle.bold("目录: ")).append(AnsiStyle.dim(summary.workingDir())).append("\n");
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatConversationList(List<ConversationSummary> conversations) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("\n");
|
||||||
|
sb.append(AnsiStyle.bold(" 📂 Saved Conversations\n"));
|
||||||
|
sb.append(" ").append("─".repeat(50)).append("\n\n");
|
||||||
|
|
||||||
|
int maxShow = Math.min(conversations.size(), 20);
|
||||||
|
for (int i = 0; i < maxShow; i++) {
|
||||||
|
ConversationSummary conv = conversations.get(i);
|
||||||
|
sb.append(" ").append(AnsiStyle.cyan(String.format("%2d", i + 1))).append(". ");
|
||||||
|
sb.append(AnsiStyle.bold(conv.summary())).append("\n");
|
||||||
|
sb.append(" ").append(AnsiStyle.dim(conv.savedAt()))
|
||||||
|
.append(AnsiStyle.dim(" | " + conv.messageCount() + " messages"))
|
||||||
|
.append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conversations.size() > maxShow) {
|
||||||
|
sb.append(AnsiStyle.dim("\n ... 还有 " + (conversations.size() - maxShow) + " 个对话\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append(AnsiStyle.dim("\n 使用 /resume [序号] 恢复指定对话\n"));
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,6 +82,9 @@ public class AppConfig {
|
|||||||
new SkillsCommand(),
|
new SkillsCommand(),
|
||||||
new MemoryCommand(),
|
new MemoryCommand(),
|
||||||
new CopyCommand(),
|
new CopyCommand(),
|
||||||
|
new ResumeCommand(),
|
||||||
|
new ExportCommand(),
|
||||||
|
new CommitCommand(),
|
||||||
new ExitCommand()
|
new ExitCommand()
|
||||||
);
|
);
|
||||||
return registry;
|
return registry;
|
||||||
|
|||||||
@@ -1,17 +1,82 @@
|
|||||||
package com.claudecode.console;
|
package com.claudecode.console;
|
||||||
|
|
||||||
import java.io.PrintStream;
|
import java.io.PrintStream;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Markdown 简易渲染器 —— 对应 claude-code/src/renderers/markdown.ts。
|
* Markdown 渲染器(增强版) —— 对应 claude-code/src/renderers/markdown.ts。
|
||||||
* <p>
|
* <p>
|
||||||
* 将 AI 回复中的 Markdown 格式转换为终端 ANSI 样式输出。
|
* 将 AI 回复中的 Markdown 格式转换为终端 ANSI 样式输出。
|
||||||
* 这是一个简化版,支持常见格式。
|
* 支持代码块语法高亮、有序列表、引用块、表格等。
|
||||||
*/
|
*/
|
||||||
public class MarkdownRenderer {
|
public class MarkdownRenderer {
|
||||||
|
|
||||||
private final PrintStream out;
|
private final PrintStream out;
|
||||||
|
|
||||||
|
// 各语言的关键字集合,用于代码高亮
|
||||||
|
private static final Set<String> JAVA_KEYWORDS = Set.of(
|
||||||
|
"abstract", "assert", "boolean", "break", "byte", "case", "catch", "char",
|
||||||
|
"class", "const", "continue", "default", "do", "double", "else", "enum",
|
||||||
|
"extends", "final", "finally", "float", "for", "goto", "if", "implements",
|
||||||
|
"import", "instanceof", "int", "interface", "long", "native", "new", "package",
|
||||||
|
"private", "protected", "public", "return", "short", "static", "strictfp",
|
||||||
|
"super", "switch", "synchronized", "this", "throw", "throws", "transient",
|
||||||
|
"try", "void", "volatile", "while", "var", "record", "sealed", "permits",
|
||||||
|
"yield", "when");
|
||||||
|
|
||||||
|
private static final Set<String> JS_KEYWORDS = Set.of(
|
||||||
|
"async", "await", "break", "case", "catch", "class", "const", "continue",
|
||||||
|
"debugger", "default", "delete", "do", "else", "export", "extends", "false",
|
||||||
|
"finally", "for", "from", "function", "if", "import", "in", "instanceof",
|
||||||
|
"let", "new", "null", "of", "return", "super", "switch", "this", "throw",
|
||||||
|
"true", "try", "typeof", "undefined", "var", "void", "while", "with", "yield");
|
||||||
|
|
||||||
|
private static final Set<String> PYTHON_KEYWORDS = Set.of(
|
||||||
|
"and", "as", "assert", "async", "await", "break", "class", "continue",
|
||||||
|
"def", "del", "elif", "else", "except", "False", "finally", "for", "from",
|
||||||
|
"global", "if", "import", "in", "is", "lambda", "None", "nonlocal", "not",
|
||||||
|
"or", "pass", "raise", "return", "True", "try", "while", "with", "yield");
|
||||||
|
|
||||||
|
private static final Set<String> SHELL_KEYWORDS = Set.of(
|
||||||
|
"if", "then", "else", "elif", "fi", "for", "while", "do", "done", "case",
|
||||||
|
"esac", "function", "return", "exit", "echo", "export", "source", "set",
|
||||||
|
"unset", "local", "readonly", "declare", "cd", "pwd", "ls", "cat", "grep",
|
||||||
|
"sed", "awk", "find", "mkdir", "rm", "cp", "mv", "chmod", "chown");
|
||||||
|
|
||||||
|
private static final Set<String> SQL_KEYWORDS = Set.of(
|
||||||
|
"SELECT", "FROM", "WHERE", "INSERT", "INTO", "VALUES", "UPDATE", "SET",
|
||||||
|
"DELETE", "CREATE", "TABLE", "ALTER", "DROP", "INDEX", "JOIN", "LEFT",
|
||||||
|
"RIGHT", "INNER", "OUTER", "ON", "AND", "OR", "NOT", "NULL", "IS",
|
||||||
|
"IN", "LIKE", "BETWEEN", "ORDER", "BY", "GROUP", "HAVING", "LIMIT",
|
||||||
|
"OFFSET", "AS", "DISTINCT", "COUNT", "SUM", "AVG", "MAX", "MIN");
|
||||||
|
|
||||||
|
/** 语言到关键字集的映射 */
|
||||||
|
private static final Map<String, Set<String>> LANG_KEYWORDS;
|
||||||
|
static {
|
||||||
|
var map = new java.util.HashMap<String, Set<String>>();
|
||||||
|
map.put("java", JAVA_KEYWORDS);
|
||||||
|
map.put("javascript", JS_KEYWORDS);
|
||||||
|
map.put("js", JS_KEYWORDS);
|
||||||
|
map.put("typescript", JS_KEYWORDS);
|
||||||
|
map.put("ts", JS_KEYWORDS);
|
||||||
|
map.put("python", PYTHON_KEYWORDS);
|
||||||
|
map.put("py", PYTHON_KEYWORDS);
|
||||||
|
map.put("bash", SHELL_KEYWORDS);
|
||||||
|
map.put("sh", SHELL_KEYWORDS);
|
||||||
|
map.put("shell", SHELL_KEYWORDS);
|
||||||
|
map.put("sql", SQL_KEYWORDS);
|
||||||
|
LANG_KEYWORDS = Map.copyOf(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 高亮用的正则
|
||||||
|
private static final Pattern STRING_PATTERN = Pattern.compile("(\"[^\"\\\\]*(\\\\.[^\"\\\\]*)*\"|'[^'\\\\]*(\\\\.[^'\\\\]*)*')");
|
||||||
|
private static final Pattern NUMBER_PATTERN = Pattern.compile("\\b(\\d+\\.?\\d*[fFdDlL]?|0x[0-9a-fA-F]+)\\b");
|
||||||
|
private static final Pattern SINGLE_LINE_COMMENT = Pattern.compile("(//.*|#.*)$");
|
||||||
|
private static final Pattern ANNOTATION_PATTERN = Pattern.compile("(@\\w+)");
|
||||||
|
|
||||||
public MarkdownRenderer(PrintStream out) {
|
public MarkdownRenderer(PrintStream out) {
|
||||||
this.out = out;
|
this.out = out;
|
||||||
}
|
}
|
||||||
@@ -27,19 +92,21 @@ public class MarkdownRenderer {
|
|||||||
// 代码块
|
// 代码块
|
||||||
if (line.stripLeading().startsWith("```")) {
|
if (line.stripLeading().startsWith("```")) {
|
||||||
if (!inCodeBlock) {
|
if (!inCodeBlock) {
|
||||||
codeBlockLang = line.stripLeading().substring(3).strip();
|
codeBlockLang = line.stripLeading().substring(3).strip().toLowerCase();
|
||||||
inCodeBlock = true;
|
inCodeBlock = true;
|
||||||
out.println(AnsiStyle.dim(" ┌─" + (codeBlockLang.isEmpty() ? "code" : codeBlockLang) + "─"));
|
String langLabel = codeBlockLang.isEmpty() ? "code" : codeBlockLang;
|
||||||
|
out.println(AnsiStyle.dim(" ┌─" + langLabel + "─" + "─".repeat(Math.max(0, 40 - langLabel.length()))));
|
||||||
continue;
|
continue;
|
||||||
} else {
|
} else {
|
||||||
inCodeBlock = false;
|
inCodeBlock = false;
|
||||||
out.println(AnsiStyle.dim(" └─────"));
|
out.println(AnsiStyle.dim(" └" + "─".repeat(42)));
|
||||||
|
codeBlockLang = "";
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inCodeBlock) {
|
if (inCodeBlock) {
|
||||||
out.println(AnsiStyle.BRIGHT_GREEN + " │ " + line + AnsiStyle.RESET);
|
out.println(" " + AnsiStyle.DIM + "│" + AnsiStyle.RESET + " " + highlightCode(line, codeBlockLang));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,13 +118,38 @@ public class MarkdownRenderer {
|
|||||||
} else if (line.startsWith("# ")) {
|
} else if (line.startsWith("# ")) {
|
||||||
out.println(AnsiStyle.bold(AnsiStyle.MAGENTA + " " + line.substring(2)) + AnsiStyle.RESET);
|
out.println(AnsiStyle.bold(AnsiStyle.MAGENTA + " " + line.substring(2)) + AnsiStyle.RESET);
|
||||||
}
|
}
|
||||||
// 列表项
|
// 引用块
|
||||||
|
else if (line.stripLeading().startsWith("> ")) {
|
||||||
|
String quoteText = line.stripLeading().substring(2);
|
||||||
|
out.println(" " + AnsiStyle.DIM + "┃" + AnsiStyle.RESET + " " + AnsiStyle.ITALIC + renderInline(quoteText) + AnsiStyle.RESET);
|
||||||
|
}
|
||||||
|
// 有序列表
|
||||||
|
else if (line.stripLeading().matches("^\\d+\\.\\s+.*")) {
|
||||||
|
Matcher m = Pattern.compile("^(\\s*)(\\d+)\\.\\s+(.*)").matcher(line);
|
||||||
|
if (m.matches()) {
|
||||||
|
String indent = m.group(1);
|
||||||
|
String num = m.group(2);
|
||||||
|
String text = m.group(3);
|
||||||
|
out.println(" " + indent + AnsiStyle.CYAN + num + "." + AnsiStyle.RESET + " " + renderInline(text));
|
||||||
|
} else {
|
||||||
|
out.println(" " + renderInline(line));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 无序列表
|
||||||
else if (line.stripLeading().startsWith("- ") || line.stripLeading().startsWith("* ")) {
|
else if (line.stripLeading().startsWith("- ") || line.stripLeading().startsWith("* ")) {
|
||||||
out.println(" " + AnsiStyle.CYAN + "•" + AnsiStyle.RESET + " " + renderInline(line.stripLeading().substring(2)));
|
int indent = line.length() - line.stripLeading().length();
|
||||||
|
String prefix = " ".repeat(indent);
|
||||||
|
out.println(" " + prefix + AnsiStyle.CYAN + "•" + AnsiStyle.RESET + " " + renderInline(line.stripLeading().substring(2)));
|
||||||
|
}
|
||||||
|
// 复选框列表
|
||||||
|
else if (line.stripLeading().startsWith("- [ ] ")) {
|
||||||
|
out.println(" " + AnsiStyle.DIM + "☐" + AnsiStyle.RESET + " " + renderInline(line.stripLeading().substring(6)));
|
||||||
|
} else if (line.stripLeading().startsWith("- [x] ") || line.stripLeading().startsWith("- [X] ")) {
|
||||||
|
out.println(" " + AnsiStyle.GREEN + "☑" + AnsiStyle.RESET + " " + renderInline(line.stripLeading().substring(6)));
|
||||||
}
|
}
|
||||||
// 分隔线
|
// 分隔线
|
||||||
else if (line.strip().matches("^-{3,}$") || line.strip().matches("^\\*{3,}$")) {
|
else if (line.strip().matches("^[-*]{3,}$")) {
|
||||||
out.println(AnsiStyle.dim(" ─────────────────────────────────────────"));
|
out.println(AnsiStyle.dim(" " + "─".repeat(42)));
|
||||||
}
|
}
|
||||||
// 普通文本
|
// 普通文本
|
||||||
else {
|
else {
|
||||||
@@ -66,14 +158,113 @@ public class MarkdownRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 代码语法高亮 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于语言的代码行高亮。
|
||||||
|
* 着色优先级:注释 > 字符串 > 注解 > 关键字 > 数字。
|
||||||
|
*/
|
||||||
|
private String highlightCode(String line, String lang) {
|
||||||
|
if (lang.isEmpty() || !LANG_KEYWORDS.containsKey(lang)) {
|
||||||
|
// 未知语言:仅着绿色
|
||||||
|
return AnsiStyle.BRIGHT_GREEN + line + AnsiStyle.RESET;
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> keywords = LANG_KEYWORDS.get(lang);
|
||||||
|
StringBuilder result = new StringBuilder();
|
||||||
|
|
||||||
|
// 简单的逐行高亮:先检测注释和字符串区间,再对非特殊区间着色关键字
|
||||||
|
// 为简化实现,采用分段替换策略
|
||||||
|
|
||||||
|
String processed = line;
|
||||||
|
|
||||||
|
// 1. 注释(行末 // 或 #)—— 灰色斜体
|
||||||
|
Matcher commentMatcher = SINGLE_LINE_COMMENT.matcher(processed);
|
||||||
|
if (commentMatcher.find()) {
|
||||||
|
String beforeComment = processed.substring(0, commentMatcher.start());
|
||||||
|
String comment = commentMatcher.group();
|
||||||
|
return highlightNonComment(beforeComment, keywords, lang)
|
||||||
|
+ AnsiStyle.BRIGHT_BLACK + AnsiStyle.ITALIC + comment + AnsiStyle.RESET;
|
||||||
|
}
|
||||||
|
|
||||||
|
return highlightNonComment(processed, keywords, lang);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对非注释部分进行高亮 */
|
||||||
|
private String highlightNonComment(String code, Set<String> keywords, String lang) {
|
||||||
|
// 用占位符保护字符串字面量
|
||||||
|
var stringRanges = new java.util.ArrayList<int[]>();
|
||||||
|
Matcher strMatcher = STRING_PATTERN.matcher(code);
|
||||||
|
while (strMatcher.find()) {
|
||||||
|
stringRanges.add(new int[]{strMatcher.start(), strMatcher.end()});
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder result = new StringBuilder();
|
||||||
|
int pos = 0;
|
||||||
|
|
||||||
|
for (int[] range : stringRanges) {
|
||||||
|
// 高亮字符串之前的部分
|
||||||
|
if (range[0] > pos) {
|
||||||
|
result.append(highlightSegment(code.substring(pos, range[0]), keywords, lang));
|
||||||
|
}
|
||||||
|
// 字符串本身着黄色
|
||||||
|
result.append(AnsiStyle.YELLOW).append(code, range[0], range[1]).append(AnsiStyle.RESET);
|
||||||
|
pos = range[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最后一段
|
||||||
|
if (pos < code.length()) {
|
||||||
|
result.append(highlightSegment(code.substring(pos), keywords, lang));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对普通代码段(无字符串)进行关键字和数字高亮 */
|
||||||
|
private String highlightSegment(String segment, Set<String> keywords, String lang) {
|
||||||
|
// 注解(@Annotation)— 仅 Java/Python
|
||||||
|
if (lang.equals("java") || lang.equals("python") || lang.equals("py")) {
|
||||||
|
Matcher annMatcher = ANNOTATION_PATTERN.matcher(segment);
|
||||||
|
segment = annMatcher.replaceAll(AnsiStyle.BRIGHT_YELLOW + "$1" + AnsiStyle.RESET);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关键字着色 — 使用 word boundary 匹配
|
||||||
|
for (String kw : keywords) {
|
||||||
|
// SQL 关键字大小写不敏感
|
||||||
|
if (lang.equals("sql")) {
|
||||||
|
segment = segment.replaceAll("(?i)\\b(" + Pattern.quote(kw) + ")\\b",
|
||||||
|
AnsiStyle.BRIGHT_CYAN + "$1" + AnsiStyle.RESET);
|
||||||
|
} else {
|
||||||
|
segment = segment.replaceAll("\\b(" + Pattern.quote(kw) + ")\\b",
|
||||||
|
AnsiStyle.BRIGHT_CYAN + "$1" + AnsiStyle.RESET);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数字着色
|
||||||
|
Matcher numMatcher = NUMBER_PATTERN.matcher(segment);
|
||||||
|
segment = numMatcher.replaceAll(AnsiStyle.BRIGHT_MAGENTA + "$1" + AnsiStyle.RESET);
|
||||||
|
|
||||||
|
// true/false/null 着色
|
||||||
|
segment = segment.replaceAll("\\b(true|false|null|None|nil)\\b",
|
||||||
|
AnsiStyle.BRIGHT_RED + "$1" + AnsiStyle.RESET);
|
||||||
|
|
||||||
|
return segment;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 行内格式 ====================
|
||||||
|
|
||||||
/** 行内格式渲染 */
|
/** 行内格式渲染 */
|
||||||
private String renderInline(String text) {
|
private String renderInline(String text) {
|
||||||
// 粗体 **text**
|
// 粗体 **text**
|
||||||
text = text.replaceAll("\\*\\*(.+?)\\*\\*", AnsiStyle.BOLD + "$1" + AnsiStyle.RESET);
|
text = text.replaceAll("\\*\\*(.+?)\\*\\*", AnsiStyle.BOLD + "$1" + AnsiStyle.RESET);
|
||||||
// 行内代码 `text`
|
// 行内代码 `text`
|
||||||
text = text.replaceAll("`(.+?)`", AnsiStyle.BRIGHT_GREEN + "$1" + AnsiStyle.RESET);
|
text = text.replaceAll("`(.+?)`", AnsiStyle.BRIGHT_GREEN + "$1" + AnsiStyle.RESET);
|
||||||
// 斜体 *text*
|
// 斜体 *text*(需避免匹配粗体中的 *)
|
||||||
text = text.replaceAll("\\*(.+?)\\*", AnsiStyle.ITALIC + "$1" + AnsiStyle.RESET);
|
text = text.replaceAll("(?<!\\*)\\*([^*]+?)\\*(?!\\*)", AnsiStyle.ITALIC + "$1" + AnsiStyle.RESET);
|
||||||
|
// 删除线 ~~text~~
|
||||||
|
text = text.replaceAll("~~(.+?)~~", AnsiStyle.DIM + "$1" + AnsiStyle.RESET);
|
||||||
|
// 链接 [text](url) → text (url)
|
||||||
|
text = text.replaceAll("\\[(.+?)]\\((.+?)\\)", AnsiStyle.UNDERLINE + "$1" + AnsiStyle.RESET + AnsiStyle.DIM + " ($2)" + AnsiStyle.RESET);
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package com.claudecode.console;
|
||||||
|
|
||||||
|
import com.claudecode.core.TokenTracker;
|
||||||
|
|
||||||
|
import java.io.PrintStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 底部状态行渲染器 —— 对应 claude-code 的 StatusLine 组件。
|
||||||
|
* <p>
|
||||||
|
* 在终端底部持续显示:模型名、Token 用量/费用、工作目录等状态信息。
|
||||||
|
* 使用 ANSI 转义序列控制光标位置,在每次输出后刷新状态行。
|
||||||
|
* <p>
|
||||||
|
* 注意:仅在非 dumb 终端下启用,dumb 终端不支持光标控制。
|
||||||
|
*/
|
||||||
|
public class StatusLine {
|
||||||
|
|
||||||
|
private final PrintStream out;
|
||||||
|
private volatile boolean enabled = false;
|
||||||
|
private volatile String modelName = "";
|
||||||
|
private volatile TokenTracker tokenTracker;
|
||||||
|
private volatile String workDir = "";
|
||||||
|
|
||||||
|
public StatusLine(PrintStream out) {
|
||||||
|
this.out = out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启用状态行 */
|
||||||
|
public void enable(String model, TokenTracker tracker) {
|
||||||
|
this.modelName = model;
|
||||||
|
this.tokenTracker = tracker;
|
||||||
|
this.workDir = abbreviatePath(System.getProperty("user.dir"));
|
||||||
|
this.enabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 禁用状态行 */
|
||||||
|
public void disable() {
|
||||||
|
this.enabled = false;
|
||||||
|
clearStatusLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新底部状态行显示。
|
||||||
|
* <p>
|
||||||
|
* 使用 ANSI 转义序列:
|
||||||
|
* - 保存光标位置
|
||||||
|
* - 移动到屏幕底部
|
||||||
|
* - 输出状态信息
|
||||||
|
* - 恢复光标位置
|
||||||
|
*/
|
||||||
|
public void refresh() {
|
||||||
|
if (!enabled || tokenTracker == null) return;
|
||||||
|
|
||||||
|
String status = buildStatusText();
|
||||||
|
|
||||||
|
// 保存光标 → 移到最后一行 → 清行 → 写状态 → 恢复光标
|
||||||
|
out.print("\033[s"); // 保存光标
|
||||||
|
out.print("\033[999;1H"); // 移到最后一行
|
||||||
|
out.print("\033[2K"); // 清除该行
|
||||||
|
out.print(status);
|
||||||
|
out.print("\033[u"); // 恢复光标
|
||||||
|
out.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染一行式状态摘要(不使用光标控制,适合在提示符之前显示)。
|
||||||
|
* 这是一种更安全的替代方案,不会干扰终端滚动。
|
||||||
|
*/
|
||||||
|
public String renderInline() {
|
||||||
|
if (!enabled || tokenTracker == null) return "";
|
||||||
|
return buildStatusText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildStatusText() {
|
||||||
|
long inputTokens = tokenTracker.getInputTokens();
|
||||||
|
long outputTokens = tokenTracker.getOutputTokens();
|
||||||
|
double cost = tokenTracker.estimateCost();
|
||||||
|
long apiCalls = tokenTracker.getApiCallCount();
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
|
||||||
|
// 反色背景的状态栏
|
||||||
|
sb.append(AnsiStyle.DIM);
|
||||||
|
|
||||||
|
// 模型名
|
||||||
|
sb.append(" ").append(modelName);
|
||||||
|
|
||||||
|
// Token 用量
|
||||||
|
sb.append(" │ ↑").append(TokenTracker.formatTokens(inputTokens));
|
||||||
|
sb.append(" ↓").append(TokenTracker.formatTokens(outputTokens));
|
||||||
|
|
||||||
|
// 费用
|
||||||
|
if (cost > 0) {
|
||||||
|
sb.append(String.format(" $%.4f", cost));
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 调用次数
|
||||||
|
sb.append(" │ ").append(apiCalls).append(" calls");
|
||||||
|
|
||||||
|
// 工作目录
|
||||||
|
sb.append(" │ ").append(workDir);
|
||||||
|
|
||||||
|
sb.append(AnsiStyle.RESET);
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清除状态行 */
|
||||||
|
private void clearStatusLine() {
|
||||||
|
out.print("\033[s\033[999;1H\033[2K\033[u");
|
||||||
|
out.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 缩写路径:将 home 目录替换为 ~ */
|
||||||
|
private String abbreviatePath(String path) {
|
||||||
|
if (path == null) return "";
|
||||||
|
String home = System.getProperty("user.home");
|
||||||
|
if (path.startsWith(home)) {
|
||||||
|
return "~" + path.substring(home.length());
|
||||||
|
}
|
||||||
|
// 过长时截断
|
||||||
|
if (path.length() > 40) {
|
||||||
|
return "..." + path.substring(path.length() - 37);
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEnabled() {
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ public class ReplSession {
|
|||||||
private final MarkdownRenderer markdownRenderer;
|
private final MarkdownRenderer markdownRenderer;
|
||||||
private final SpinnerAnimation spinner;
|
private final SpinnerAnimation spinner;
|
||||||
private final ThinkingRenderer thinkingRenderer;
|
private final ThinkingRenderer thinkingRenderer;
|
||||||
|
private final StatusLine statusLine;
|
||||||
|
|
||||||
/** 对话摘要(取第一次用户输入的前40字) */
|
/** 对话摘要(取第一次用户输入的前40字) */
|
||||||
private String conversationSummary = "";
|
private String conversationSummary = "";
|
||||||
@@ -76,6 +77,7 @@ public class ReplSession {
|
|||||||
this.markdownRenderer = new MarkdownRenderer(out);
|
this.markdownRenderer = new MarkdownRenderer(out);
|
||||||
this.spinner = new SpinnerAnimation(out);
|
this.spinner = new SpinnerAnimation(out);
|
||||||
this.thinkingRenderer = new ThinkingRenderer(out);
|
this.thinkingRenderer = new ThinkingRenderer(out);
|
||||||
|
this.statusLine = new StatusLine(out);
|
||||||
|
|
||||||
setupAgentCallbacks();
|
setupAgentCallbacks();
|
||||||
setupToolContextCallbacks();
|
setupToolContextCallbacks();
|
||||||
@@ -178,6 +180,12 @@ public class ReplSession {
|
|||||||
// 设置活跃的 reader,供 AskUser 和权限确认使用
|
// 设置活跃的 reader,供 AskUser 和权限确认使用
|
||||||
this.activeReader = reader;
|
this.activeReader = reader;
|
||||||
|
|
||||||
|
// 非 dumb 终端启用底部状态行
|
||||||
|
boolean isDumb2 = "dumb".equals(terminal.getType());
|
||||||
|
if (!isDumb2) {
|
||||||
|
statusLine.enable(providerInfo.model(), agentLoop.getTokenTracker());
|
||||||
|
}
|
||||||
|
|
||||||
CommandContext cmdContext = new CommandContext(agentLoop, toolRegistry, out, () -> running = false);
|
CommandContext cmdContext = new CommandContext(agentLoop, toolRegistry, out, () -> running = false);
|
||||||
|
|
||||||
while (running) {
|
while (running) {
|
||||||
@@ -299,6 +307,11 @@ public class ReplSession {
|
|||||||
|
|
||||||
spinner.stop();
|
spinner.stop();
|
||||||
out.println(); // 流式输出结束后换行
|
out.println(); // 流式输出结束后换行
|
||||||
|
|
||||||
|
// 刷新底部状态行(显示最新 token 用量)
|
||||||
|
if (statusLine.isEnabled()) {
|
||||||
|
out.println(statusLine.renderInline());
|
||||||
|
}
|
||||||
out.println();
|
out.println();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
spinner.stop();
|
spinner.stop();
|
||||||
|
|||||||
Reference in New Issue
Block a user