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:
342
ReplLoop.cs
342
ReplLoop.cs
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user