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,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;
/// <summary>
/// Manages the interactive REPL (Read-Eval-Print Loop) for user interaction.
/// Orchestrates input handling, command dispatching, and response display.
/// </summary>
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();
}
}