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:
176
ApplicationStartup.cs
Normal file
176
ApplicationStartup.cs
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
using System.ClientModel;
|
||||||
|
using AnchorCli.Commands;
|
||||||
|
using AnchorCli.OpenRouter;
|
||||||
|
using AnchorCli.Providers;
|
||||||
|
using AnchorCli.Tools;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
using OpenAI;
|
||||||
|
using Spectre.Console;
|
||||||
|
|
||||||
|
namespace AnchorCli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encapsulates application startup logic, including configuration loading,
|
||||||
|
/// API client creation, and component initialization.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ApplicationStartup
|
||||||
|
{
|
||||||
|
private readonly string[] _args;
|
||||||
|
private AnchorConfig? _config;
|
||||||
|
private ITokenExtractor? _tokenExtractor;
|
||||||
|
private ModelInfo? _modelInfo;
|
||||||
|
private IChatClient? _chatClient;
|
||||||
|
private TokenTracker? _tokenTracker;
|
||||||
|
|
||||||
|
public ApplicationStartup(string[] args)
|
||||||
|
{
|
||||||
|
_args = args;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AnchorConfig Config => _config ?? throw new InvalidOperationException("Run InitializeAsync first");
|
||||||
|
public string ApiKey => _config?.ApiKey ?? throw new InvalidOperationException("API key not loaded");
|
||||||
|
public string Model => _config?.Model ?? throw new InvalidOperationException("Model not loaded");
|
||||||
|
public string Endpoint => _config?.Endpoint ?? "https://openrouter.ai/api/v1";
|
||||||
|
public string ProviderName => _tokenExtractor?.ProviderName ?? "Unknown";
|
||||||
|
public ITokenExtractor TokenExtractor => _tokenExtractor ?? throw new InvalidOperationException("Token extractor not initialized");
|
||||||
|
public ModelInfo? ModelInfo => _modelInfo;
|
||||||
|
public IChatClient ChatClient => _chatClient ?? throw new InvalidOperationException("Chat client not initialized");
|
||||||
|
public TokenTracker TokenTracker => _tokenTracker ?? throw new InvalidOperationException("Token tracker not initialized");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the setup TUI if the "setup" subcommand was passed. Returns true if setup was run.
|
||||||
|
/// </summary>
|
||||||
|
public bool HandleSetupSubcommand()
|
||||||
|
{
|
||||||
|
if (_args.Length > 0 && _args[0].Equals("setup", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
SetupTui.Run();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes the application by loading configuration and creating the chat client.
|
||||||
|
/// </summary>
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
// Load configuration
|
||||||
|
_config = AnchorConfig.Load();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(_config.ApiKey))
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine("[red]No API key configured. Run [bold]anchor setup[/] first.[/]");
|
||||||
|
throw new InvalidOperationException("API key not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create token extractor
|
||||||
|
_tokenExtractor = ProviderFactory.CreateTokenExtractorForEndpoint(Endpoint);
|
||||||
|
|
||||||
|
// Fetch model pricing (only for OpenRouter)
|
||||||
|
if (ProviderFactory.IsOpenRouter(Endpoint))
|
||||||
|
{
|
||||||
|
await AnsiConsole.Status()
|
||||||
|
.Spinner(Spinner.Known.BouncingBar)
|
||||||
|
.SpinnerStyle(Style.Parse("cornflowerblue"))
|
||||||
|
.StartAsync("Fetching model pricing...", async ctx =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pricingProvider = new OpenRouterProvider();
|
||||||
|
_modelInfo = await pricingProvider.GetModelInfoAsync(Model);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Pricing is best-effort
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create chat client
|
||||||
|
var httpClient = new HttpClient();
|
||||||
|
OpenRouterHeaders.ApplyTo(httpClient);
|
||||||
|
|
||||||
|
var openAiClient = new OpenAIClient(
|
||||||
|
new ApiKeyCredential(ApiKey),
|
||||||
|
new OpenAIClientOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri(Endpoint),
|
||||||
|
Transport = new System.ClientModel.Primitives.HttpClientPipelineTransport(httpClient)
|
||||||
|
});
|
||||||
|
|
||||||
|
_chatClient = openAiClient.GetChatClient(Model).AsIChatClient();
|
||||||
|
|
||||||
|
// Initialize token tracker
|
||||||
|
_tokenTracker = new TokenTracker(new ChatSession(_chatClient))
|
||||||
|
{
|
||||||
|
Provider = _tokenExtractor.ProviderName
|
||||||
|
};
|
||||||
|
|
||||||
|
if (_modelInfo?.Pricing != null)
|
||||||
|
{
|
||||||
|
_tokenTracker.InputPrice = PricingProvider.ParsePrice(_modelInfo.Pricing.Prompt);
|
||||||
|
_tokenTracker.OutputPrice = PricingProvider.ParsePrice(_modelInfo.Pricing.Completion);
|
||||||
|
_tokenTracker.RequestPrice = PricingProvider.ParsePrice(_modelInfo.Pricing.Request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_modelInfo != null)
|
||||||
|
{
|
||||||
|
_tokenTracker.ContextLength = _modelInfo.ContextLength;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new ChatSession with the initialized chat client.
|
||||||
|
/// </summary>
|
||||||
|
public ChatSession CreateSession()
|
||||||
|
{
|
||||||
|
return new ChatSession(ChatClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures tool logging to use Spectre.Console.
|
||||||
|
/// </summary>
|
||||||
|
public void ConfigureToolLogging()
|
||||||
|
{
|
||||||
|
object consoleLock = new();
|
||||||
|
|
||||||
|
void ToolLog(string message)
|
||||||
|
{
|
||||||
|
lock (consoleLock)
|
||||||
|
{
|
||||||
|
Console.Write("\r" + new string(' ', 40) + "\r");
|
||||||
|
AnsiConsole.MarkupLine($"[dim grey]{Markup.Escape(message)}[/]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CommandTool.Log =
|
||||||
|
DirTools.Log =
|
||||||
|
FileTools.Log =
|
||||||
|
EditTools.Log = ToolLog;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates and populates a CommandRegistry with all available commands.
|
||||||
|
/// </summary>
|
||||||
|
public CommandRegistry CreateCommandRegistry(ChatSession session)
|
||||||
|
{
|
||||||
|
var registry = new CommandRegistry();
|
||||||
|
registry.Register(new ExitCommand());
|
||||||
|
registry.Register(new HelpCommand(registry));
|
||||||
|
registry.Register(new ClearCommand());
|
||||||
|
registry.Register(new StatusCommand(Model, Endpoint));
|
||||||
|
registry.Register(new CompactCommand(session.Compactor, session.History));
|
||||||
|
registry.Register(new SetupCommand());
|
||||||
|
registry.Register(new ResetCommand(session, TokenTracker));
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a HeaderRenderer with the current configuration.
|
||||||
|
/// </summary>
|
||||||
|
public HeaderRenderer CreateHeaderRenderer()
|
||||||
|
{
|
||||||
|
return new HeaderRenderer(Model, Endpoint, ProviderName, _modelInfo, _tokenTracker);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
ContextCompactionService.cs
Normal file
61
ContextCompactionService.cs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
using Spectre.Console;
|
||||||
|
using AnchorCli.OpenRouter;
|
||||||
|
|
||||||
|
namespace AnchorCli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles context compaction when the conversation approaches token limits.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ContextCompactionService
|
||||||
|
{
|
||||||
|
private readonly ContextCompactor _compactor;
|
||||||
|
private readonly List<ChatMessage> _history;
|
||||||
|
private readonly TokenTracker _tokenTracker;
|
||||||
|
|
||||||
|
public ContextCompactionService(
|
||||||
|
ContextCompactor compactor,
|
||||||
|
List<ChatMessage> history,
|
||||||
|
TokenTracker tokenTracker)
|
||||||
|
{
|
||||||
|
_compactor = compactor;
|
||||||
|
_history = history;
|
||||||
|
_tokenTracker = tokenTracker;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if compaction is needed and performs it if so.
|
||||||
|
/// Returns true if compaction was performed.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<bool> TryCompactAsync()
|
||||||
|
{
|
||||||
|
if (!_tokenTracker.ShouldCompact())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 _compactor.TryCompactAsync(_history, default));
|
||||||
|
|
||||||
|
if (compacted)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine(
|
||||||
|
$"[green]✓ Context compacted ({_history.Count} messages remaining)[/]");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine(
|
||||||
|
"[dim grey] (compaction skipped — not enough history to compress)[/]");
|
||||||
|
}
|
||||||
|
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
return compacted;
|
||||||
|
}
|
||||||
|
}
|
||||||
110
HeaderRenderer.cs
Normal file
110
HeaderRenderer.cs
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using Spectre.Console;
|
||||||
|
using AnchorCli.OpenRouter;
|
||||||
|
|
||||||
|
namespace AnchorCli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders the application header, including ASCII art logo and configuration info table.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class HeaderRenderer
|
||||||
|
{
|
||||||
|
private readonly string _model;
|
||||||
|
private readonly string _endpoint;
|
||||||
|
private readonly string _providerName;
|
||||||
|
private readonly ModelInfo? _modelInfo;
|
||||||
|
private readonly TokenTracker? _tokenTracker;
|
||||||
|
|
||||||
|
public HeaderRenderer(
|
||||||
|
string model,
|
||||||
|
string endpoint,
|
||||||
|
string providerName,
|
||||||
|
ModelInfo? modelInfo = null,
|
||||||
|
TokenTracker? tokenTracker = null)
|
||||||
|
{
|
||||||
|
_model = model;
|
||||||
|
_endpoint = endpoint;
|
||||||
|
_providerName = providerName;
|
||||||
|
_modelInfo = modelInfo;
|
||||||
|
_tokenTracker = tokenTracker;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders the full header including logo, subtitle, and info table.
|
||||||
|
/// </summary>
|
||||||
|
public void Render()
|
||||||
|
{
|
||||||
|
RenderLogo();
|
||||||
|
RenderSubtitle();
|
||||||
|
RenderInfoTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders the ASCII art logo.
|
||||||
|
/// </summary>
|
||||||
|
public void RenderLogo()
|
||||||
|
{
|
||||||
|
var fontStream = Assembly.GetExecutingAssembly().GetManifestResourceStream("AnchorCli.Assets.3d.flf");
|
||||||
|
if (fontStream != null)
|
||||||
|
{
|
||||||
|
var font = FigletFont.Load(fontStream);
|
||||||
|
AnsiConsole.Write(
|
||||||
|
new FigletText(font, "anchor")
|
||||||
|
.Color(Color.CornflowerBlue));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
AnsiConsole.Write(
|
||||||
|
new FigletText("anchor")
|
||||||
|
.Color(Color.CornflowerBlue));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders the subtitle rule.
|
||||||
|
/// </summary>
|
||||||
|
public void RenderSubtitle()
|
||||||
|
{
|
||||||
|
AnsiConsole.Write(
|
||||||
|
new Rule("[dim]AI-powered coding assistant[/]")
|
||||||
|
.RuleStyle(Style.Parse("cornflowerblue dim"))
|
||||||
|
.LeftJustified());
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders the configuration info table.
|
||||||
|
/// </summary>
|
||||||
|
public void RenderInfoTable()
|
||||||
|
{
|
||||||
|
var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
|
||||||
|
|
||||||
|
var table = new Table()
|
||||||
|
.Border(TableBorder.Rounded)
|
||||||
|
.BorderColor(Color.Grey)
|
||||||
|
.AddColumn(new TableColumn("[dim]Setting[/]").NoWrap())
|
||||||
|
.AddColumn(new TableColumn("[dim]Value[/]"));
|
||||||
|
|
||||||
|
table.AddRow("[grey]Model[/]", $"[cyan]{Markup.Escape(_modelInfo?.Name ?? _model)}[/]");
|
||||||
|
table.AddRow("[grey]Provider[/]", $"[blue]{_providerName}[/]");
|
||||||
|
table.AddRow("[grey]Endpoint[/]", $"[dim]{_endpoint}[/]");
|
||||||
|
table.AddRow("[grey]Version[/]", $"[magenta]{version}[/]");
|
||||||
|
|
||||||
|
if (_modelInfo?.Pricing != null && _tokenTracker != null)
|
||||||
|
{
|
||||||
|
var inM = _tokenTracker.InputPrice * 1_000_000m;
|
||||||
|
var outM = _tokenTracker.OutputPrice * 1_000_000m;
|
||||||
|
table.AddRow("[grey]Pricing[/]",
|
||||||
|
$"[yellow]${inM:F2}[/][dim]/M in[/] [yellow]${outM:F2}[/][dim]/M out[/]");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_modelInfo != null)
|
||||||
|
{
|
||||||
|
table.AddRow("[grey]Context[/]",
|
||||||
|
$"[dim]{_modelInfo.ContextLength:N0} tokens[/]");
|
||||||
|
}
|
||||||
|
|
||||||
|
AnsiConsole.Write(table);
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,13 +5,23 @@ namespace AnchorCli.OpenRouter;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class TokenTracker
|
internal sealed class TokenTracker
|
||||||
{
|
{
|
||||||
private readonly ChatSession _session;
|
private ChatSession _session;
|
||||||
|
|
||||||
public TokenTracker(ChatSession session)
|
public TokenTracker(ChatSession session)
|
||||||
{
|
{
|
||||||
_session = session;
|
_session = session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the session. Allows setting the session after construction
|
||||||
|
/// to support dependency injection patterns.
|
||||||
|
/// </summary>
|
||||||
|
public ChatSession Session
|
||||||
|
{
|
||||||
|
get => _session;
|
||||||
|
set => _session = value;
|
||||||
|
}
|
||||||
|
|
||||||
public string Provider { get; set; } = "Unknown";
|
public string Provider { get; set; } = "Unknown";
|
||||||
public long SessionInputTokens => _session.SessionInputTokens;
|
public long SessionInputTokens => _session.SessionInputTokens;
|
||||||
public long SessionOutputTokens => _session.SessionOutputTokens;
|
public long SessionOutputTokens => _session.SessionOutputTokens;
|
||||||
|
|||||||
207
Program.cs
207
Program.cs
@@ -1,208 +1,45 @@
|
|||||||
using System.ClientModel;
|
|
||||||
using System.Reflection;
|
|
||||||
using AnchorCli.Providers;
|
|
||||||
using Microsoft.Extensions.AI;
|
|
||||||
using OpenAI;
|
|
||||||
using AnchorCli;
|
using AnchorCli;
|
||||||
using AnchorCli.Tools;
|
|
||||||
using AnchorCli.Commands;
|
using AnchorCli.Commands;
|
||||||
using AnchorCli.OpenRouter;
|
|
||||||
using Spectre.Console;
|
using Spectre.Console;
|
||||||
|
|
||||||
Console.InputEncoding = System.Text.Encoding.UTF8;
|
Console.InputEncoding = System.Text.Encoding.UTF8;
|
||||||
Console.OutputEncoding = System.Text.Encoding.UTF8;
|
Console.OutputEncoding = System.Text.Encoding.UTF8;
|
||||||
|
|
||||||
// ── Setup subcommand ─────────────────────────────────────────────────────
|
// ── Application entry point ───────────────────────────────────────────────
|
||||||
if (args.Length > 0 && args[0].Equals("setup", StringComparison.OrdinalIgnoreCase))
|
var startup = new ApplicationStartup(args);
|
||||||
|
|
||||||
|
// Handle setup subcommand
|
||||||
|
if (startup.HandleSetupSubcommand())
|
||||||
{
|
{
|
||||||
SetupTui.Run();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Config ──────────────────────────────────────────────────────────────
|
// Initialize application (load config, create clients, fetch pricing)
|
||||||
var cfg = AnchorConfig.Load();
|
await startup.InitializeAsync();
|
||||||
string apiKey = cfg.ApiKey;
|
|
||||||
string model = cfg.Model;
|
|
||||||
string provider = cfg.Provider ?? "openrouter";
|
|
||||||
string endpoint = cfg.Endpoint ?? "https://openrouter.ai/api/v1";
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(apiKey))
|
// Render header
|
||||||
{
|
var headerRenderer = startup.CreateHeaderRenderer();
|
||||||
AnsiConsole.MarkupLine("[red]No API key configured. Run [bold]anchor setup[/] first.[/]");
|
headerRenderer.Render();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Create token extractor for this provider ───────────────────────────
|
// Configure tool logging
|
||||||
var tokenExtractor = ProviderFactory.CreateTokenExtractorForEndpoint(endpoint);
|
startup.ConfigureToolLogging();
|
||||||
|
|
||||||
// ── Fetch model pricing (only for supported providers) ─────────────────
|
|
||||||
ModelInfo? modelInfo = null;
|
|
||||||
TokenTracker? tokenTracker = null;
|
|
||||||
if (ProviderFactory.IsOpenRouter(endpoint))
|
|
||||||
{
|
|
||||||
await AnsiConsole.Status()
|
|
||||||
.Spinner(Spinner.Known.BouncingBar)
|
|
||||||
.SpinnerStyle(Style.Parse("cornflowerblue"))
|
|
||||||
.StartAsync("Fetching model pricing...", async ctx =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var pricingProvider = new OpenRouterProvider();
|
|
||||||
modelInfo = await pricingProvider.GetModelInfoAsync(model);
|
|
||||||
}
|
|
||||||
catch { /* pricing is best-effort */ }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ── Pretty header ───────────────────────────────────────────────────────
|
|
||||||
var fontStream = Assembly.GetExecutingAssembly().GetManifestResourceStream("AnchorCli.Assets.3d.flf");
|
|
||||||
if (fontStream != null)
|
|
||||||
{
|
|
||||||
var font = FigletFont.Load(fontStream);
|
|
||||||
AnsiConsole.Write(
|
|
||||||
new FigletText(font, "anchor")
|
|
||||||
.Color(Color.CornflowerBlue));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
AnsiConsole.Write(
|
|
||||||
new FigletText("anchor")
|
|
||||||
.Color(Color.CornflowerBlue));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
|
|
||||||
|
|
||||||
// ── Pretty header ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
AnsiConsole.Write(
|
|
||||||
new Rule("[dim]AI-powered coding assistant[/]")
|
|
||||||
.RuleStyle(Style.Parse("cornflowerblue dim"))
|
|
||||||
.LeftJustified());
|
|
||||||
|
|
||||||
AnsiConsole.WriteLine();
|
|
||||||
|
|
||||||
var infoTable = new Table()
|
|
||||||
.Border(TableBorder.Rounded)
|
|
||||||
.BorderColor(Color.Grey)
|
|
||||||
.AddColumn(new TableColumn("[dim]Setting[/]").NoWrap())
|
|
||||||
.AddColumn(new TableColumn("[dim]Value[/]"));
|
|
||||||
|
|
||||||
infoTable.AddRow("[grey]Model[/]", $"[cyan]{Markup.Escape(modelInfo?.Name ?? model)}[/]");
|
|
||||||
infoTable.AddRow("[grey]Provider[/]", $"[blue]{tokenExtractor.ProviderName}[/]");
|
|
||||||
infoTable.AddRow("[grey]Endpoint[/]", $"[dim]{endpoint}[/]");
|
|
||||||
infoTable.AddRow("[grey]Version[/]", $"[magenta]{version}[/]");
|
|
||||||
|
|
||||||
if (modelInfo?.Pricing != null && tokenTracker != null)
|
|
||||||
|
|
||||||
{
|
|
||||||
var inM = tokenTracker.InputPrice * 1_000_000m;
|
|
||||||
var outM = tokenTracker.OutputPrice * 1_000_000m;
|
|
||||||
infoTable.AddRow("[grey]Pricing[/]",
|
|
||||||
$"[yellow]${inM:F2}[/][dim]/M in[/] [yellow]${outM:F2}[/][dim]/M out[/]");
|
|
||||||
}
|
|
||||||
if (modelInfo != null)
|
|
||||||
{
|
|
||||||
infoTable.AddRow("[grey]Context[/]",
|
|
||||||
$"[dim]{modelInfo.ContextLength:N0} tokens[/]");
|
|
||||||
}
|
|
||||||
|
|
||||||
AnsiConsole.Write(infoTable);
|
|
||||||
AnsiConsole.WriteLine();
|
|
||||||
|
|
||||||
// ── Build the chat client with tool-calling support ─────────────────────
|
|
||||||
var httpClient = new HttpClient();
|
|
||||||
OpenRouterHeaders.ApplyTo(httpClient);
|
|
||||||
|
|
||||||
var openAiClient = new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClientOptions
|
|
||||||
{
|
|
||||||
Endpoint = new Uri(endpoint),
|
|
||||||
Transport = new System.ClientModel.Primitives.HttpClientPipelineTransport(httpClient)
|
|
||||||
});
|
|
||||||
|
|
||||||
IChatClient innerClient = openAiClient.GetChatClient(model).AsIChatClient();
|
|
||||||
|
|
||||||
// ── Tool call logging via Spectre ───────────────────────────────────────
|
|
||||||
object consoleLock = new();
|
|
||||||
|
|
||||||
void ToolLog(string message)
|
|
||||||
{
|
|
||||||
lock (consoleLock)
|
|
||||||
{
|
|
||||||
Console.Write("\r" + new string(' ', 40) + "\r");
|
|
||||||
AnsiConsole.MarkupLine($"[dim grey]{Markup.Escape(message)}[/]");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CommandTool.Log =
|
|
||||||
DirTools.Log =
|
|
||||||
FileTools.Log =
|
|
||||||
EditTools.Log = ToolLog;
|
|
||||||
|
|
||||||
// ── Instantiate Core Components ──────────────────────────────────────────
|
|
||||||
|
|
||||||
var session = new ChatSession(innerClient);
|
|
||||||
tokenTracker = new TokenTracker(session) { Provider = tokenExtractor.ProviderName };
|
|
||||||
if (modelInfo != null)
|
|
||||||
{
|
|
||||||
if (modelInfo.Pricing != null)
|
|
||||||
{
|
|
||||||
tokenTracker.InputPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Prompt);
|
|
||||||
tokenTracker.OutputPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Completion);
|
|
||||||
tokenTracker.RequestPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Request);
|
|
||||||
}
|
|
||||||
tokenTracker.ContextLength = modelInfo.ContextLength;
|
|
||||||
}
|
|
||||||
|
|
||||||
var commandRegistry = new CommandRegistry();
|
|
||||||
commandRegistry.Register(new ExitCommand());
|
|
||||||
commandRegistry.Register(new HelpCommand(commandRegistry));
|
|
||||||
commandRegistry.Register(new ClearCommand());
|
|
||||||
commandRegistry.Register(new StatusCommand(model, endpoint));
|
|
||||||
commandRegistry.Register(new CompactCommand(session.Compactor, session.History));
|
|
||||||
commandRegistry.Register(new SetupCommand());
|
|
||||||
commandRegistry.Register(new ResetCommand(session, tokenTracker));
|
|
||||||
|
|
||||||
|
// Create core components
|
||||||
|
var session = startup.CreateSession();
|
||||||
|
startup.TokenTracker.Session = session;
|
||||||
|
|
||||||
|
var commandRegistry = startup.CreateCommandRegistry(session);
|
||||||
var commandDispatcher = new CommandDispatcher(commandRegistry);
|
var commandDispatcher = new CommandDispatcher(commandRegistry);
|
||||||
|
|
||||||
// ── Run Repl ────────────────────────────────────────────────────────────
|
// Create session manager
|
||||||
|
var sessionManager = new SessionManager(session);
|
||||||
|
|
||||||
// Auto-load session if it exists
|
// Auto-load session if it exists
|
||||||
const string sessionPath = ".anchor/session.json";
|
await sessionManager.TryLoadAsync();
|
||||||
if (File.Exists(sessionPath))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await session.LoadAsync(sessionPath, default);
|
|
||||||
AnsiConsole.MarkupLine($"[dim grey]Auto-loaded previous session.[/]");
|
|
||||||
|
|
||||||
// Print the last message if there is one
|
// Run REPL loop
|
||||||
if (session.History.Count > 1)
|
var repl = new ReplLoop(session, startup.TokenTracker, commandDispatcher, sessionManager);
|
||||||
{
|
|
||||||
var lastMessage = session.History[^1];
|
|
||||||
var preview = lastMessage.Text.Length > 280
|
|
||||||
? lastMessage.Text[..277] + "..."
|
|
||||||
: lastMessage.Text;
|
|
||||||
AnsiConsole.MarkupLine($"[dim grey] Last message: {Markup.Escape(preview)}[/]");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch { /* Ignore load errors on startup */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
var repl = new ReplLoop(session, tokenTracker, commandDispatcher);
|
|
||||||
await repl.RunAsync();
|
await repl.RunAsync();
|
||||||
|
|
||||||
// Auto-save session on clean exit
|
// Auto-save session on clean exit
|
||||||
try
|
await sessionManager.TrySaveAsync();
|
||||||
{
|
|
||||||
var directory = Path.GetDirectoryName(sessionPath);
|
|
||||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(directory);
|
|
||||||
}
|
|
||||||
await session.SaveAsync(sessionPath, default);
|
|
||||||
}
|
|
||||||
catch { /* Ignore save errors on exit */ }
|
|
||||||
|
|
||||||
|
|||||||
308
ReplLoop.cs
308
ReplLoop.cs
@@ -1,23 +1,38 @@
|
|||||||
using Microsoft.Extensions.AI;
|
using System.Text;
|
||||||
using OpenAI;
|
|
||||||
using Spectre.Console;
|
using Spectre.Console;
|
||||||
using AnchorCli.OpenRouter;
|
|
||||||
using AnchorCli.Commands;
|
using AnchorCli.Commands;
|
||||||
using AnchorCli.Tools;
|
using AnchorCli.Tools;
|
||||||
|
using AnchorCli.OpenRouter;
|
||||||
|
|
||||||
namespace AnchorCli;
|
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
|
internal sealed class ReplLoop
|
||||||
{
|
{
|
||||||
private readonly ChatSession _session;
|
private readonly ChatSession _session;
|
||||||
private readonly TokenTracker _tokenTracker;
|
private readonly TokenTracker _tokenTracker;
|
||||||
private readonly CommandDispatcher _commandDispatcher;
|
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;
|
_session = session;
|
||||||
_tokenTracker = tokenTracker;
|
_tokenTracker = tokenTracker;
|
||||||
_commandDispatcher = commandDispatcher;
|
_commandDispatcher = commandDispatcher;
|
||||||
|
_sessionManager = sessionManager;
|
||||||
|
_streamer = new ResponseStreamer(session);
|
||||||
|
_usageDisplayer = new UsageDisplayer(tokenTracker);
|
||||||
|
_compactionService = new ContextCompactionService(session.Compactor, session.History, tokenTracker);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RunAsync()
|
public async Task RunAsync()
|
||||||
@@ -29,7 +44,7 @@ internal sealed class ReplLoop
|
|||||||
|
|
||||||
Console.CancelKeyPress += (_, e) =>
|
Console.CancelKeyPress += (_, e) =>
|
||||||
{
|
{
|
||||||
e.Cancel = true; // Prevent process termination
|
e.Cancel = true;
|
||||||
responseCts?.Cancel();
|
responseCts?.Cancel();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -42,215 +57,23 @@ internal sealed class ReplLoop
|
|||||||
|
|
||||||
if (await _commandDispatcher.TryExecuteAsync(input, default)) continue;
|
if (await _commandDispatcher.TryExecuteAsync(input, default)) continue;
|
||||||
|
|
||||||
_session.History.Add(new ChatMessage(ChatRole.User, input));
|
_session.History.Add(new Microsoft.Extensions.AI.ChatMessage(Microsoft.Extensions.AI.ChatRole.User, input));
|
||||||
int turnStartIndex = _session.History.Count;
|
|
||||||
|
|
||||||
AnsiConsole.WriteLine();
|
AnsiConsole.WriteLine();
|
||||||
|
|
||||||
|
responseCts?.Dispose();
|
||||||
responseCts = new CancellationTokenSource();
|
responseCts = new CancellationTokenSource();
|
||||||
string fullResponse = "";
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var stream = _session
|
await ProcessTurnAsync(responseCts.Token);
|
||||||
.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)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
AnsiConsole.WriteLine();
|
HandleCancellation();
|
||||||
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
AnsiConsole.WriteLine();
|
DisplayError(ex);
|
||||||
AnsiConsole.Write(
|
|
||||||
new Panel($"[red]{Markup.Escape(ex.Message)}[/]")
|
|
||||||
.Header("[bold red] Error [/]")
|
|
||||||
.BorderColor(Color.Red)
|
|
||||||
.RoundedBorder()
|
|
||||||
.Padding(1, 0));
|
|
||||||
AnsiConsole.WriteLine();
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -258,7 +81,84 @@ internal sealed class ReplLoop
|
|||||||
responseCts = null;
|
responseCts = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
}
|
||||||
|
|
||||||
|
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.WriteLine();
|
||||||
AnsiConsole.Write(
|
AnsiConsole.Write(
|
||||||
@@ -270,5 +170,3 @@ internal sealed class ReplLoop
|
|||||||
AnsiConsole.WriteLine();
|
AnsiConsole.WriteLine();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
60
ResponseStreamer.cs
Normal file
60
ResponseStreamer.cs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
using OpenAI;
|
||||||
|
|
||||||
|
namespace AnchorCli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles streaming responses from the chat client, including token usage capture.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ResponseStreamer
|
||||||
|
{
|
||||||
|
private readonly ChatSession _session;
|
||||||
|
|
||||||
|
public ResponseStreamer(ChatSession session)
|
||||||
|
{
|
||||||
|
_session = session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Streams a response from the session and captures token usage.
|
||||||
|
/// Returns an async enumerable that yields text chunks as they arrive.
|
||||||
|
/// </summary>
|
||||||
|
public async IAsyncEnumerable<string> StreamAsync(
|
||||||
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var stream = _session
|
||||||
|
.GetStreamingResponseAsync(cancellationToken)
|
||||||
|
.GetAsyncEnumerator(cancellationToken);
|
||||||
|
|
||||||
|
int respIn = 0, respOut = 0;
|
||||||
|
|
||||||
|
void CaptureUsage(ChatResponseUpdate update)
|
||||||
|
{
|
||||||
|
if (update.RawRepresentation is OpenAI.Chat.StreamingChatCompletionUpdate raw
|
||||||
|
&& raw.Usage != null)
|
||||||
|
{
|
||||||
|
respIn = raw.Usage.InputTokenCount;
|
||||||
|
respOut += raw.Usage.OutputTokenCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream all chunks
|
||||||
|
while (await stream.MoveNextAsync())
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
CaptureUsage(stream.Current);
|
||||||
|
var text = stream.Current.Text;
|
||||||
|
if (!string.IsNullOrEmpty(text))
|
||||||
|
{
|
||||||
|
yield return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store final usage stats
|
||||||
|
LastInputTokens = respIn;
|
||||||
|
LastOutputTokens = respOut;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int LastInputTokens { get; private set; }
|
||||||
|
public int LastOutputTokens { get; private set; }
|
||||||
|
}
|
||||||
83
SessionManager.cs
Normal file
83
SessionManager.cs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
using Spectre.Console;
|
||||||
|
|
||||||
|
namespace AnchorCli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages session persistence, including auto-load on startup and auto-save on exit.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class SessionManager
|
||||||
|
{
|
||||||
|
private readonly ChatSession _session;
|
||||||
|
private readonly string _sessionPath;
|
||||||
|
|
||||||
|
public SessionManager(ChatSession session, string sessionPath = ".anchor/session.json")
|
||||||
|
{
|
||||||
|
_session = session;
|
||||||
|
_sessionPath = sessionPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to load a session from disk. Returns true if successful.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<bool> TryLoadAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!File.Exists(_sessionPath))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _session.LoadAsync(_sessionPath, cancellationToken);
|
||||||
|
AnsiConsole.MarkupLine($"[dim grey]Auto-loaded previous session.[/]");
|
||||||
|
|
||||||
|
// Print the last message if there is one
|
||||||
|
if (_session.History.Count > 1)
|
||||||
|
{
|
||||||
|
var lastMessage = _session.History[^1];
|
||||||
|
var preview = lastMessage.Text.Length > 280
|
||||||
|
? lastMessage.Text[..277] + "..."
|
||||||
|
: lastMessage.Text;
|
||||||
|
AnsiConsole.MarkupLine($"[dim grey] Last message: {Markup.Escape(preview)}[/]");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore load errors
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to save the session to disk. Returns true if successful.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<bool> TrySaveAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var directory = Path.GetDirectoryName(_sessionPath);
|
||||||
|
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _session.SaveAsync(_sessionPath, cancellationToken);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore save errors
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves the session after an LLM turn completes.
|
||||||
|
/// </summary>
|
||||||
|
public async Task SaveAfterTurnAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await TrySaveAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
100
SpinnerService.cs
Normal file
100
SpinnerService.cs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
using Spectre.Console;
|
||||||
|
|
||||||
|
namespace AnchorCli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages the "thinking" spinner animation during AI response generation.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class SpinnerService : IDisposable
|
||||||
|
{
|
||||||
|
private readonly object _consoleLock = new();
|
||||||
|
private CancellationTokenSource? _spinnerCts;
|
||||||
|
private Task? _spinnerTask;
|
||||||
|
private bool _showSpinner = true;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts the spinner animation.
|
||||||
|
/// </summary>
|
||||||
|
public void Start(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_spinnerCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
_showSpinner = true;
|
||||||
|
|
||||||
|
_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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops the spinner animation and waits for it to complete.
|
||||||
|
/// </summary>
|
||||||
|
public async Task StopAsync()
|
||||||
|
{
|
||||||
|
_spinnerCts?.Cancel();
|
||||||
|
if (_spinnerTask != null)
|
||||||
|
{
|
||||||
|
await Task.WhenAny(_spinnerTask);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pauses the spinner (e.g., during tool execution).
|
||||||
|
/// </summary>
|
||||||
|
public void Pause()
|
||||||
|
{
|
||||||
|
lock (_consoleLock)
|
||||||
|
{
|
||||||
|
_showSpinner = false;
|
||||||
|
Console.Write("\r" + new string(' ', 40) + "\r");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resumes the spinner after being paused.
|
||||||
|
/// </summary>
|
||||||
|
public void Resume()
|
||||||
|
{
|
||||||
|
lock (_consoleLock)
|
||||||
|
{
|
||||||
|
_showSpinner = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_spinnerCts?.Dispose();
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
48
UsageDisplayer.cs
Normal file
48
UsageDisplayer.cs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
using Spectre.Console;
|
||||||
|
|
||||||
|
namespace AnchorCli.OpenRouter;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Displays token usage and cost information to the console.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class UsageDisplayer
|
||||||
|
{
|
||||||
|
private readonly TokenTracker _tokenTracker;
|
||||||
|
|
||||||
|
public UsageDisplayer(TokenTracker tokenTracker)
|
||||||
|
{
|
||||||
|
_tokenTracker = tokenTracker;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Displays the usage statistics for a single response.
|
||||||
|
/// </summary>
|
||||||
|
public void Display(int inputTokens, int outputTokens)
|
||||||
|
{
|
||||||
|
if (inputTokens > 0 || outputTokens > 0)
|
||||||
|
{
|
||||||
|
_tokenTracker.AddUsage(inputTokens, outputTokens);
|
||||||
|
var cost = _tokenTracker.CalculateCost(inputTokens, outputTokens);
|
||||||
|
var ctxPct = _tokenTracker.ContextUsagePercent;
|
||||||
|
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
AnsiConsole.MarkupLine(
|
||||||
|
$"[dim grey] {TokenTracker.FormatTokens(inputTokens)}↑ {TokenTracker.FormatTokens(outputTokens)}↓" +
|
||||||
|
$" {TokenTracker.FormatCost(cost)}" +
|
||||||
|
(ctxPct >= 0 ? $" ctx:{ctxPct:F0}%" : "") +
|
||||||
|
$" │ session: {TokenTracker.FormatCost(_tokenTracker.SessionCost)}[/]");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Displays a rule separator.
|
||||||
|
/// </summary>
|
||||||
|
public void DisplaySeparator()
|
||||||
|
{
|
||||||
|
AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("grey dim")));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user