diff --git a/ApplicationStartup.cs b/ApplicationStartup.cs
new file mode 100644
index 0000000..024c2d6
--- /dev/null
+++ b/ApplicationStartup.cs
@@ -0,0 +1,176 @@
+using System.ClientModel;
+using AnchorCli.Commands;
+using AnchorCli.OpenRouter;
+using AnchorCli.Providers;
+using AnchorCli.Tools;
+using Microsoft.Extensions.AI;
+using OpenAI;
+using Spectre.Console;
+
+namespace AnchorCli;
+
+///
+/// Encapsulates application startup logic, including configuration loading,
+/// API client creation, and component initialization.
+///
+internal sealed class ApplicationStartup
+{
+ private readonly string[] _args;
+ private AnchorConfig? _config;
+ private ITokenExtractor? _tokenExtractor;
+ private ModelInfo? _modelInfo;
+ private IChatClient? _chatClient;
+ private TokenTracker? _tokenTracker;
+
+ public ApplicationStartup(string[] args)
+ {
+ _args = args;
+ }
+
+ public AnchorConfig Config => _config ?? throw new InvalidOperationException("Run InitializeAsync first");
+ public string ApiKey => _config?.ApiKey ?? throw new InvalidOperationException("API key not loaded");
+ public string Model => _config?.Model ?? throw new InvalidOperationException("Model not loaded");
+ public string Endpoint => _config?.Endpoint ?? "https://openrouter.ai/api/v1";
+ public string ProviderName => _tokenExtractor?.ProviderName ?? "Unknown";
+ public ITokenExtractor TokenExtractor => _tokenExtractor ?? throw new InvalidOperationException("Token extractor not initialized");
+ public ModelInfo? ModelInfo => _modelInfo;
+ public IChatClient ChatClient => _chatClient ?? throw new InvalidOperationException("Chat client not initialized");
+ public TokenTracker TokenTracker => _tokenTracker ?? throw new InvalidOperationException("Token tracker not initialized");
+
+ ///
+ /// Runs the setup TUI if the "setup" subcommand was passed. Returns true if setup was run.
+ ///
+ public bool HandleSetupSubcommand()
+ {
+ if (_args.Length > 0 && _args[0].Equals("setup", StringComparison.OrdinalIgnoreCase))
+ {
+ SetupTui.Run();
+ return true;
+ }
+ return false;
+ }
+
+ ///
+ /// Initializes the application by loading configuration and creating the chat client.
+ ///
+ public async Task InitializeAsync()
+ {
+ // Load configuration
+ _config = AnchorConfig.Load();
+
+ if (string.IsNullOrWhiteSpace(_config.ApiKey))
+ {
+ AnsiConsole.MarkupLine("[red]No API key configured. Run [bold]anchor setup[/] first.[/]");
+ throw new InvalidOperationException("API key not configured");
+ }
+
+ // Create token extractor
+ _tokenExtractor = ProviderFactory.CreateTokenExtractorForEndpoint(Endpoint);
+
+ // Fetch model pricing (only for OpenRouter)
+ if (ProviderFactory.IsOpenRouter(Endpoint))
+ {
+ await AnsiConsole.Status()
+ .Spinner(Spinner.Known.BouncingBar)
+ .SpinnerStyle(Style.Parse("cornflowerblue"))
+ .StartAsync("Fetching model pricing...", async ctx =>
+ {
+ try
+ {
+ var pricingProvider = new OpenRouterProvider();
+ _modelInfo = await pricingProvider.GetModelInfoAsync(Model);
+ }
+ catch
+ {
+ // Pricing is best-effort
+ }
+ });
+ }
+
+ // Create chat client
+ var httpClient = new HttpClient();
+ OpenRouterHeaders.ApplyTo(httpClient);
+
+ var openAiClient = new OpenAIClient(
+ new ApiKeyCredential(ApiKey),
+ new OpenAIClientOptions
+ {
+ Endpoint = new Uri(Endpoint),
+ Transport = new System.ClientModel.Primitives.HttpClientPipelineTransport(httpClient)
+ });
+
+ _chatClient = openAiClient.GetChatClient(Model).AsIChatClient();
+
+ // Initialize token tracker
+ _tokenTracker = new TokenTracker(new ChatSession(_chatClient))
+ {
+ Provider = _tokenExtractor.ProviderName
+ };
+
+ if (_modelInfo?.Pricing != null)
+ {
+ _tokenTracker.InputPrice = PricingProvider.ParsePrice(_modelInfo.Pricing.Prompt);
+ _tokenTracker.OutputPrice = PricingProvider.ParsePrice(_modelInfo.Pricing.Completion);
+ _tokenTracker.RequestPrice = PricingProvider.ParsePrice(_modelInfo.Pricing.Request);
+ }
+
+ if (_modelInfo != null)
+ {
+ _tokenTracker.ContextLength = _modelInfo.ContextLength;
+ }
+ }
+
+ ///
+ /// Creates a new ChatSession with the initialized chat client.
+ ///
+ public ChatSession CreateSession()
+ {
+ return new ChatSession(ChatClient);
+ }
+
+ ///
+ /// Configures tool logging to use Spectre.Console.
+ ///
+ public void ConfigureToolLogging()
+ {
+ object consoleLock = new();
+
+ void ToolLog(string message)
+ {
+ lock (consoleLock)
+ {
+ Console.Write("\r" + new string(' ', 40) + "\r");
+ AnsiConsole.MarkupLine($"[dim grey]{Markup.Escape(message)}[/]");
+ }
+ }
+
+ CommandTool.Log =
+ DirTools.Log =
+ FileTools.Log =
+ EditTools.Log = ToolLog;
+ }
+
+ ///
+ /// Creates and populates a CommandRegistry with all available commands.
+ ///
+ public CommandRegistry CreateCommandRegistry(ChatSession session)
+ {
+ var registry = new CommandRegistry();
+ registry.Register(new ExitCommand());
+ registry.Register(new HelpCommand(registry));
+ registry.Register(new ClearCommand());
+ registry.Register(new StatusCommand(Model, Endpoint));
+ registry.Register(new CompactCommand(session.Compactor, session.History));
+ registry.Register(new SetupCommand());
+ registry.Register(new ResetCommand(session, TokenTracker));
+ return registry;
+ }
+
+ ///
+ /// Creates a HeaderRenderer with the current configuration.
+ ///
+ public HeaderRenderer CreateHeaderRenderer()
+ {
+ return new HeaderRenderer(Model, Endpoint, ProviderName, _modelInfo, _tokenTracker);
+ }
+}
diff --git a/ContextCompactionService.cs b/ContextCompactionService.cs
new file mode 100644
index 0000000..4db4617
--- /dev/null
+++ b/ContextCompactionService.cs
@@ -0,0 +1,61 @@
+using Microsoft.Extensions.AI;
+using Spectre.Console;
+using AnchorCli.OpenRouter;
+
+namespace AnchorCli;
+
+///
+/// Handles context compaction when the conversation approaches token limits.
+///
+internal sealed class ContextCompactionService
+{
+ private readonly ContextCompactor _compactor;
+ private readonly List _history;
+ private readonly TokenTracker _tokenTracker;
+
+ public ContextCompactionService(
+ ContextCompactor compactor,
+ List history,
+ TokenTracker tokenTracker)
+ {
+ _compactor = compactor;
+ _history = history;
+ _tokenTracker = tokenTracker;
+ }
+
+ ///
+ /// Checks if compaction is needed and performs it if so.
+ /// Returns true if compaction was performed.
+ ///
+ public async Task TryCompactAsync()
+ {
+ if (!_tokenTracker.ShouldCompact())
+ {
+ return false;
+ }
+
+ var pct = _tokenTracker.ContextUsagePercent;
+ AnsiConsole.MarkupLine(
+ $"[yellow]⚠ Context at {pct:F0}% — compacting conversation history...[/]");
+
+ bool compacted = await AnsiConsole.Status()
+ .Spinner(Spinner.Known.BouncingBar)
+ .SpinnerStyle(Style.Parse("yellow"))
+ .StartAsync("Compacting context...", async ctx =>
+ await _compactor.TryCompactAsync(_history, default));
+
+ if (compacted)
+ {
+ AnsiConsole.MarkupLine(
+ $"[green]✓ Context compacted ({_history.Count} messages remaining)[/]");
+ }
+ else
+ {
+ AnsiConsole.MarkupLine(
+ "[dim grey] (compaction skipped — not enough history to compress)[/]");
+ }
+
+ AnsiConsole.WriteLine();
+ return compacted;
+ }
+}
diff --git a/HeaderRenderer.cs b/HeaderRenderer.cs
new file mode 100644
index 0000000..e3711d4
--- /dev/null
+++ b/HeaderRenderer.cs
@@ -0,0 +1,110 @@
+using System.Reflection;
+using Spectre.Console;
+using AnchorCli.OpenRouter;
+
+namespace AnchorCli;
+
+///
+/// Renders the application header, including ASCII art logo and configuration info table.
+///
+internal sealed class HeaderRenderer
+{
+ private readonly string _model;
+ private readonly string _endpoint;
+ private readonly string _providerName;
+ private readonly ModelInfo? _modelInfo;
+ private readonly TokenTracker? _tokenTracker;
+
+ public HeaderRenderer(
+ string model,
+ string endpoint,
+ string providerName,
+ ModelInfo? modelInfo = null,
+ TokenTracker? tokenTracker = null)
+ {
+ _model = model;
+ _endpoint = endpoint;
+ _providerName = providerName;
+ _modelInfo = modelInfo;
+ _tokenTracker = tokenTracker;
+ }
+
+ ///
+ /// Renders the full header including logo, subtitle, and info table.
+ ///
+ public void Render()
+ {
+ RenderLogo();
+ RenderSubtitle();
+ RenderInfoTable();
+ }
+
+ ///
+ /// Renders the ASCII art logo.
+ ///
+ public void RenderLogo()
+ {
+ var fontStream = Assembly.GetExecutingAssembly().GetManifestResourceStream("AnchorCli.Assets.3d.flf");
+ if (fontStream != null)
+ {
+ var font = FigletFont.Load(fontStream);
+ AnsiConsole.Write(
+ new FigletText(font, "anchor")
+ .Color(Color.CornflowerBlue));
+ }
+ else
+ {
+ AnsiConsole.Write(
+ new FigletText("anchor")
+ .Color(Color.CornflowerBlue));
+ }
+ }
+
+ ///
+ /// Renders the subtitle rule.
+ ///
+ public void RenderSubtitle()
+ {
+ AnsiConsole.Write(
+ new Rule("[dim]AI-powered coding assistant[/]")
+ .RuleStyle(Style.Parse("cornflowerblue dim"))
+ .LeftJustified());
+ AnsiConsole.WriteLine();
+ }
+
+ ///
+ /// Renders the configuration info table.
+ ///
+ public void RenderInfoTable()
+ {
+ var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
+
+ var table = new Table()
+ .Border(TableBorder.Rounded)
+ .BorderColor(Color.Grey)
+ .AddColumn(new TableColumn("[dim]Setting[/]").NoWrap())
+ .AddColumn(new TableColumn("[dim]Value[/]"));
+
+ table.AddRow("[grey]Model[/]", $"[cyan]{Markup.Escape(_modelInfo?.Name ?? _model)}[/]");
+ table.AddRow("[grey]Provider[/]", $"[blue]{_providerName}[/]");
+ table.AddRow("[grey]Endpoint[/]", $"[dim]{_endpoint}[/]");
+ table.AddRow("[grey]Version[/]", $"[magenta]{version}[/]");
+
+ if (_modelInfo?.Pricing != null && _tokenTracker != null)
+ {
+ var inM = _tokenTracker.InputPrice * 1_000_000m;
+ var outM = _tokenTracker.OutputPrice * 1_000_000m;
+ table.AddRow("[grey]Pricing[/]",
+ $"[yellow]${inM:F2}[/][dim]/M in[/] [yellow]${outM:F2}[/][dim]/M out[/]");
+ }
+
+ if (_modelInfo != null)
+ {
+ table.AddRow("[grey]Context[/]",
+ $"[dim]{_modelInfo.ContextLength:N0} tokens[/]");
+ }
+
+ AnsiConsole.Write(table);
+ AnsiConsole.WriteLine();
+ }
+}
diff --git a/OpenRouter/TokenTracker.cs b/OpenRouter/TokenTracker.cs
index e29d102..80bc893 100644
--- a/OpenRouter/TokenTracker.cs
+++ b/OpenRouter/TokenTracker.cs
@@ -5,12 +5,22 @@ namespace AnchorCli.OpenRouter;
///
internal sealed class TokenTracker
{
- private readonly ChatSession _session;
+ private ChatSession _session;
public TokenTracker(ChatSession session)
{
_session = session;
}
+
+ ///
+ /// Gets or sets the session. Allows setting the session after construction
+ /// to support dependency injection patterns.
+ ///
+ public ChatSession Session
+ {
+ get => _session;
+ set => _session = value;
+ }
public string Provider { get; set; } = "Unknown";
public long SessionInputTokens => _session.SessionInputTokens;
diff --git a/Program.cs b/Program.cs
index 32ab85b..582a638 100644
--- a/Program.cs
+++ b/Program.cs
@@ -1,208 +1,45 @@
-using System.ClientModel;
-using System.Reflection;
-using AnchorCli.Providers;
-using Microsoft.Extensions.AI;
-using OpenAI;
using AnchorCli;
-using AnchorCli.Tools;
using AnchorCli.Commands;
-using AnchorCli.OpenRouter;
using Spectre.Console;
Console.InputEncoding = System.Text.Encoding.UTF8;
Console.OutputEncoding = System.Text.Encoding.UTF8;
-// ── Setup subcommand ─────────────────────────────────────────────────────
-if (args.Length > 0 && args[0].Equals("setup", StringComparison.OrdinalIgnoreCase))
+// ── Application entry point ───────────────────────────────────────────────
+var startup = new ApplicationStartup(args);
+
+// Handle setup subcommand
+if (startup.HandleSetupSubcommand())
{
- SetupTui.Run();
return;
}
-// ── Config ──────────────────────────────────────────────────────────────
-var cfg = AnchorConfig.Load();
-string apiKey = cfg.ApiKey;
-string model = cfg.Model;
-string provider = cfg.Provider ?? "openrouter";
-string endpoint = cfg.Endpoint ?? "https://openrouter.ai/api/v1";
+// Initialize application (load config, create clients, fetch pricing)
+await startup.InitializeAsync();
-if (string.IsNullOrWhiteSpace(apiKey))
-{
- AnsiConsole.MarkupLine("[red]No API key configured. Run [bold]anchor setup[/] first.[/]");
- return;
-}
+// Render header
+var headerRenderer = startup.CreateHeaderRenderer();
+headerRenderer.Render();
-// ── Create token extractor for this provider ───────────────────────────
-var tokenExtractor = ProviderFactory.CreateTokenExtractorForEndpoint(endpoint);
-
-// ── Fetch model pricing (only for supported providers) ─────────────────
-ModelInfo? modelInfo = null;
-TokenTracker? tokenTracker = null;
-if (ProviderFactory.IsOpenRouter(endpoint))
-{
- await AnsiConsole.Status()
- .Spinner(Spinner.Known.BouncingBar)
- .SpinnerStyle(Style.Parse("cornflowerblue"))
- .StartAsync("Fetching model pricing...", async ctx =>
- {
- try
- {
- var pricingProvider = new OpenRouterProvider();
- modelInfo = await pricingProvider.GetModelInfoAsync(model);
- }
- catch { /* pricing is best-effort */ }
- });
-}
-
-
-// ── Pretty header ───────────────────────────────────────────────────────
-var fontStream = Assembly.GetExecutingAssembly().GetManifestResourceStream("AnchorCli.Assets.3d.flf");
-if (fontStream != null)
-{
- var font = FigletFont.Load(fontStream);
- AnsiConsole.Write(
- new FigletText(font, "anchor")
- .Color(Color.CornflowerBlue));
-}
-else
-{
- AnsiConsole.Write(
- new FigletText("anchor")
- .Color(Color.CornflowerBlue));
-}
-
-
-var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
-
-// ── Pretty header ───────────────────────────────────────────────────────
-
-AnsiConsole.Write(
- new Rule("[dim]AI-powered coding assistant[/]")
- .RuleStyle(Style.Parse("cornflowerblue dim"))
- .LeftJustified());
-
-AnsiConsole.WriteLine();
-
-var infoTable = new Table()
- .Border(TableBorder.Rounded)
- .BorderColor(Color.Grey)
- .AddColumn(new TableColumn("[dim]Setting[/]").NoWrap())
- .AddColumn(new TableColumn("[dim]Value[/]"));
-
-infoTable.AddRow("[grey]Model[/]", $"[cyan]{Markup.Escape(modelInfo?.Name ?? model)}[/]");
-infoTable.AddRow("[grey]Provider[/]", $"[blue]{tokenExtractor.ProviderName}[/]");
-infoTable.AddRow("[grey]Endpoint[/]", $"[dim]{endpoint}[/]");
-infoTable.AddRow("[grey]Version[/]", $"[magenta]{version}[/]");
-
-if (modelInfo?.Pricing != null && tokenTracker != 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[/]",
- $"[dim]{modelInfo.ContextLength:N0} tokens[/]");
-}
-
-AnsiConsole.Write(infoTable);
-AnsiConsole.WriteLine();
-
-// ── Build the chat client with tool-calling support ─────────────────────
-var httpClient = new HttpClient();
-OpenRouterHeaders.ApplyTo(httpClient);
-
-var openAiClient = new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClientOptions
-{
- Endpoint = new Uri(endpoint),
- Transport = new System.ClientModel.Primitives.HttpClientPipelineTransport(httpClient)
-});
-
-IChatClient innerClient = openAiClient.GetChatClient(model).AsIChatClient();
-
-// ── Tool call logging via Spectre ───────────────────────────────────────
-object consoleLock = new();
-
-void ToolLog(string message)
-{
- lock (consoleLock)
- {
- Console.Write("\r" + new string(' ', 40) + "\r");
- AnsiConsole.MarkupLine($"[dim grey]{Markup.Escape(message)}[/]");
- }
-}
-
-CommandTool.Log =
-DirTools.Log =
-FileTools.Log =
-EditTools.Log = ToolLog;
-
-// ── Instantiate Core Components ──────────────────────────────────────────
-
-var session = new ChatSession(innerClient);
-tokenTracker = new TokenTracker(session) { Provider = tokenExtractor.ProviderName };
-if (modelInfo != null)
-{
- if (modelInfo.Pricing != null)
- {
- tokenTracker.InputPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Prompt);
- tokenTracker.OutputPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Completion);
- tokenTracker.RequestPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Request);
- }
- tokenTracker.ContextLength = modelInfo.ContextLength;
-}
-
-var commandRegistry = new CommandRegistry();
-commandRegistry.Register(new ExitCommand());
-commandRegistry.Register(new HelpCommand(commandRegistry));
-commandRegistry.Register(new ClearCommand());
-commandRegistry.Register(new StatusCommand(model, endpoint));
-commandRegistry.Register(new CompactCommand(session.Compactor, session.History));
-commandRegistry.Register(new SetupCommand());
-commandRegistry.Register(new ResetCommand(session, tokenTracker));
+// Configure tool logging
+startup.ConfigureToolLogging();
+// Create core components
+var session = startup.CreateSession();
+startup.TokenTracker.Session = session;
+var commandRegistry = startup.CreateCommandRegistry(session);
var commandDispatcher = new CommandDispatcher(commandRegistry);
-// ── Run Repl ────────────────────────────────────────────────────────────
+// Create session manager
+var sessionManager = new SessionManager(session);
// 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.[/]");
+await sessionManager.TryLoadAsync();
- // Print the last message if there is one
- if (session.History.Count > 1)
- {
- var lastMessage = session.History[^1];
- var preview = lastMessage.Text.Length > 280
- ? lastMessage.Text[..277] + "..."
- : lastMessage.Text;
- AnsiConsole.MarkupLine($"[dim grey] Last message: {Markup.Escape(preview)}[/]");
- }
- }
- catch { /* Ignore load errors on startup */ }
-}
-
-var repl = new ReplLoop(session, tokenTracker, commandDispatcher);
+// Run REPL loop
+var repl = new ReplLoop(session, startup.TokenTracker, commandDispatcher, sessionManager);
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 */ }
-
+await sessionManager.TrySaveAsync();
diff --git a/ReplLoop.cs b/ReplLoop.cs
index f749ff7..28fd1ca 100644
--- a/ReplLoop.cs
+++ b/ReplLoop.cs
@@ -1,23 +1,38 @@
-using Microsoft.Extensions.AI;
-using OpenAI;
+using System.Text;
using Spectre.Console;
-using AnchorCli.OpenRouter;
using AnchorCli.Commands;
using AnchorCli.Tools;
+using AnchorCli.OpenRouter;
namespace AnchorCli;
+///
+/// Manages the interactive REPL (Read-Eval-Print Loop) for user interaction.
+/// Orchestrates input handling, command dispatching, and response display.
+///
internal sealed class ReplLoop
{
private readonly ChatSession _session;
private readonly TokenTracker _tokenTracker;
private readonly CommandDispatcher _commandDispatcher;
+ private readonly SessionManager _sessionManager;
+ private readonly ResponseStreamer _streamer;
+ private readonly UsageDisplayer _usageDisplayer;
+ private readonly ContextCompactionService _compactionService;
- public ReplLoop(ChatSession session, TokenTracker tokenTracker, CommandDispatcher commandDispatcher)
+ public ReplLoop(
+ ChatSession session,
+ TokenTracker tokenTracker,
+ CommandDispatcher commandDispatcher,
+ SessionManager sessionManager)
{
_session = session;
_tokenTracker = tokenTracker;
_commandDispatcher = commandDispatcher;
+ _sessionManager = sessionManager;
+ _streamer = new ResponseStreamer(session);
+ _usageDisplayer = new UsageDisplayer(tokenTracker);
+ _compactionService = new ContextCompactionService(session.Compactor, session.History, tokenTracker);
}
public async Task RunAsync()
@@ -29,7 +44,7 @@ internal sealed class ReplLoop
Console.CancelKeyPress += (_, e) =>
{
- e.Cancel = true; // Prevent process termination
+ e.Cancel = true;
responseCts?.Cancel();
};
@@ -42,233 +57,116 @@ internal sealed class ReplLoop
if (await _commandDispatcher.TryExecuteAsync(input, default)) continue;
- _session.History.Add(new ChatMessage(ChatRole.User, input));
- int turnStartIndex = _session.History.Count;
-
+ _session.History.Add(new Microsoft.Extensions.AI.ChatMessage(Microsoft.Extensions.AI.ChatRole.User, input));
AnsiConsole.WriteLine();
+ responseCts?.Dispose();
responseCts = new CancellationTokenSource();
- string fullResponse = "";
try
{
- await using var stream = _session
- .GetStreamingResponseAsync(responseCts.Token)
- .GetAsyncEnumerator(responseCts.Token);
-
- string? firstChunk = null;
- int respIn = 0, respOut = 0;
-
- void CaptureUsage(ChatResponseUpdate update)
- {
- if (update.RawRepresentation is OpenAI.Chat.StreamingChatCompletionUpdate raw
- && raw.Usage != null)
- {
- respIn = raw.Usage.InputTokenCount; // last call = actual context size
- respOut += raw.Usage.OutputTokenCount; // additive — each round generates new output
- }
- }
-
- object consoleLock = new();
- using var spinnerCts = CancellationTokenSource.CreateLinkedTokenSource(responseCts.Token);
- bool showSpinner = true;
-
- CommandTool.PauseSpinner = () =>
- {
- lock (consoleLock)
- {
- showSpinner = false;
- Console.Write("\r" + new string(' ', 40) + "\r");
- }
- };
- CommandTool.ResumeSpinner = () =>
- {
- lock (consoleLock)
- {
- showSpinner = true;
- }
- };
- FileTools.OnFileRead = _ =>
- {
- int n = ContextCompactor.CompactStaleToolResults(_session.History);
- if (n > 0)
- AnsiConsole.MarkupLine(
- $"[dim grey] ♻ Compacted {n} stale tool result(s)[/]");
- };
-
- var spinnerTask = Task.Run(async () =>
- {
- var frames = Spinner.Known.BouncingBar.Frames;
- var interval = Spinner.Known.BouncingBar.Interval;
- int i = 0;
-
- Console.Write("\x1b[?25l");
- try
- {
- while (!spinnerCts.Token.IsCancellationRequested)
- {
- lock (consoleLock)
- {
- if (showSpinner && !spinnerCts.Token.IsCancellationRequested)
- {
- var frame = frames[i % frames.Count];
- Console.Write($"\r\x1b[38;5;69m{frame}\x1b[0m Thinking...");
- i++;
- }
- }
- try { await Task.Delay(interval, spinnerCts.Token); } catch { }
- }
- }
- finally
- {
- lock (consoleLock)
- {
- if (showSpinner)
- Console.Write("\r" + new string(' ', 40) + "\r");
- Console.Write("\x1b[?25h");
- }
- }
- });
-
- try
- {
- while (await stream.MoveNextAsync())
- {
- responseCts.Token.ThrowIfCancellationRequested();
- CaptureUsage(stream.Current);
- if (!string.IsNullOrEmpty(stream.Current.Text))
- {
- firstChunk = stream.Current.Text;
- fullResponse = firstChunk;
- break;
- }
- }
- }
- finally
- {
- spinnerCts.Cancel();
- await Task.WhenAny(spinnerTask);
- CommandTool.PauseSpinner = null;
- CommandTool.ResumeSpinner = null;
- FileTools.OnFileRead = null;
- }
-
- if (firstChunk != null)
- {
- AnsiConsole.Markup(Markup.Escape(firstChunk));
- }
-
- while (await stream.MoveNextAsync())
- {
- responseCts.Token.ThrowIfCancellationRequested();
- CaptureUsage(stream.Current);
- var text = stream.Current.Text;
- if (!string.IsNullOrEmpty(text))
- {
- AnsiConsole.Markup(Markup.Escape(text));
- }
- fullResponse += text;
- }
-
- if (respIn > 0 || respOut > 0)
- {
- _tokenTracker.AddUsage(respIn, respOut);
- var cost = _tokenTracker.CalculateCost(respIn, respOut);
- var ctxPct = _tokenTracker.ContextUsagePercent;
- AnsiConsole.WriteLine();
- AnsiConsole.MarkupLine(
- $"[dim grey] {TokenTracker.FormatTokens(respIn)}↑ {TokenTracker.FormatTokens(respOut)}↓" +
- $" {TokenTracker.FormatCost(cost)}" +
- (ctxPct >= 0 ? $" ctx:{ctxPct:F0}%" : "") +
- $" │ session: {TokenTracker.FormatCost(_tokenTracker.SessionCost)}[/]");
- }
- else
- {
- AnsiConsole.WriteLine();
- }
-
- AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("grey dim")));
-
- _session.History.Add(new ChatMessage(ChatRole.Assistant, fullResponse));
-
- if (_tokenTracker.ShouldCompact())
- {
- var pct = _tokenTracker.ContextUsagePercent;
- AnsiConsole.MarkupLine(
- $"[yellow]⚠ Context at {pct:F0}% — compacting conversation history...[/]");
-
- bool compacted = await AnsiConsole.Status()
- .Spinner(Spinner.Known.BouncingBar)
- .SpinnerStyle(Style.Parse("yellow"))
- .StartAsync("Compacting context...", async ctx =>
- await _session.Compactor.TryCompactAsync(_session.History, default));
-
- if (compacted)
- {
- AnsiConsole.MarkupLine(
- $"[green]✓ Context compacted ({_session.History.Count} messages remaining)[/]");
- }
- else
- {
- AnsiConsole.MarkupLine(
- "[dim grey] (compaction skipped — not enough history to compress)[/]");
- }
- AnsiConsole.WriteLine();
- }
-
- // Save session after each LLM turn completes
- try
- {
- const string sessionPath = ".anchor/session.json";
- var directory = Path.GetDirectoryName(sessionPath);
- if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
- {
- Directory.CreateDirectory(directory);
- }
- await _session.SaveAsync(sessionPath, default);
- }
- catch (OperationCanceledException)
- {
- AnsiConsole.WriteLine();
- AnsiConsole.MarkupLine("[dim grey] ✗ Cancelled[/]");
- AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("grey dim")));
- AnsiConsole.WriteLine();
-
- if (!string.IsNullOrEmpty(fullResponse))
- {
- _session.History.Add(new ChatMessage(ChatRole.Assistant, fullResponse));
- }
- _session.History.Add(new ChatMessage(ChatRole.User,
- "[Response cancelled by user. Acknowledge briefly and wait for the next instruction. Do not repeat what was already said.]"));
- }
- catch (Exception ex)
- {
- AnsiConsole.WriteLine();
- AnsiConsole.Write(
- new Panel($"[red]{Markup.Escape(ex.Message)}[/]")
- .Header("[bold red] Error [/]")
- .BorderColor(Color.Red)
- .RoundedBorder()
- .Padding(1, 0));
- AnsiConsole.WriteLine();
- }
- finally
- {
- responseCts?.Dispose();
- responseCts = null;
- }
+ await ProcessTurnAsync(responseCts.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ HandleCancellation();
}
catch (Exception ex)
{
- AnsiConsole.WriteLine();
- AnsiConsole.Write(
- new Panel($"[red]{Markup.Escape(ex.Message)}[/]")
- .Header("[bold red] Error [/]")
- .BorderColor(Color.Red)
- .RoundedBorder()
- .Padding(1, 0));
- AnsiConsole.WriteLine();
+ DisplayError(ex);
+ }
+ finally
+ {
+ responseCts?.Dispose();
+ responseCts = null;
}
}
}
+
+ private async Task ProcessTurnAsync(CancellationToken cancellationToken)
+ {
+ using var spinner = new SpinnerService();
+ spinner.Start(cancellationToken);
+
+ // Configure tool callbacks for spinner control and stale result compaction
+ var originalPause = CommandTool.PauseSpinner;
+ var originalResume = CommandTool.ResumeSpinner;
+ var originalOnFileRead = FileTools.OnFileRead;
+
+ CommandTool.PauseSpinner = spinner.Pause;
+ CommandTool.ResumeSpinner = spinner.Resume;
+ FileTools.OnFileRead = _ =>
+ {
+ int n = ContextCompactor.CompactStaleToolResults(_session.History);
+ if (n > 0)
+ AnsiConsole.MarkupLine($"[dim grey] ♻ Compacted {n} stale tool result(s)[/]");
+ };
+
+ var responseBuilder = new StringBuilder();
+ bool firstChunkDisplayed = false;
+
+ try
+ {
+ await foreach (var chunk in _streamer.StreamAsync(cancellationToken))
+ {
+ // Stop spinner before displaying first chunk
+ if (!firstChunkDisplayed)
+ {
+ await spinner.StopAsync();
+ firstChunkDisplayed = true;
+ }
+
+ AnsiConsole.Markup(Markup.Escape(chunk));
+ responseBuilder.Append(chunk);
+ }
+ }
+ finally
+ {
+ if (!firstChunkDisplayed)
+ {
+ await spinner.StopAsync();
+ }
+ CommandTool.PauseSpinner = originalPause;
+ CommandTool.ResumeSpinner = originalResume;
+ FileTools.OnFileRead = originalOnFileRead;
+ }
+
+ var fullResponse = responseBuilder.ToString();
+
+ // Display usage statistics
+ _usageDisplayer.Display(_streamer.LastInputTokens, _streamer.LastOutputTokens);
+ _usageDisplayer.DisplaySeparator();
+
+ // Add response to history
+ _session.History.Add(new Microsoft.Extensions.AI.ChatMessage(Microsoft.Extensions.AI.ChatRole.Assistant, fullResponse));
+
+ // Check for context compaction
+ await _compactionService.TryCompactAsync();
+
+ // Save session after turn completes
+ await _sessionManager.SaveAfterTurnAsync();
+ }
+
+ private void HandleCancellation()
+ {
+ AnsiConsole.WriteLine();
+ AnsiConsole.MarkupLine("[dim grey] ✗ Cancelled[/]");
+ AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("grey dim")));
+ AnsiConsole.WriteLine();
+
+ _session.History.Add(new Microsoft.Extensions.AI.ChatMessage(Microsoft.Extensions.AI.ChatRole.User,
+ "[Response cancelled by user. Acknowledge briefly and wait for the next instruction. Do not repeat what was already said.]"));
+ }
+
+ private void DisplayError(Exception ex)
+ {
+ AnsiConsole.WriteLine();
+ AnsiConsole.Write(
+ new Panel($"[red]{Markup.Escape(ex.Message)}[/]")
+ .Header("[bold red] Error [/]")
+ .BorderColor(Color.Red)
+ .RoundedBorder()
+ .Padding(1, 0));
+ AnsiConsole.WriteLine();
+ }
}
diff --git a/ResponseStreamer.cs b/ResponseStreamer.cs
new file mode 100644
index 0000000..ac69413
--- /dev/null
+++ b/ResponseStreamer.cs
@@ -0,0 +1,60 @@
+using Microsoft.Extensions.AI;
+using OpenAI;
+
+namespace AnchorCli;
+
+///
+/// Handles streaming responses from the chat client, including token usage capture.
+///
+internal sealed class ResponseStreamer
+{
+ private readonly ChatSession _session;
+
+ public ResponseStreamer(ChatSession session)
+ {
+ _session = session;
+ }
+
+ ///
+ /// Streams a response from the session and captures token usage.
+ /// Returns an async enumerable that yields text chunks as they arrive.
+ ///
+ public async IAsyncEnumerable StreamAsync(
+ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await using var stream = _session
+ .GetStreamingResponseAsync(cancellationToken)
+ .GetAsyncEnumerator(cancellationToken);
+
+ int respIn = 0, respOut = 0;
+
+ void CaptureUsage(ChatResponseUpdate update)
+ {
+ if (update.RawRepresentation is OpenAI.Chat.StreamingChatCompletionUpdate raw
+ && raw.Usage != null)
+ {
+ respIn = raw.Usage.InputTokenCount;
+ respOut += raw.Usage.OutputTokenCount;
+ }
+ }
+
+ // Stream all chunks
+ while (await stream.MoveNextAsync())
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ CaptureUsage(stream.Current);
+ var text = stream.Current.Text;
+ if (!string.IsNullOrEmpty(text))
+ {
+ yield return text;
+ }
+ }
+
+ // Store final usage stats
+ LastInputTokens = respIn;
+ LastOutputTokens = respOut;
+ }
+
+ public int LastInputTokens { get; private set; }
+ public int LastOutputTokens { get; private set; }
+}
diff --git a/SessionManager.cs b/SessionManager.cs
new file mode 100644
index 0000000..d0e3411
--- /dev/null
+++ b/SessionManager.cs
@@ -0,0 +1,83 @@
+using Spectre.Console;
+
+namespace AnchorCli;
+
+///
+/// Manages session persistence, including auto-load on startup and auto-save on exit.
+///
+internal sealed class SessionManager
+{
+ private readonly ChatSession _session;
+ private readonly string _sessionPath;
+
+ public SessionManager(ChatSession session, string sessionPath = ".anchor/session.json")
+ {
+ _session = session;
+ _sessionPath = sessionPath;
+ }
+
+ ///
+ /// Attempts to load a session from disk. Returns true if successful.
+ ///
+ public async Task TryLoadAsync(CancellationToken cancellationToken = default)
+ {
+ if (!File.Exists(_sessionPath))
+ {
+ return false;
+ }
+
+ try
+ {
+ await _session.LoadAsync(_sessionPath, cancellationToken);
+ AnsiConsole.MarkupLine($"[dim grey]Auto-loaded previous session.[/]");
+
+ // Print the last message if there is one
+ if (_session.History.Count > 1)
+ {
+ var lastMessage = _session.History[^1];
+ var preview = lastMessage.Text.Length > 280
+ ? lastMessage.Text[..277] + "..."
+ : lastMessage.Text;
+ AnsiConsole.MarkupLine($"[dim grey] Last message: {Markup.Escape(preview)}[/]");
+ }
+
+ return true;
+ }
+ catch
+ {
+ // Ignore load errors
+ return false;
+ }
+ }
+
+ ///
+ /// Attempts to save the session to disk. Returns true if successful.
+ ///
+ public async Task TrySaveAsync(CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var directory = Path.GetDirectoryName(_sessionPath);
+ if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ await _session.SaveAsync(_sessionPath, cancellationToken);
+ return true;
+ }
+ catch
+ {
+ // Ignore save errors
+ return false;
+ }
+ }
+
+ ///
+ /// Saves the session after an LLM turn completes.
+ ///
+ public async Task SaveAfterTurnAsync(CancellationToken cancellationToken = default)
+ {
+ await TrySaveAsync(cancellationToken);
+ }
+}
diff --git a/SpinnerService.cs b/SpinnerService.cs
new file mode 100644
index 0000000..cfe373d
--- /dev/null
+++ b/SpinnerService.cs
@@ -0,0 +1,100 @@
+using Spectre.Console;
+
+namespace AnchorCli;
+
+///
+/// Manages the "thinking" spinner animation during AI response generation.
+///
+internal sealed class SpinnerService : IDisposable
+{
+ private readonly object _consoleLock = new();
+ private CancellationTokenSource? _spinnerCts;
+ private Task? _spinnerTask;
+ private bool _showSpinner = true;
+ private bool _disposed;
+
+ ///
+ /// Starts the spinner animation.
+ ///
+ public void Start(CancellationToken cancellationToken)
+ {
+ _spinnerCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ _showSpinner = true;
+
+ _spinnerTask = Task.Run(async () =>
+ {
+ var frames = Spinner.Known.BouncingBar.Frames;
+ var interval = Spinner.Known.BouncingBar.Interval;
+ int i = 0;
+
+ Console.Write("\x1b[?25l");
+ try
+ {
+ while (!_spinnerCts.Token.IsCancellationRequested)
+ {
+ lock (_consoleLock)
+ {
+ if (_showSpinner && !_spinnerCts.Token.IsCancellationRequested)
+ {
+ var frame = frames[i % frames.Count];
+ Console.Write($"\r\x1b[38;5;69m{frame}\x1b[0m Thinking...");
+ i++;
+ }
+ }
+ try { await Task.Delay(interval, _spinnerCts.Token); } catch { }
+ }
+ }
+ finally
+ {
+ lock (_consoleLock)
+ {
+ if (_showSpinner)
+ Console.Write("\r" + new string(' ', 40) + "\r");
+ Console.Write("\x1b[?25h");
+ }
+ }
+ });
+ }
+
+ ///
+ /// Stops the spinner animation and waits for it to complete.
+ ///
+ public async Task StopAsync()
+ {
+ _spinnerCts?.Cancel();
+ if (_spinnerTask != null)
+ {
+ await Task.WhenAny(_spinnerTask);
+ }
+ }
+
+ ///
+ /// Pauses the spinner (e.g., during tool execution).
+ ///
+ public void Pause()
+ {
+ lock (_consoleLock)
+ {
+ _showSpinner = false;
+ Console.Write("\r" + new string(' ', 40) + "\r");
+ }
+ }
+
+ ///
+ /// Resumes the spinner after being paused.
+ ///
+ public void Resume()
+ {
+ lock (_consoleLock)
+ {
+ _showSpinner = true;
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _spinnerCts?.Dispose();
+ _disposed = true;
+ }
+}
diff --git a/UsageDisplayer.cs b/UsageDisplayer.cs
new file mode 100644
index 0000000..ead02c3
--- /dev/null
+++ b/UsageDisplayer.cs
@@ -0,0 +1,48 @@
+using Spectre.Console;
+
+namespace AnchorCli.OpenRouter;
+
+///
+/// Displays token usage and cost information to the console.
+///
+internal sealed class UsageDisplayer
+{
+ private readonly TokenTracker _tokenTracker;
+
+ public UsageDisplayer(TokenTracker tokenTracker)
+ {
+ _tokenTracker = tokenTracker;
+ }
+
+ ///
+ /// Displays the usage statistics for a single response.
+ ///
+ public void Display(int inputTokens, int outputTokens)
+ {
+ if (inputTokens > 0 || outputTokens > 0)
+ {
+ _tokenTracker.AddUsage(inputTokens, outputTokens);
+ var cost = _tokenTracker.CalculateCost(inputTokens, outputTokens);
+ var ctxPct = _tokenTracker.ContextUsagePercent;
+
+ AnsiConsole.WriteLine();
+ AnsiConsole.MarkupLine(
+ $"[dim grey] {TokenTracker.FormatTokens(inputTokens)}↑ {TokenTracker.FormatTokens(outputTokens)}↓" +
+ $" {TokenTracker.FormatCost(cost)}" +
+ (ctxPct >= 0 ? $" ctx:{ctxPct:F0}%" : "") +
+ $" │ session: {TokenTracker.FormatCost(_tokenTracker.SessionCost)}[/]");
+ }
+ else
+ {
+ AnsiConsole.WriteLine();
+ }
+ }
+
+ ///
+ /// Displays a rule separator.
+ ///
+ public void DisplaySeparator()
+ {
+ AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("grey dim")));
+ }
+}