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

View File

@@ -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();