From 1e943e65661eedf209cc1dbf3e88a7cfb9875218 Mon Sep 17 00:00:00 2001 From: Tomi Eckert Date: Wed, 11 Mar 2026 16:59:06 +0100 Subject: [PATCH] refactor: apply Single Responsibility Principle to Program.cs and ReplLoop.cs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extracted responsibilities from Program.cs (208→46 lines) and ReplLoop.cs (274→174 lines) into focused service classes: HeaderRenderer, SessionManager, ApplicationStartup, ResponseStreamer, SpinnerService, UsageDisplayer, and ContextCompactionService. Each class now has a single, well-defined responsibility, improving testability and maintainability. --- ApplicationStartup.cs | 176 +++++++++++++++++++ ContextCompactionService.cs | 61 +++++++ HeaderRenderer.cs | 110 ++++++++++++ OpenRouter/TokenTracker.cs | 12 +- Program.cs | 207 +++------------------- ReplLoop.cs | 342 +++++++++++++----------------------- ResponseStreamer.cs | 60 +++++++ SessionManager.cs | 83 +++++++++ SpinnerService.cs | 100 +++++++++++ UsageDisplayer.cs | 48 +++++ 10 files changed, 791 insertions(+), 408 deletions(-) create mode 100644 ApplicationStartup.cs create mode 100644 ContextCompactionService.cs create mode 100644 HeaderRenderer.cs create mode 100644 ResponseStreamer.cs create mode 100644 SessionManager.cs create mode 100644 SpinnerService.cs create mode 100644 UsageDisplayer.cs 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"))); + } +}