diff --git a/Commands/HelpCommand.cs b/Commands/HelpCommand.cs index 7789149..5fcb77f 100644 --- a/Commands/HelpCommand.cs +++ b/Commands/HelpCommand.cs @@ -1,5 +1,5 @@ using Spectre.Console; -using System.Linq; +using System.Reflection; namespace AnchorCli.Commands; public class HelpCommand : ICommand @@ -16,6 +16,8 @@ public class HelpCommand : ICommand public Task ExecuteAsync(string[] args, CancellationToken ct) { + var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown"; + AnsiConsole.MarkupLine($"[cyan]Anchor CLI v{version}[/]"); AnsiConsole.MarkupLine("[cyan]Available commands:[/]"); var table = new Table(); diff --git a/Commands/StatusCommand.cs b/Commands/StatusCommand.cs index de7f3a6..135e23a 100644 --- a/Commands/StatusCommand.cs +++ b/Commands/StatusCommand.cs @@ -1,4 +1,5 @@ using Spectre.Console; +using System.Reflection; namespace AnchorCli.Commands; public class StatusCommand : ICommand @@ -25,6 +26,7 @@ public class StatusCommand : ICommand table.AddRow("[grey]Model[/]", $"[cyan]{Markup.Escape(_model)}[/]"); table.AddRow("[grey]Endpoint[/]", $"[blue]{Markup.Escape(_endpoint)}[/]"); + table.AddRow("[grey]Version[/]", $"[magenta]{Assembly.GetExecutingAssembly().GetName().Version}[/]"); table.AddRow("[grey]CWD[/]", $"[green]{Markup.Escape(Environment.CurrentDirectory)}[/]"); AnsiConsole.Write(table); diff --git a/Program.cs b/Program.cs index e420385..9c22345 100644 --- a/Program.cs +++ b/Program.cs @@ -1,189 +1,198 @@ -using System.ClientModel; -using AnchorCli.Providers; -using Microsoft.Extensions.AI; -using OpenAI; -using AnchorCli; -using AnchorCli.Tools; -using AnchorCli.Commands; -using AnchorCli.OpenRouter; -using Spectre.Console; - -// ── Setup subcommand ───────────────────────────────────────────────────── -if (args.Length > 0 && args[0].Equals("setup", StringComparison.OrdinalIgnoreCase)) -{ - 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"; - -if (string.IsNullOrWhiteSpace(apiKey)) -{ - AnsiConsole.MarkupLine("[red]No API key configured. Run [bold]anchor setup[/] first.[/]"); - return; -} - -// ── Create token extractor for this provider ─────────────────────────── -var tokenExtractor = ProviderFactory.CreateTokenExtractorForEndpoint(endpoint); -var tokenTracker = new TokenTracker { Provider = tokenExtractor.ProviderName }; - -// ── Fetch model pricing (only for supported providers) ───────────────── -ModelInfo? modelInfo = 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); - 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); - } - } - catch { /* pricing is best-effort */ } - }); -} - -// ── Pretty header ─────────────────────────────────────────────────────── -AnsiConsole.Write( - new FigletText("anchor") - .Color(Color.CornflowerBlue)); - -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]CWD[/]", $"[green]{Markup.Escape(Environment.CurrentDirectory)}[/]"); - -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 != 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); -if (modelInfo != null) -{ - 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)); -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.[/]"); - - // 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); -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 */ } - +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; + +// ── Setup subcommand ───────────────────────────────────────────────────── +if (args.Length > 0 && args[0].Equals("setup", StringComparison.OrdinalIgnoreCase)) +{ + 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"; + +if (string.IsNullOrWhiteSpace(apiKey)) +{ + AnsiConsole.MarkupLine("[red]No API key configured. Run [bold]anchor setup[/] first.[/]"); + return; +} + +// ── Create token extractor for this provider ─────────────────────────── +var tokenExtractor = ProviderFactory.CreateTokenExtractorForEndpoint(endpoint); +var tokenTracker = new TokenTracker { Provider = tokenExtractor.ProviderName }; + +// ── Fetch model pricing (only for supported providers) ───────────────── +ModelInfo? modelInfo = 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); + 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); + } + } + catch { /* pricing is best-effort */ } + }); +} + + +// ── Pretty header ─────────────────────────────────────────────────────── +AnsiConsole.Write( + new FigletText("anchor") + .Color(Color.CornflowerBlue)); + +var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown"; + +AnsiConsole.Write( + new Rule("[dim]AI-powered coding assistant[/]") + .RuleStyle(Style.Parse("cornflowerblue dim")) + .LeftJustified()); +// ── 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) + + { + 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); +if (modelInfo != null) +{ + 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)); +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.[/]"); + + // 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); +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 */ } + diff --git a/docs/ARCHITECTURE_REFACTOR.md b/docs/ARCHITECTURE_REFACTOR.md deleted file mode 100644 index caa3c84..0000000 --- a/docs/ARCHITECTURE_REFACTOR.md +++ /dev/null @@ -1,1101 +0,0 @@ - ---- - -## Table of Contents - -1. [Current Architecture Analysis](#current-architecture-analysis) -2. [Core Problems Identified](#core-problems-identified) -3. [Proposed Architecture](#proposed-architecture) -4. [Component Specifications](#component-specifications) -5. [Event System Design](#event-system-design) -6. [Migration Strategy](#migration-strategy) -7. [Benefits](#benefits) - ---- - -## Current Architecture Analysis - -### Program.cs (153 lines - God Class) - -**Responsibilities (should be separate):** -- Configuration loading and validation -- Pricing data fetching from GitHub -- UI initialization (Spectre.Console, Figlet banner) -- Client setup (Anthropic API, API key management) -- Tool registration (CommandTool, FileTools) -- Command wiring (global static delegates) -- REPL initialization and startup - -**Anti-patterns:** -```csharp -// Global static wiring -CommandTool.Log = (msg) => { ... }; -FileTools.OnFileRead = (path) => { ... }; - -// Multiple concerns in one method -async Task Main(string[] args) -{ - var config = LoadConfig(); - var pricing = await FetchPricing(); - var console = new Console(); - var client = new AnthropicClient(); - RegisterTools(); - WireCommands(); - await ReplLoop.RunAsync(); -} -``` - -### ReplLoop.cs (252 lines - Tangled Logic) - -**Responsibilities mixed together:** -- Input prompt handling -- Command detection and routing -- Streaming response processing -- UI spinner management (async task) -- Token usage tracking (inline closure) -- Context compaction triggering -- Cancellation handling -- Error handling and recovery - -**Anti-patterns:** -```csharp -// Local function capturing state -var CaptureUsage = (usage) => { ... }; - -// Async spinner task inline -Task.Run(async () => { - while (!ctoken.IsCancellationRequested) { /* spinner logic */ } -}); - -// State scattered across objects -var history = new List(); -var tokenTracker = new TokenTracker(); -var compactor = new ContextCompactor(); -``` - -### ContextCompactor.cs (Half-Static, Half-Instance) - -**Inconsistent design:** -```csharp -public static void CompactStaleToolResults(List history) -public async Task TryCompactAsync(List history, TokenTracker tracker) -``` - -**Hardcoded strategy:** -- Compaction threshold is magic number (80%) -- No pluggable compaction policies -- No way to customize what gets compacted - ---- - -## Core Problems Identified - -### 1. Lack of Separation of Concerns - -| Concern | Current Location | Should Be | -|---------|-----------------|-----------| -| Configuration | Program.cs | ConfigLoader service | -| Pricing | Program.cs | PricingService | -| UI Rendering | Program.cs, ReplLoop.cs | ReplRenderer service | -| Input Handling | ReplLoop.cs | InputProcessor service | -| Streaming | ReplLoop.cs | ResponseStreamer service | -| Token Tracking | ReplLoop.cs (closure) | TokenTracker service | -| Context Management | ContextCompactor | ContextManager service | -| Tool Logging | Static delegate | ToolEventPublisher | -| Command Routing | ReplLoop.cs | CommandRouter service | - -### 2. Global State and Static Dependencies - -```csharp -CommandTool.Log = ...; -FileTools.OnFileRead = ...; -var consoleLock = new object(); -``` - -**Problems:** -- Impossible to unit test without mocking statics -- State persists across test runs -- No way to have multiple instances -- Tight coupling between components - -### 3. No Event-Driven Communication - -Components communicate via: -- Direct method calls -- Static delegates -- Shared mutable state - -**Should use:** -- Events for loose coupling -- Cancellation tokens for lifecycle -- Dependency injection for dependencies - -### 4. Untestable Components - -**ReplLoop cannot be unit tested because:** -- Reads from stdin directly -- Writes to console directly -- Creates Anthropic client inline -- Uses static tool delegates -- Has async spinner task with no cancellation control - -**ContextCompactor cannot be unit tested because:** -- Static method mixes with instance method -- Token tracking logic is external -- No strategy pattern for compaction rules - ---- - -## Proposed Architecture - -### Directory Structure - -``` -AnchorCli/ -├── Program.cs # Bootstrap only (20 lines) -├── Core/ -│ ├── AnchorHost.cs # DI container + lifecycle manager -│ ├── IAnchorHost.cs -│ ├── ChatSessionManager.cs # Owns session state + history -│ ├── IChatSessionManager.cs -│ ├── TokenAwareCompactor.cs # Combines tracking + compaction -│ ├── IContextStrategy.cs # Strategy pattern for compaction -│ ├── DefaultContextStrategy.cs -│ ├── AggressiveContextStrategy.cs -│ └── EventMultiplexer.cs # Central event bus -├── Events/ -│ ├── ChatEvents.cs # UserInputReceived, ResponseStreaming, TurnCompleted -│ ├── ContextEvents.cs # ContextThresholdReached, CompactionRequested -│ ├── ToolEvents.cs # ToolExecuting, ToolCompleted, ToolFailed -│ └── SessionEvents.cs # SessionStarted, SessionEnded -├── UI/ -│ ├── ReplRenderer.cs # All UI concerns -│ ├── IUiRenderer.cs -│ ├── SpinnerService.cs # Dedicated spinner management -│ ├── ISpinnerService.cs -│ ├── ToolOutputRenderer.cs # Tool call logging -│ └── StreamingRenderer.cs # Streaming response rendering -├── Input/ -│ ├── InputProcessor.cs # Routes between commands and chat -│ ├── IInputProcessor.cs -│ ├── CommandRouter.cs # Command dispatching -│ ├── ICommandRouter.cs -│ └── ICommand.cs # Command interface -├── Streaming/ -│ ├── ResponseStreamer.cs # Handles streaming + token capture -│ ├── IResponseStreamer.cs -│ └── StreamFormatter.cs # Formats streaming output -├── Configuration/ -│ ├── ConfigLoader.cs # Loads and validates config -│ ├── IConfigLoader.cs -│ ├── PricingService.cs # Fetches pricing data -│ └── IPricingService.cs -├── Tools/ -│ ├── ToolRegistry.cs # Registers and manages tools -│ ├── IToolRegistry.cs -│ ├── CommandTool.cs # Refactored without static -│ └── FileTools.cs # Refactored without static -└── Extensions/ - ├── CancellationTokenExtensions.cs - └── StringExtensions.cs -``` - -### Dependency Injection Graph - -``` -AnchorHost (Composition Root) -│ -├── IChatSessionManager → ChatSessionManager -│ ├── IContextStrategy → DefaultContextStrategy -│ ├── ITokenTracker → TokenTracker -│ └── IMessageHistory → InMemoryMessageHistory -│ -├── IInputProcessor → InputProcessor -│ ├── ICommandRouter → CommandRouter -│ ├── IChatSessionManager → ChatSessionManager -│ └── IConsole → SpectreConsole -│ -├── IResponseStreamer → ResponseStreamer -│ ├── IAnthropicClient → AnthropicClient -│ ├── ITokenTracker → TokenTracker -│ ├── IUiRenderer → ReplRenderer -│ └── IChatSessionManager → ChatSessionManager -│ -├── IUiRenderer → ReplRenderer -│ ├── ISpinnerService → SpinnerService -│ ├── IStreamingRenderer → StreamingRenderer -│ ├── IToolOutputRenderer → ToolOutputRenderer -│ └── IConsole → SpectreConsole -│ -├── IToolRegistry → ToolRegistry -│ ├── CommandTool -│ └── FileTools -│ -├── IConfigLoader → ConfigLoader -├── IPricingService → PricingService -└── IEventMultiplexer → EventMultiplexer -``` - ---- - -## Component Specifications - -### AnchorHost - -**Purpose:** Composition root and lifecycle manager - -```csharp -public interface IAnchorHost -{ - Task RunAsync(string[] args, CancellationToken cancellationToken = default); - Task StopAsync(CancellationToken cancellationToken = default); - T GetService() where T : class; -} - -public class AnchorHost : IAnchorHost -{ - private readonly IServiceCollection _services; - private readonly IServiceProvider _provider; - - public AnchorHost() - { - _services = new ServiceCollection(); - RegisterServices(); - _provider = _services.BuildServiceProvider(); - } - - private void RegisterServices() - { - // Configuration - _services.AddSingleton(); - _services.AddSingleton(); - - // Event system - _services.AddSingleton(); - - // Tools - _services.AddSingleton(); - _services.AddSingleton(); - _services.AddSingleton(); - - // Session management - _services.AddSingleton(); - _services.AddSingleton(); - _services.AddSingleton(); - - // UI - _services.AddSingleton(_ => new Console()); - _services.AddSingleton(); - _services.AddSingleton(); - _services.AddSingleton(); - _services.AddSingleton(); - - // Input/Output - _services.AddSingleton(); - _services.AddSingleton(); - _services.AddSingleton(); - - // Client - _services.AddSingleton(); - } - - public async Task RunAsync(string[] args, CancellationToken cancellationToken = default) - { - // 1. Load configuration - var config = GetService().LoadAsync(cancellationToken); - - // 2. Display banner - GetService().ShowBanner(config.Pricing); - - // 3. Start input processing loop - await GetService().RunAsync(cancellationToken); - } -} -``` - -**New Program.cs (20 lines):** -```csharp -using AnchorCli.Core; - -namespace AnchorCli; - -class Program -{ - static async Task Main(string[] args) - { - var host = new AnchorHost(); - await host.RunAsync(args); - } -} -``` - -### ChatSessionManager - -**Purpose:** Owns all session state and history - -```csharp -public interface IChatSessionManager -{ - IReadOnlyList History { get; } - int TokenCount { get; } - bool IsContextNearLimit { get; } - - void AddUserMessage(string content); - void AddAssistantMessage(string content); - void AddToolCall(ToolCall call); - void AddToolResult(ToolResult result); - - Task TryCompactAsync(CancellationToken cancellationToken); - void Reset(); - - event EventHandler ContextThresholdReached; - event EventHandler CompactionCompleted; -} - -public class ChatSessionManager : IChatSessionManager -{ - private readonly List _history = new(); - private readonly ITokenTracker _tokenTracker; - private readonly IContextStrategy _strategy; - private readonly IEventMultiplexer _events; - - public IReadOnlyList History => _history.AsReadOnly(); - public int TokenCount => _tokenTracker.TotalTokens; - public bool IsContextNearLimit => _strategy.ShouldCompact(_tokenTracker.TotalTokens); - - public async Task TryCompactAsync(CancellationToken cancellationToken) - { - if (!IsContextNearLimit) return false; - - var compacted = await _strategy.CompactAsync(_history, cancellationToken); - - if (compacted) - { - _events.Raise(new CompactionCompletedEventArgs(_history.Count)); - } - - return compacted; - } -} -``` - -### IContextStrategy (Strategy Pattern) - -**Purpose:** Pluggable compaction policies - -```csharp -public interface IContextStrategy -{ - bool ShouldCompact(int currentTokenCount); - Task CompactAsync(List history, CancellationToken cancellationToken); -} - -public class DefaultContextStrategy : IContextStrategy -{ - private const int MaxTokens = 100000; - private const double Threshold = 0.8; - - public bool ShouldCompact(int currentTokenCount) - { - return currentTokenCount > (MaxTokens * Threshold); - } - - public async Task CompactAsync(List history, CancellationToken cancellationToken) - { - // Remove old tool results, summarize early conversation, etc. - var originalCount = history.Count; - CompactStaleToolResults(history); - await SummarizeEarlyMessages(history, cancellationToken); - return history.Count < originalCount; - } -} - -public class AggressiveContextStrategy : IContextStrategy -{ - private const double Threshold = 0.6; - - public bool ShouldCompact(int currentTokenCount) - { - return currentTokenCount > (MaxTokens * Threshold); - } - - public async Task CompactAsync(List history, CancellationToken cancellationToken) - { - // More aggressive: remove more history, summarize more - } -} -``` - -### InputProcessor - -**Purpose:** Routes input between commands and chat - -```csharp -public interface IInputProcessor -{ - Task RunAsync(CancellationToken cancellationToken); -} - -public class InputProcessor : IInputProcessor -{ - private readonly IConsole _console; - private readonly ICommandRouter _commandRouter; - private readonly IChatSessionManager _sessionManager; - private readonly IResponseStreamer _streamer; - private readonly IUiRenderer _renderer; - - public async Task RunAsync(CancellationToken cancellationToken) - { - while (!cancellationToken.IsCancellationRequested) - { - // Get input - _renderer.ShowPrompt(); - var input = _console.ReadLine(); - - if (string.IsNullOrWhiteSpace(input)) continue; - - // Check for commands - if (_commandRouter.IsCommand(input)) - { - await _commandRouter.ExecuteAsync(input, cancellationToken); - continue; - } - - // Process as chat - _sessionManager.AddUserMessage(input); - - // Stream response - await _streamer.StreamResponseAsync(cancellationToken); - } - } -} -``` - -### ResponseStreamer - -**Purpose:** Handles streaming responses with token tracking - -```csharp -public interface IResponseStreamer -{ - Task StreamResponseAsync(CancellationToken cancellationToken); -} - -public class ResponseStreamer : IResponseStreamer -{ - private readonly IAnthropicClient _client; - private readonly IChatSessionManager _sessionManager; - private readonly IUiRenderer _renderer; - private readonly IEventMultiplexer _events; - - public async Task StreamResponseAsync(CancellationToken cancellationToken) - { - var response = _client.CreateStreamedMessage(_sessionManager.History); - var fullResponse = new StringBuilder(); - - await foreach (var delta in response.WithCancellation(cancellationToken)) - { - var text = delta.Delta?.Text ?? ""; - fullResponse.Append(text); - _renderer.RenderStreamDelta(text); - _sessionManager.TokenTracker.AddTokens(CountTokens(text)); - _events.Raise(new ResponseDeltaEventArgs(text)); - - if (_sessionManager.IsContextNearLimit) - { - await _sessionManager.TryCompactAsync(cancellationToken); - } - } - - _sessionManager.AddAssistantMessage(fullResponse.ToString()); - _events.Raise(new TurnCompletedEventArgs(fullResponse.ToString())); - } -} -``` - -### ReplRenderer - -**Purpose:** Centralized UI rendering - -```csharp -public interface IUiRenderer -{ - void ShowBanner(PricingInfo pricing); - void ShowPrompt(); - void RenderStreamDelta(string text); - void RenderToolCall(ToolCall call); - void RenderToolResult(ToolResult result); - void RenderError(Exception ex); - void RenderCommandOutput(string output); -} - -public class ReplRenderer : IUiRenderer -{ - private readonly IConsole _console; - private readonly ISpinnerService _spinner; - private readonly IStreamingRenderer _streaming; - private readonly IToolOutputRenderer _toolOutput; - - public void ShowBanner(PricingInfo pricing) - { - _console.WriteLine(Figlet.Render("Anchor CLI")); - _console.WriteLine($"Pricing: {pricing.Input}/token in, {pricing.Output}/token out\n"); - } - - public void ShowPrompt() - { - _console.Write("[green]❯[/] "); - } - - public void RenderStreamDelta(string text) - { - _streaming.Render(text); - } - - public void RenderToolCall(ToolCall call) - { - _toolOutput.RenderCall(call); - } -} -``` - -### SpinnerService - -**Purpose:** Dedicated spinner management - -```csharp -public interface ISpinnerService -{ - Task StartAsync(string message, CancellationToken cancellationToken); - Task StopAsync(); -} - -public class SpinnerService : ISpinnerService -{ - private readonly IConsole _console; - private readonly CancellationTokenSource _cts; - private Task _spinnerTask; - - public async Task StartAsync(string message, CancellationToken cancellationToken) - { - _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - - _spinnerTask = Task.Run(async () => - { - var spinner = _console.CreateSpinner("[green]{0}[/]"); - spinner.Start(message); - while (!_cts.Token.IsCancellationRequested) - { - await Task.Delay(100, _cts.Token); - } - spinner.Stop(); - }, _cts.Token); - } - - public Task StopAsync() - { - _cts?.Cancel(); - return _spinnerTask ?? Task.CompletedTask; - } -} -``` - ---- - -## Event System Design - -### Event Multiplexer - -**Purpose:** Central event bus for loose coupling - -```csharp -public interface IEventMultiplexer -{ - void Subscribe(EventHandler handler) where T : AnchorEvent; - void Unsubscribe(EventHandler handler) where T : AnchorEvent; - void Raise(T @event) where T : AnchorEvent; -} - -public class EventMultiplexer : IEventMultiplexer -{ - private readonly Dictionary> _subscribers = new(); - private readonly object _lock = new(); - - public void Subscribe(EventHandler handler) where T : AnchorEvent - { - lock (_lock) - { - if (!_subscribers.ContainsKey(typeof(T))) - { - _subscribers[typeof(T)] = new List(); - } - _subscribers[typeof(T)].Add(handler); - } - } - - public void Raise(T @event) where T : AnchorEvent - { - lock (_lock) - { - if (_subscribers.TryGetValue(typeof(T), out var handlers)) - { - foreach (var handler in handlers) - { - try { handler.DynamicInvoke(this, @event); } - catch { /* Log but don't fail */ } - } - } - } - } -} -``` - -### Event Definitions - -```csharp -// ChatEvents.cs -public abstract class AnchorEvent : EventArgs { } - -public class UserInputReceivedEvent : AnchorEvent -{ - public string Input { get; } - public UserInputReceivedEvent(string input) => Input = input; -} - -public class ResponseDeltaEvent : AnchorEvent -{ - public string Delta { get; } - public ResponseDeltaEvent(string delta) => Delta = delta; -} - -public class TurnCompletedEvent : AnchorEvent -{ - public string FullResponse { get; } - public int TokensUsed { get; } - public TurnCompletedEvent(string fullResponse, int tokensUsed) - { - FullResponse = fullResponse; - TokensUsed = tokensUsed; - } -} - -// ContextEvents.cs -public class ContextThresholdReachedEvent : AnchorEvent -{ - public int CurrentTokens { get; } - public int MaxTokens { get; } - public double Percentage { get; } - public ContextThresholdReachedEvent(int currentTokens, int maxTokens) - { - CurrentTokens = currentTokens; - MaxTokens = maxTokens; - Percentage = (double)currentTokens / maxTokens; - } -} - -public class CompactionCompletedEvent : AnchorEvent -{ - public int MessagesRemoved { get; } - public int TokensSaved { get; } - public CompactionCompletedEvent(int messagesRemoved, int tokensSaved) - { - MessagesRemoved = messagesRemoved; - TokensSaved = tokensSaved; - } -} - -// ToolEvents.cs -public class ToolExecutingEvent : AnchorEvent -{ - public string ToolName { get; } - public Dictionary Arguments { get; } - public ToolExecutingEvent(string toolName, Dictionary arguments) - { - ToolName = toolName; - Arguments = arguments; - } -} - -public class ToolCompletedEvent : AnchorEvent -{ - public string ToolName { get; } - public string Result { get; } - public TimeSpan Duration { get; } - public ToolCompletedEvent(string toolName, string result, TimeSpan duration) - { - ToolName = toolName; - Result = result; - Duration = duration; - } -} -``` - -### Event Usage Example - -```csharp -public class ToolOutputRenderer : IToolOutputRenderer -{ - private readonly IEventMultiplexer _events; - private readonly IConsole _console; - - public ToolOutputRenderer(IEventMultiplexer events, IConsole console) - { - _events = events; - _console = console; - _events.Subscribe(OnToolExecuting); - _events.Subscribe(OnToolCompleted); - _events.Subscribe(OnToolFailed); - } - - private void OnToolExecuting(object sender, ToolExecutingEvent e) - { - _console.WriteLine($"[dim]Calling tool: {e.ToolName}[/]"); - } - - private void OnToolCompleted(object sender, ToolCompletedEvent e) - { - _console.WriteLine($"[dim]Tool {e.ToolName} completed in {e.Duration.TotalMilliseconds}ms[/]"); - } - - private void OnToolFailed(object sender, ToolFailedEvent e) - { - _console.WriteLine($"[red]Tool {e.ToolName} failed: {e.Error.Message}[/]"); - } -} -``` - ---- - -## Migration Strategy - -### Phase 1: Foundation (Week 1) - -**Goals:** Set up the new architecture skeleton without breaking existing functionality - -1. **Create new directory structure** - - Create all new folders - - Create interface files - - Set up project file references - -2. **Implement Event System** - - Create `EventMultiplexer` - - Create all event classes - - Add event subscription infrastructure - -3. **Implement AnchorHost** - - Create DI container - - Register all services (can be stubs) - - Implement lifecycle methods - -4. **Refactor Program.cs** - - Replace with new bootstrap code - - Keep existing functionality working via old classes - -**Deliverable:** Project builds and runs with new Program.cs, but still uses old ReplLoop - -### Phase 2: Session Management (Week 2) - -**Goals:** Extract session state and context management - -1. **Create ChatSessionManager** - - Move history management from ReplLoop - - Implement IContextStrategy interface - - Move token tracking logic - -2. **Refactor ContextCompactor** - - Remove static methods - - Implement strategy pattern - - Add DefaultContextStrategy and AggressiveContextStrategy - -3. **Wire up events** - - ChatSessionManager raises context events - - EventMultiplexer distributes to subscribers - -**Deliverable:** Session state is centralized, compaction is pluggable - -### Phase 3: UI Layer (Week 3) - -**Goals:** Extract all UI concerns into dedicated services - -1. **Create IUiRenderer abstraction** - - Define interface for all rendering operations - - Create ReplRenderer implementation - -2. **Create SpinnerService** - - Extract spinner logic from ReplLoop - - Make cancellable and testable - -3. **Create StreamingRenderer** - - Extract streaming output logic - - Handle incremental rendering - -4. **Create ToolOutputRenderer** - - Subscribe to tool events - - Render tool calls/results - -**Deliverable:** All UI code is in UI folder, ReplLoop has no direct console access - -### Phase 4: Input/Output (Week 4) - -**Goals:** Split ReplLoop into InputProcessor and ResponseStreamer - -1. **Create InputProcessor** - - Extract input loop from ReplLoop - - Add command routing - - Wire to ChatSessionManager - -2. **Create ResponseStreamer** - - Extract streaming logic from ReplLoop - - Add token tracking - - Wire to UI renderer - -3. **Create CommandRouter** - - Extract command detection from ReplLoop - - Make commands pluggable - - Add new commands easily - -4. **Delete ReplLoop.cs** - - All functionality migrated - - Remove old file - -**Deliverable:** ReplLoop is gone, replaced by InputProcessor + ResponseStreamer - -### Phase 5: Tool Refactoring (Week 5) - -**Goals:** Remove static state from tools - -1. **Refactor CommandTool** - - Remove static Log delegate - - Accept IEventMultiplexer in constructor - - Raise ToolExecuting/ToolCompleted events - -2. **Refactor FileTools** - - Remove static OnFileRead delegate - - Accept dependencies via constructor - - Raise events instead of calling delegates - -3. **Create ToolRegistry** - - Register all tools - - Provide tool discovery - - Handle tool lifecycle - -**Deliverable:** No static state in tools, all event-driven - -### Phase 6: Configuration & Pricing (Week 6) - -**Goals:** Extract configuration concerns - -1. **Create ConfigLoader** - - Move config loading from Program.cs - - Add validation - - Make testable - -2. **Create PricingService** - - Move pricing fetch from Program.cs - - Add caching - - Add fallback values - -3. **Update AnchorHost** - - Register config and pricing services - - Load config in RunAsync - - Pass to UI renderer - -**Deliverable:** Configuration is properly separated and testable - -### Phase 7: Testing & Cleanup (Week 7) - -**Goals:** Add tests and clean up - -1. **Add unit tests** - - Test ChatSessionManager - - Test InputProcessor - - Test ResponseStreamer - - Test ContextStrategy implementations - - Test CommandRouter - -2. **Add integration tests** - - Test full flow with mock Anthropic client - - Test event propagation - - Test cancellation - -3. **Clean up** - - Remove any remaining old code - - Update documentation - - Add XML docs to public APIs - -**Deliverable:** Fully refactored project with test coverage - ---- - -## Benefits - -### Immediate Benefits - -1. **Testability** - - Every component can be unit tested in isolation - - No static state to mock - - Dependencies are injectable - - Can test with mock Anthropic client - -2. **Maintainability** - - Clear separation of concerns - - Each file has one responsibility - - Easy to find where code lives - - New developers can understand structure quickly - -3. **Extensibility** - - New compaction strategies via IContextStrategy - - New commands via ICommandRouter - - New UI themes via IUiRenderer - - New tools via IToolRegistry - -4. **No Breaking Changes** - - External behavior is identical - - Same commands work the same - - Same output format - - Users notice nothing - -### Long-term Benefits - -1. **Feature Development** - - Add new features without touching unrelated code - - Example: Add "save session" feature → just implement ISessionPersister - - Example: Add "export to markdown" → just implement IExportFormat - -2. **Performance Optimization** - - Can optimize individual components - - Can add caching layers - - Can parallelize independent operations - -3. **Multi-platform Support** - - Easy to add headless mode (different IUiRenderer) - - Easy to add GUI (different IUiRenderer) - - Easy to add web interface (different IUiRenderer) - -4. **Team Collaboration** - - Multiple developers can work on different components - - Less merge conflicts - - Clear ownership boundaries - -### Quantifiable Improvements - -| Metric | Before | After | Improvement | -|--------|--------|-------|-------------| -| Program.cs lines | 153 | 20 | 87% reduction | -| ReplLoop.cs lines | 252 | 0 (split into 3 files) | Better organization | -| Static methods | 8+ | 0 | 100% reduction | -| Testable components | 2 | 15+ | 7.5x increase | -| Coupling between components | High | Low | Event-driven | -| Time to add new command | 30 min | 5 min | 83% faster | -| Time to add new compaction strategy | 1 hour | 10 min | 83% faster | - ---- - -## Risk Mitigation - -### Risk: Breaking Existing Functionality - -**Mitigation:** -- Keep old classes working in parallel during migration -- Run existing tests after each phase -- Manual testing of all commands after each phase -- Feature flags to toggle between old/new if needed - -### Risk: Performance Regression - -**Mitigation:** -- Profile before and after each phase -- Event system is lightweight (in-memory dictionary) -- No additional allocations in hot paths -- Benchmark critical paths (streaming, token counting) - -### Risk: Increased Complexity - -**Mitigation:** -- Interfaces are simple and focused -- Documentation for each component -- XML docs on public APIs -- Example usage in tests -- Gradual migration allows team to learn incrementally - -### Risk: Team Resistance - -**Mitigation:** -- Show benefits with small wins first (Program.cs reduction) -- Phase 1 is low-risk (just skeleton) -- Each phase is reversible -- Demonstrate testability improvements early - ---- - -## Conclusion - -This architectural refactor transforms Anchor CLI from a monolithic, tightly-coupled application into a modular, event-driven system with clear separation of concerns. The new architecture enables: - -- ✅ Easy unit testing of all components -- ✅ Pluggable strategies for compaction, rendering, and commands -- ✅ No static state or global dependencies -- ✅ Clear ownership and boundaries -- ✅ Extensibility without modifying existing code - -The phased migration strategy ensures minimal risk while delivering incremental value. By the end of Phase 7, the codebase will be maintainable, testable, and ready for rapid feature development. - ---- - -## Appendix: File Checklist - -### New Files to Create - -- [ ] `Core/IAnchorHost.cs` -- [ ] `Core/AnchorHost.cs` -- [ ] `Core/IChatSessionManager.cs` -- [ ] `Core/ChatSessionManager.cs` -- [ ] `Core/IContextStrategy.cs` -- [ ] `Core/DefaultContextStrategy.cs` -- [ ] `Core/AggressiveContextStrategy.cs` -- [ ] `Core/EventMultiplexer.cs` -- [ ] `Core/IEventMultiplexer.cs` -- [ ] `Events/ChatEvents.cs` -- [ ] `Events/ContextEvents.cs` -- [ ] `Events/ToolEvents.cs` -- [ ] `Events/SessionEvents.cs` -- [ ] `UI/IUiRenderer.cs` -- [ ] `UI/ReplRenderer.cs` -- [ ] `UI/ISpinnerService.cs` -- [ ] `UI/SpinnerService.cs` -- [ ] `UI/IToolOutputRenderer.cs` -- [ ] `UI/ToolOutputRenderer.cs` -- [ ] `UI/IStreamingRenderer.cs` -- [ ] `UI/StreamingRenderer.cs` -- [ ] `Input/IInputProcessor.cs` -- [ ] `Input/InputProcessor.cs` -- [ ] `Input/ICommandRouter.cs` -- [ ] `Input/CommandRouter.cs` -- [ ] `Input/ICommand.cs` -- [ ] `Streaming/IResponseStreamer.cs` -- [ ] `Streaming/ResponseStreamer.cs` -- [ ] `Streaming/StreamFormatter.cs` -- [ ] `Configuration/IConfigLoader.cs` -- [ ] `Configuration/ConfigLoader.cs` -- [ ] `Configuration/IPricingService.cs` -- [ ] `Configuration/PricingService.cs` -- [ ] `Tools/IToolRegistry.cs` -- [ ] `Tools/ToolRegistry.cs` -- [ ] `Extensions/CancellationTokenExtensions.cs` - -### Files to Modify - -- [ ] `Program.cs` (complete rewrite) -- [ ] `CommandTool.cs` (remove static, add events) -- [ ] `FileTools.cs` (remove static, add events) - -### Files to Delete - -- [ ] `ReplLoop.cs` (after Phase 4) -- [ ] `ContextCompactor.cs` (replaced by ChatSessionManager + strategies) - ---- - -*Document Version: 1.0* -*Last Updated: 2024* -*Author: Architecture Review*