1
0

feat: Add commands and functionality to save and load chat sessions.

This commit is contained in:
2026-03-06 01:38:55 +01:00
parent 50414e8b8c
commit e98cd3b19c
6 changed files with 135 additions and 9 deletions

3
.gitignore vendored
View File

@@ -1,4 +1,5 @@
bin bin
obj obj
.vscode .vscode
publish publish
.anchor

View File

@@ -16,6 +16,8 @@ namespace AnchorCli;
[JsonSerializable(typeof(ModelsResponse))] [JsonSerializable(typeof(ModelsResponse))]
[JsonSerializable(typeof(ModelInfo))] [JsonSerializable(typeof(ModelInfo))]
[JsonSerializable(typeof(ModelPricing))] [JsonSerializable(typeof(ModelPricing))]
[JsonSerializable(typeof(Microsoft.Extensions.AI.ChatMessage))]
[JsonSerializable(typeof(System.Collections.Generic.List<Microsoft.Extensions.AI.ChatMessage>))]
[JsonSerializable(typeof(AnchorConfig))] [JsonSerializable(typeof(AnchorConfig))]
[JsonSourceGenerationOptions( [JsonSourceGenerationOptions(
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,

View File

@@ -1,4 +1,5 @@
using Microsoft.Extensions.AI; using Microsoft.Extensions.AI;
using System.Text.Json;
namespace AnchorCli; namespace AnchorCli;
@@ -69,4 +70,38 @@ internal sealed class ChatSession
yield return update; 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<List<ChatMessage>>(json, AppJsonContext.Default.ListChatMessage)
?? new List<ChatMessage>();
// Keep the system message and append loaded messages
var systemMessage = History[0];
History.Clear();
History.Add(systemMessage);
History.AddRange(messages);
}
} }

31
Commands/LoadCommand.cs Normal file
View File

@@ -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)}[/]");
}
}
}

31
Commands/SaveCommand.cs Normal file
View File

@@ -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)}[/]");
}
}
}

View File

@@ -81,13 +81,13 @@ infoTable.AddRow("[grey]CWD[/]", $"[green]{Markup.Escape(Environment.CurrentDire
if (modelInfo?.Pricing != null) if (modelInfo?.Pricing != null)
if (modelInfo?.Pricing != null) if (modelInfo?.Pricing != null)
{ {
var inM = tokenTracker.InputPrice * 1_000_000m; var inM = tokenTracker.InputPrice * 1_000_000m;
var outM = tokenTracker.OutputPrice * 1_000_000m; var outM = tokenTracker.OutputPrice * 1_000_000m;
infoTable.AddRow("[grey]Pricing[/]", infoTable.AddRow("[grey]Pricing[/]",
$"[yellow]${inM:F2}[/][dim]/M in[/] [yellow]${outM:F2}[/][dim]/M out[/]"); $"[yellow]${inM:F2}[/][dim]/M in[/] [yellow]${outM:F2}[/][dim]/M out[/]");
} }
if (modelInfo != null) if (modelInfo != null)
{ {
infoTable.AddRow("[grey]Context[/]", infoTable.AddRow("[grey]Context[/]",
@@ -110,7 +110,7 @@ var openAiClient = new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClie
IChatClient innerClient = openAiClient.GetChatClient(model).AsIChatClient(); IChatClient innerClient = openAiClient.GetChatClient(model).AsIChatClient();
// ── Tool call logging via Spectre ─────────────────────────────────────── // ── Tool call logging via Spectre ───────────────────────────────────────
object consoleLock = new object(); object consoleLock = new();
void ToolLog(string message) 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 CompactCommand(session.Compactor, session.History));
commandRegistry.Register(new SetupCommand()); commandRegistry.Register(new SetupCommand());
commandRegistry.Register(new ResetCommand(session, tokenTracker)); commandRegistry.Register(new ResetCommand(session, tokenTracker));
commandRegistry.Register(new SaveCommand(session));
commandRegistry.Register(new LoadCommand(session));
var commandDispatcher = new CommandDispatcher(commandRegistry); var commandDispatcher = new CommandDispatcher(commandRegistry);
// ── Run Repl ──────────────────────────────────────────────────────────── // ── 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); var repl = new ReplLoop(session, tokenTracker, commandDispatcher);
await repl.RunAsync(); 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 */ }