diff --git a/src/main/java/com/claudecode/command/impl/HistoryCommand.java b/src/main/java/com/claudecode/command/impl/HistoryCommand.java
new file mode 100644
index 0000000..23b51d8
--- /dev/null
+++ b/src/main/java/com/claudecode/command/impl/HistoryCommand.java
@@ -0,0 +1,56 @@
+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;
+
+/**
+ * /history 命令 —— 列出保存的对话历史。
+ *
+ * 显示最近的对话记录,包括时间、摘要和消息数量。
+ */
+public class HistoryCommand implements SlashCommand {
+
+ @Override
+ public String name() {
+ return "/history";
+ }
+
+ @Override
+ public String description() {
+ return "列出保存的对话历史";
+ }
+
+ @Override
+ public String execute(String args, CommandContext context) {
+ ConversationPersistence persistence = new ConversationPersistence();
+ var conversations = persistence.listConversations();
+
+ if (conversations.isEmpty()) {
+ return AnsiStyle.dim(" 📂 暂无保存的对话历史");
+ }
+
+ StringBuilder sb = new StringBuilder();
+ sb.append(AnsiStyle.bold(" 📂 对话历史") + AnsiStyle.dim(" (")
+ + conversations.size() + AnsiStyle.dim(" 条记录)\n"));
+ sb.append(AnsiStyle.dim(" " + "─".repeat(50)) + "\n");
+
+ int shown = Math.min(conversations.size(), 10);
+ for (int i = 0; i < shown; i++) {
+ var conv = conversations.get(i);
+ sb.append(" " + AnsiStyle.cyan((i + 1) + ".") + " ");
+ sb.append(AnsiStyle.bold(conv.summary()));
+ sb.append(AnsiStyle.dim(" (" + conv.messageCount() + " messages)") + "\n");
+ sb.append(" " + AnsiStyle.dim(conv.savedAt() + " | " + conv.workingDir()) + "\n");
+ }
+
+ if (conversations.size() > 10) {
+ sb.append(AnsiStyle.dim(" ... 还有 " + (conversations.size() - 10) + " 条更早的记录\n"));
+ }
+
+ sb.append(AnsiStyle.dim("\n 对话存储位置: " + persistence.getConversationsDir()));
+
+ return sb.toString();
+ }
+}
diff --git a/src/main/java/com/claudecode/config/AppConfig.java b/src/main/java/com/claudecode/config/AppConfig.java
index 8d2b396..a072bb0 100644
--- a/src/main/java/com/claudecode/config/AppConfig.java
+++ b/src/main/java/com/claudecode/config/AppConfig.java
@@ -74,6 +74,7 @@ public class AppConfig {
new ContextCommand(),
new InitCommand(),
new ConfigCommand(),
+ new HistoryCommand(),
new ExitCommand()
);
return registry;
diff --git a/src/main/java/com/claudecode/core/ConversationPersistence.java b/src/main/java/com/claudecode/core/ConversationPersistence.java
new file mode 100644
index 0000000..6ce7fee
--- /dev/null
+++ b/src/main/java/com/claudecode/core/ConversationPersistence.java
@@ -0,0 +1,246 @@
+package com.claudecode.core;
+
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.ai.chat.messages.*;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.*;
+import java.util.stream.Stream;
+
+/**
+ * 对话历史持久化 —— 对应 claude-code 的会话持久化机制。
+ *
+ * 将对话消息序列化为 JSON 存储到 ~/.claude-code-java/conversations/ 目录。
+ * 支持保存、加载和列出历史对话。
+ *
+ * 存储格式:每条消息序列化为简化的 JSON 对象,包含角色、内容和工具调用信息。
+ */
+public class ConversationPersistence {
+
+ private static final Logger log = LoggerFactory.getLogger(ConversationPersistence.class);
+ private static final ObjectMapper MAPPER = new ObjectMapper()
+ .enable(SerializationFeature.INDENT_OUTPUT);
+ private static final DateTimeFormatter FILE_DATE_FMT = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss");
+
+ private final Path conversationsDir;
+
+ public ConversationPersistence() {
+ this(Path.of(System.getProperty("user.home"), ".claude-code-java", "conversations"));
+ }
+
+ public ConversationPersistence(Path conversationsDir) {
+ this.conversationsDir = conversationsDir;
+ try {
+ Files.createDirectories(conversationsDir);
+ } catch (IOException e) {
+ log.warn("无法创建对话存储目录: {}", e.getMessage());
+ }
+ }
+
+ /**
+ * 保存对话历史到文件。
+ *
+ * @param messages 消息列表
+ * @param summary 对话摘要(用于文件名/列表显示)
+ * @return 保存的文件路径,失败返回 null
+ */
+ public Path save(List messages, String summary) {
+ if (messages == null || messages.isEmpty()) return null;
+
+ String timestamp = FILE_DATE_FMT.format(LocalDateTime.now());
+ String safeSummary = sanitizeFilename(summary);
+ String filename = timestamp + "_" + safeSummary + ".json";
+ Path file = conversationsDir.resolve(filename);
+
+ try {
+ List records = messages.stream()
+ .map(this::toRecord)
+ .filter(Objects::nonNull)
+ .toList();
+
+ ConversationFile conv = new ConversationFile(
+ LocalDateTime.now().toString(),
+ summary,
+ System.getProperty("user.dir"),
+ records
+ );
+
+ MAPPER.writeValue(file.toFile(), conv);
+ log.info("对话已保存: {}", file.getFileName());
+ return file;
+ } catch (IOException e) {
+ log.error("保存对话失败: {}", e.getMessage());
+ return null;
+ }
+ }
+
+ /**
+ * 加载最近一次对话历史。
+ *
+ * @return 消息列表,无历史时返回空列表
+ */
+ public List loadLatest() {
+ Path latest = findLatestFile();
+ if (latest == null) return List.of();
+ return loadFromFile(latest);
+ }
+
+ /**
+ * 从指定文件加载对话历史。
+ */
+ public List loadFromFile(Path file) {
+ try {
+ ConversationFile conv = MAPPER.readValue(file.toFile(), ConversationFile.class);
+ return conv.messages().stream()
+ .map(this::fromRecord)
+ .filter(Objects::nonNull)
+ .toList();
+ } catch (IOException e) {
+ log.error("加载对话失败: {}", e.getMessage());
+ return List.of();
+ }
+ }
+
+ /**
+ * 列出所有保存的对话(按时间倒序)。
+ */
+ public List listConversations() {
+ List summaries = new ArrayList<>();
+
+ try (Stream paths = Files.list(conversationsDir)) {
+ paths.filter(p -> p.toString().endsWith(".json"))
+ .sorted(Comparator.reverseOrder())
+ .forEach(file -> {
+ try {
+ ConversationFile conv = MAPPER.readValue(file.toFile(), ConversationFile.class);
+ summaries.add(new ConversationSummary(
+ file.getFileName().toString(),
+ conv.summary(),
+ conv.savedAt(),
+ conv.workingDir(),
+ conv.messages().size()
+ ));
+ } catch (IOException e) {
+ log.debug("跳过无效对话文件: {}", file.getFileName());
+ }
+ });
+ } catch (IOException e) {
+ log.warn("列出对话失败: {}", e.getMessage());
+ }
+
+ return summaries;
+ }
+
+ /** 获取对话存储目录 */
+ public Path getConversationsDir() {
+ return conversationsDir;
+ }
+
+ // ==================== 序列化/反序列化 ====================
+
+ private MessageRecord toRecord(Message msg) {
+ return switch (msg) {
+ case SystemMessage sm -> new MessageRecord("system", sm.getText(), null, null);
+ case UserMessage um -> new MessageRecord("user", um.getText(), null, null);
+ case AssistantMessage am -> {
+ List toolCalls = null;
+ if (am.hasToolCalls()) {
+ toolCalls = am.getToolCalls().stream()
+ .map(tc -> new ToolCallRecord(tc.id(), tc.name(), tc.arguments()))
+ .toList();
+ }
+ yield new MessageRecord("assistant", am.getText(), toolCalls, null);
+ }
+ case ToolResponseMessage trm -> {
+ List responses = trm.getResponses().stream()
+ .map(tr -> new ToolResponseRecord(tr.id(), tr.name(), tr.responseData()))
+ .toList();
+ yield new MessageRecord("tool_response", null, null, responses);
+ }
+ default -> null;
+ };
+ }
+
+ private Message fromRecord(MessageRecord record) {
+ return switch (record.role()) {
+ case "system" -> new SystemMessage(record.content() != null ? record.content() : "");
+ case "user" -> new UserMessage(record.content() != null ? record.content() : "");
+ case "assistant" -> {
+ if (record.toolCalls() != null && !record.toolCalls().isEmpty()) {
+ List toolCalls = record.toolCalls().stream()
+ .map(tc -> new AssistantMessage.ToolCall(tc.id(), "function", tc.name(), tc.arguments()))
+ .toList();
+ yield AssistantMessage.builder()
+ .content(record.content() != null ? record.content() : "")
+ .toolCalls(toolCalls)
+ .build();
+ }
+ yield new AssistantMessage(record.content() != null ? record.content() : "");
+ }
+ case "tool_response" -> {
+ if (record.toolResponses() != null) {
+ List responses = record.toolResponses().stream()
+ .map(tr -> new ToolResponseMessage.ToolResponse(tr.id(), tr.name(), tr.data()))
+ .toList();
+ yield ToolResponseMessage.builder().responses(responses).build();
+ }
+ yield null;
+ }
+ default -> null;
+ };
+ }
+
+ private Path findLatestFile() {
+ try (Stream paths = Files.list(conversationsDir)) {
+ return paths.filter(p -> p.toString().endsWith(".json"))
+ .max(Comparator.naturalOrder())
+ .orElse(null);
+ } catch (IOException e) {
+ return null;
+ }
+ }
+
+ private String sanitizeFilename(String name) {
+ if (name == null || name.isBlank()) return "conversation";
+ return name.replaceAll("[^a-zA-Z0-9\\u4e00-\\u9fa5_-]", "_")
+ .substring(0, Math.min(name.length(), 40));
+ }
+
+ // ==================== JSON 数据结构 ====================
+
+ public record ConversationFile(
+ String savedAt,
+ String summary,
+ String workingDir,
+ List messages
+ ) {}
+
+ public record MessageRecord(
+ String role,
+ String content,
+ List toolCalls,
+ List toolResponses
+ ) {}
+
+ public record ToolCallRecord(String id, String name, String arguments) {}
+ public record ToolResponseRecord(String id, String name, String data) {}
+
+ /** 对话摘要(用于列表显示) */
+ public record ConversationSummary(
+ String filename,
+ String summary,
+ String savedAt,
+ String workingDir,
+ int messageCount
+ ) {}
+}
diff --git a/src/main/java/com/claudecode/repl/ReplSession.java b/src/main/java/com/claudecode/repl/ReplSession.java
index e3d9be0..9354e9c 100644
--- a/src/main/java/com/claudecode/repl/ReplSession.java
+++ b/src/main/java/com/claudecode/repl/ReplSession.java
@@ -5,6 +5,7 @@ import com.claudecode.command.CommandContext;
import com.claudecode.command.CommandRegistry;
import com.claudecode.console.*;
import com.claudecode.core.AgentLoop;
+import com.claudecode.core.ConversationPersistence;
import com.claudecode.tool.ToolRegistry;
import org.jline.reader.*;
import org.jline.reader.impl.DefaultParser;
@@ -42,11 +43,14 @@ public class ReplSession {
private final ToolRegistry toolRegistry;
private final CommandRegistry commandRegistry;
private final ProviderInfo providerInfo;
+ private final ConversationPersistence persistence;
private final PrintStream out;
private final ToolStatusRenderer toolStatusRenderer;
private final MarkdownRenderer markdownRenderer;
private final SpinnerAnimation spinner;
+ /** 对话摘要(取第一次用户输入的前40字) */
+ private String conversationSummary = "";
private volatile boolean running = true;
public ReplSession(AgentLoop agentLoop,
@@ -57,6 +61,7 @@ public class ReplSession {
this.toolRegistry = toolRegistry;
this.commandRegistry = commandRegistry;
this.providerInfo = providerInfo;
+ this.persistence = new ConversationPersistence();
// 强制使用 UTF-8 编码输出,确保 emoji 等 Unicode 字符在 Windows 终端正常显示
this.out = new PrintStream(System.out, true, StandardCharsets.UTF_8);
this.toolStatusRenderer = new ToolStatusRenderer(out);
@@ -163,6 +168,7 @@ public class ReplSession {
handleInput(input, cmdContext);
}
+ saveConversation();
out.println(AnsiStyle.dim("\n Goodbye! 👋\n"));
}
}
@@ -229,11 +235,10 @@ public class ReplSession {
handleInput(input, cmdContext);
}
+ saveConversation();
out.println(AnsiStyle.dim("\n Goodbye! 👋\n"));
}
- // ==================== 公共输入处理 ====================
-
/** 处理用户输入(命令分发或 Agent 调用) */
private void handleInput(String input, CommandContext cmdContext) {
// 斜杠命令
@@ -244,6 +249,11 @@ public class ReplSession {
return;
}
+ // 记录对话摘要(取第一次用户输入前40字)
+ if (conversationSummary.isEmpty()) {
+ conversationSummary = input.length() > 40 ? input.substring(0, 40) : input;
+ }
+
// Agent 循环(流式输出)
try {
spinner.start("Thinking...");
@@ -266,6 +276,23 @@ public class ReplSession {
}
}
+ /** 退出时保存对话历史 */
+ private void saveConversation() {
+ var history = agentLoop.getMessageHistory();
+ // 只有有实际对话内容时才保存(至少包含系统提示+用户消息+助手回复)
+ if (history.size() > 2) {
+ var file = persistence.save(history, conversationSummary);
+ if (file != null) {
+ out.println(AnsiStyle.dim(" 💾 对话已保存: " + file.getFileName()));
+ }
+ }
+ }
+
+ /** 获取对话持久化管理器 */
+ public ConversationPersistence getPersistence() {
+ return persistence;
+ }
+
public void stop() {
running = false;
}