From e98cd3b19ccdb48ad3bb7a477638b202140fc7c6 Mon Sep 17 00:00:00 2001 From: Tomi Eckert Date: Fri, 6 Mar 2026 01:38:55 +0100 Subject: [PATCH] feat: Add commands and functionality to save and load chat sessions. --- .gitignore | 3 ++- AppJsonContext.cs | 2 ++ ChatSession.cs | 35 ++++++++++++++++++++++++++++++++++ Commands/LoadCommand.cs | 31 ++++++++++++++++++++++++++++++ Commands/SaveCommand.cs | 31 ++++++++++++++++++++++++++++++ Program.cs | 42 +++++++++++++++++++++++++++++++++-------- 6 files changed, 135 insertions(+), 9 deletions(-) create mode 100644 Commands/LoadCommand.cs create mode 100644 Commands/SaveCommand.cs diff --git a/.gitignore b/.gitignore index 503d513..385e776 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ bin obj .vscode -publish \ No newline at end of file +publish +.anchor \ No newline at end of file diff --git a/AppJsonContext.cs b/AppJsonContext.cs index 39f19c8..6271390 100644 --- a/AppJsonContext.cs +++ b/AppJsonContext.cs @@ -16,6 +16,8 @@ namespace AnchorCli; [JsonSerializable(typeof(ModelsResponse))] [JsonSerializable(typeof(ModelInfo))] [JsonSerializable(typeof(ModelPricing))] +[JsonSerializable(typeof(Microsoft.Extensions.AI.ChatMessage))] +[JsonSerializable(typeof(System.Collections.Generic.List))] [JsonSerializable(typeof(AnchorConfig))] [JsonSourceGenerationOptions( DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, diff --git a/ChatSession.cs b/ChatSession.cs index fee75fb..58dca26 100644 --- a/ChatSession.cs +++ b/ChatSession.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.AI; +using System.Text.Json; namespace AnchorCli; @@ -69,4 +70,38 @@ internal sealed class ChatSession yield return update; } } + + public async Task SaveAsync(string filePath, CancellationToken cancellationToken = default) + { + // Skip the system message when saving (it will be recreated on load) + var messagesToSave = History.Skip(1).ToList(); + + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + + var json = JsonSerializer.Serialize(messagesToSave, AppJsonContext.Default.ListChatMessage); + await File.WriteAllTextAsync(filePath, json, cancellationToken); + } + + public async Task LoadAsync(string filePath, CancellationToken cancellationToken = default) + { + var json = await File.ReadAllTextAsync(filePath, cancellationToken); + + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + var messages = JsonSerializer.Deserialize>(json, AppJsonContext.Default.ListChatMessage) + ?? new List(); + + // Keep the system message and append loaded messages + var systemMessage = History[0]; + History.Clear(); + History.Add(systemMessage); + History.AddRange(messages); + } } diff --git a/Commands/LoadCommand.cs b/Commands/LoadCommand.cs new file mode 100644 index 0000000..2375a88 --- /dev/null +++ b/Commands/LoadCommand.cs @@ -0,0 +1,31 @@ +using Spectre.Console; + +namespace AnchorCli.Commands; + +internal class LoadCommand(ChatSession session) : ICommand +{ + public string Name => "load"; + public string Description => "Load a chat session from a file"; + + public async Task ExecuteAsync(string[] args, CancellationToken ct) + { + string filePath = args.Length > 0 ? args[0] : ".anchor/session.json"; + + if (!File.Exists(filePath)) + { + AnsiConsole.MarkupLine($"[yellow]No session file found at {Markup.Escape(filePath)}[/]"); + return; + } + + try + { + await session.LoadAsync(filePath, ct); + AnsiConsole.MarkupLine($"[green]Session loaded from {Markup.Escape(filePath)}[/]"); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]Failed to load session: {Markup.Escape(ex.Message)}[/]"); + } + } +} + diff --git a/Commands/SaveCommand.cs b/Commands/SaveCommand.cs new file mode 100644 index 0000000..e94e61e --- /dev/null +++ b/Commands/SaveCommand.cs @@ -0,0 +1,31 @@ +using Spectre.Console; + +namespace AnchorCli.Commands; + +internal class SaveCommand(ChatSession session) : ICommand +{ + public string Name => "save"; + public string Description => "Save the current chat session to a file"; + + public async Task ExecuteAsync(string[] args, CancellationToken ct) + { + string filePath = args.Length > 0 ? args[0] : ".anchor/session.json"; + + try + { + var directory = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + await session.SaveAsync(filePath, ct); + AnsiConsole.MarkupLine($"[green]Session saved to {Markup.Escape(filePath)}[/]"); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]Failed to save session: {Markup.Escape(ex.Message)}[/]"); + } + } +} + diff --git a/Program.cs b/Program.cs index 85a7819..26738ed 100644 --- a/Program.cs +++ b/Program.cs @@ -81,13 +81,13 @@ infoTable.AddRow("[grey]CWD[/]", $"[green]{Markup.Escape(Environment.CurrentDire if (modelInfo?.Pricing != null) -if (modelInfo?.Pricing != null) -{ - var inM = tokenTracker.InputPrice * 1_000_000m; - var outM = tokenTracker.OutputPrice * 1_000_000m; - infoTable.AddRow("[grey]Pricing[/]", - $"[yellow]${inM:F2}[/][dim]/M in[/] [yellow]${outM:F2}[/][dim]/M out[/]"); -} + if (modelInfo?.Pricing != null) + { + var inM = tokenTracker.InputPrice * 1_000_000m; + var outM = tokenTracker.OutputPrice * 1_000_000m; + infoTable.AddRow("[grey]Pricing[/]", + $"[yellow]${inM:F2}[/][dim]/M in[/] [yellow]${outM:F2}[/][dim]/M out[/]"); + } if (modelInfo != null) { infoTable.AddRow("[grey]Context[/]", @@ -110,7 +110,7 @@ var openAiClient = new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClie IChatClient innerClient = openAiClient.GetChatClient(model).AsIChatClient(); // ── Tool call logging via Spectre ─────────────────────────────────────── -object consoleLock = new object(); +object consoleLock = new(); void ToolLog(string message) { @@ -142,12 +142,38 @@ commandRegistry.Register(new StatusCommand(model, endpoint)); commandRegistry.Register(new CompactCommand(session.Compactor, session.History)); commandRegistry.Register(new SetupCommand()); commandRegistry.Register(new ResetCommand(session, tokenTracker)); +commandRegistry.Register(new SaveCommand(session)); +commandRegistry.Register(new LoadCommand(session)); var commandDispatcher = new CommandDispatcher(commandRegistry); // ── Run Repl ──────────────────────────────────────────────────────────── +// Auto-load session if it exists +const string sessionPath = ".anchor/session.json"; +if (File.Exists(sessionPath)) +{ + try + { + await session.LoadAsync(sessionPath, default); + AnsiConsole.MarkupLine($"[dim grey]Auto-loaded previous session.[/]"); + } + catch { /* Ignore load errors on startup */ } +} + var repl = new ReplLoop(session, tokenTracker, commandDispatcher); await repl.RunAsync(); +// Auto-save session on clean exit +try +{ + var directory = Path.GetDirectoryName(sessionPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + await session.SaveAsync(sessionPath, default); +} +catch { /* Ignore save errors on exit */ } +