1
0

refactor: apply Single Responsibility Principle to Program.cs and ReplLoop.cs

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.
This commit is contained in:
2026-03-11 16:59:06 +01:00
parent ccfa7e1b9d
commit 1e943e6566
10 changed files with 791 additions and 408 deletions

176
ApplicationStartup.cs Normal file
View File

@@ -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;
/// <summary>
/// Encapsulates application startup logic, including configuration loading,
/// API client creation, and component initialization.
/// </summary>
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");
/// <summary>
/// Runs the setup TUI if the "setup" subcommand was passed. Returns true if setup was run.
/// </summary>
public bool HandleSetupSubcommand()
{
if (_args.Length > 0 && _args[0].Equals("setup", StringComparison.OrdinalIgnoreCase))
{
SetupTui.Run();
return true;
}
return false;
}
/// <summary>
/// Initializes the application by loading configuration and creating the chat client.
/// </summary>
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;
}
}
/// <summary>
/// Creates a new ChatSession with the initialized chat client.
/// </summary>
public ChatSession CreateSession()
{
return new ChatSession(ChatClient);
}
/// <summary>
/// Configures tool logging to use Spectre.Console.
/// </summary>
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;
}
/// <summary>
/// Creates and populates a CommandRegistry with all available commands.
/// </summary>
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;
}
/// <summary>
/// Creates a HeaderRenderer with the current configuration.
/// </summary>
public HeaderRenderer CreateHeaderRenderer()
{
return new HeaderRenderer(Model, Endpoint, ProviderName, _modelInfo, _tokenTracker);
}
}