using Microsoft.Extensions.AI; using OpenAI; using Spectre.Console; using AnchorCli.OpenRouter; using AnchorCli.Commands; using AnchorCli.Tools; namespace AnchorCli; internal sealed class ReplLoop { private readonly ChatSession _session; private readonly TokenTracker _tokenTracker; private readonly CommandDispatcher _commandDispatcher; public ReplLoop(ChatSession session, TokenTracker tokenTracker, CommandDispatcher commandDispatcher) { _session = session; _tokenTracker = tokenTracker; _commandDispatcher = commandDispatcher; } 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; // Prevent process termination 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 ChatMessage(ChatRole.User, input)); int turnStartIndex = _session.History.Count; AnsiConsole.WriteLine(); 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; } } 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(); } } } }