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.
173 lines
5.7 KiB
C#
173 lines
5.7 KiB
C#
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();
|
||
}
|
||
}
|