1
0
Files
AnchorCli/ReplLoop.cs
Tomi Eckert 1e943e6566 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.
2026-03-11 16:59:06 +01:00

173 lines
5.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text;
using Spectre.Console;
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,
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()
{
AnsiConsole.MarkupLine("[dim]Press [bold]Ctrl+C[/] to cancel the current response.[/]");
AnsiConsole.WriteLine();
CancellationTokenSource? responseCts = null;
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true;
responseCts?.Cancel();
};
while (true)
{
AnsiConsole.Markup("[grey] [/]");
string input = InputProcessor.ReadLine("Type your message, or use [bold]/help[/] to see commands.");
if (string.IsNullOrWhiteSpace(input)) continue;
if (await _commandDispatcher.TryExecuteAsync(input, default)) continue;
_session.History.Add(new Microsoft.Extensions.AI.ChatMessage(Microsoft.Extensions.AI.ChatRole.User, input));
AnsiConsole.WriteLine();
responseCts?.Dispose();
responseCts = new CancellationTokenSource();
try
{
await ProcessTurnAsync(responseCts.Token);
}
catch (OperationCanceledException)
{
HandleCancellation();
}
catch (Exception ex)
{
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();
}
}