Compare commits
5 Commits
c9515a822d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f187348d7 | |||
| fe006a5256 | |||
| ef30a2254b | |||
| 1e943e6566 | |||
| ccfa7e1b9d |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -2,4 +2,7 @@ bin
|
|||||||
obj
|
obj
|
||||||
.vscode
|
.vscode
|
||||||
publish
|
publish
|
||||||
.anchor
|
.anchor
|
||||||
|
.idea
|
||||||
|
.vs
|
||||||
|
.crush
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
using Spectre.Console;
|
using Spectre.Console;
|
||||||
namespace AnchorCli.Commands;
|
namespace AnchorCli.Commands;
|
||||||
|
|
||||||
public class ExitCommand : ICommand
|
public class ExitCommand : ICommand
|
||||||
{
|
{
|
||||||
public string Name => "exit";
|
public string Name => "exit";
|
||||||
public string Description => "Exit the application";
|
public string Description => "Exit the application";
|
||||||
|
|
||||||
public Task ExecuteAsync(string[] args, CancellationToken ct)
|
public Task ExecuteAsync(string[] args, CancellationToken ct)
|
||||||
{
|
{
|
||||||
AnsiConsole.MarkupLine("[green]Goodbye![/]");
|
AnsiConsole.MarkupLine("[green]Goodbye![/]");
|
||||||
Environment.Exit(0);
|
Console.CursorVisible = true;
|
||||||
return Task.CompletedTask;
|
Environment.Exit(0);
|
||||||
}
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
81
IDEAS.md
81
IDEAS.md
@@ -1,81 +0,0 @@
|
|||||||
# Command Ideas for AnchorCli
|
|
||||||
|
|
||||||
## Session & Help
|
|
||||||
|
|
||||||
### `/help`
|
|
||||||
Show available commands, version info, and tool capabilities. Combines `/help`, `/version`, `/about`, and `/tools`.
|
|
||||||
|
|
||||||
### `/clear`
|
|
||||||
Clear the terminal screen and optionally reset conversation with `/clear --reset`.
|
|
||||||
|
|
||||||
### `/history`
|
|
||||||
Show the current chat history. Use `/history <n>` to show last N messages.
|
|
||||||
|
|
||||||
## Navigation
|
|
||||||
|
|
||||||
### `/cd [path]`
|
|
||||||
Change directory. With no argument, shows current working directory (combines `/cwd`, `/pwd`, `/cd`).
|
|
||||||
|
|
||||||
### `/ls`
|
|
||||||
List files in current directory (alias for ListDir tool).
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### `/config`
|
|
||||||
Show or modify settings. Subcommands:
|
|
||||||
- `/config model <name>` - Change AI model
|
|
||||||
- `/config endpoint <url>` - Change API endpoint
|
|
||||||
- `/config debug <on|off>` - Toggle debug mode
|
|
||||||
|
|
||||||
## Conversation Management
|
|
||||||
|
|
||||||
### `/save [filename]`
|
|
||||||
Save current conversation to a file (JSON or markdown format).
|
|
||||||
|
|
||||||
### `/load <filename>`
|
|
||||||
Load a previous conversation from a file.
|
|
||||||
|
|
||||||
### `/export <filename>`
|
|
||||||
Export chat history in a specific format (JSON, markdown, plain text).
|
|
||||||
|
|
||||||
## Advanced Features
|
|
||||||
|
|
||||||
### `/undo`
|
|
||||||
Undo the last file edit (requires edit history tracking).
|
|
||||||
|
|
||||||
### `/diff [file]`
|
|
||||||
Show differences between current and original file state. With no argument, shows all pending changes.
|
|
||||||
|
|
||||||
### `/search <pattern>`
|
|
||||||
Quick file/content search across the project.
|
|
||||||
|
|
||||||
### `/stats`
|
|
||||||
Show session statistics (files edited, tokens used, commands run, estimated costs).
|
|
||||||
|
|
||||||
### `/macro <name> [commands...]`
|
|
||||||
Create and execute multi-step command sequences.
|
|
||||||
|
|
||||||
### `/alias <name> <command>`
|
|
||||||
Create custom command shortcuts.
|
|
||||||
|
|
||||||
## Safety & Integration
|
|
||||||
|
|
||||||
### `--dry-run` / Read-only Mode
|
|
||||||
Run Anchor without mutating any files. Shows what *would* happen (edits, deletes, renames) without applying changes. Perfect for reviewing AI suggestions before committing.
|
|
||||||
|
|
||||||
### Git Integration
|
|
||||||
Seamless version control integration:
|
|
||||||
- Auto-create a branch per session (`anchor session --git-branch`)
|
|
||||||
- Auto-commit after successful edits with descriptive messages
|
|
||||||
- Show git diff before/after operations
|
|
||||||
- Revert to pre-session state if something goes wrong
|
|
||||||
|
|
||||||
### Mutation Rate Limits
|
|
||||||
Prevent runaway AI from trashing a project:
|
|
||||||
- Configurable max file edits per conversation turn
|
|
||||||
- Hard cap on delete/rename operations without confirmation
|
|
||||||
- Cooldown period after N rapid mutations
|
|
||||||
- Warning when approaching limits
|
|
||||||
|
|
||||||
### File Type Restrictions
|
|
||||||
Config to block edits on sensitive patterns (`*.config`, `*.sql`, `*.production.*`, etc.). Requires explicit override flag.
|
|
||||||
90
IMPROVEME.md
90
IMPROVEME.md
@@ -1,90 +0,0 @@
|
|||||||
# Improvements for AnchorCli
|
|
||||||
|
|
||||||
This document contains criticisms and suggestions for improving the AnchorCli project.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
1. **Program.cs is too large (433 lines)** - Split into smaller classes: ChatSession, ReplLoop, ResponseStreamer
|
|
||||||
2. **No dependency injection** - Use Microsoft.Extensions.DependencyInjection for testability
|
|
||||||
3. **Static tool classes with global Log delegates** - Convert to instance classes with injected ILogger
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
4. **No unit tests** - Add xUnit project, test HashlineEncoder/Validator, tools, and ContextCompactor
|
|
||||||
5. **No integration tests** - Use Spectre.Console.Testing for TUI workflows
|
|
||||||
6. **No CI/CD** - Add GitHub Actions for test runs on push/PR
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
7. **Missing XML docs** - Add summary docs to public APIs
|
|
||||||
8. **Incomplete README** - Add contributing, development, troubleshooting sections
|
|
||||||
9. **No CHANGELOG.md** - Track releases and changes
|
|
||||||
|
|
||||||
## Security & Safety
|
|
||||||
|
|
||||||
10. **Command execution unsandboxed** - Add allowlist/denylist, time limits, output size limits
|
|
||||||
11. **No mutation rate limiting** - Track edits per turn, add configurable limits
|
|
||||||
12. **API key in plain text** - Use OS keychain or env var, set restrictive file permissions
|
|
||||||
|
|
||||||
## Performance
|
|
||||||
|
|
||||||
13. **No file read caching** - Cache file content per-turn with invalidation on write
|
|
||||||
14. **Regex not static** - Make compiled regexes static readonly
|
|
||||||
|
|
||||||
## User Experience
|
|
||||||
|
|
||||||
15. **No undo** - Store edit history, add /undo command
|
|
||||||
16. **No session persistence** - Add /save and /load commands
|
|
||||||
17. **Limited error recovery** - Better error messages, /debug mode
|
|
||||||
|
|
||||||
## Developer Experience
|
|
||||||
|
|
||||||
18. **No .editorconfig** - Add code style enforcement
|
|
||||||
19. **No solution file** - Create AnchorCli.sln
|
|
||||||
20. **Hardcoded model list** - Fetch from OpenRouter API dynamically
|
|
||||||
21. **No version info** - Add <Version> to .csproj, display in /help
|
|
||||||
|
|
||||||
## Code Quality
|
|
||||||
|
|
||||||
22. **Inconsistent error handling** - Standardize on error strings, avoid empty catch blocks
|
|
||||||
23. **Magic numbers** - Extract to named constants (150_000, 300, KeepRecentTurns=2)
|
|
||||||
24. **Commented-out debug code** - Remove or use #if DEBUG
|
|
||||||
25. **Weak hash algorithm** - Adler-8 XOR only has 256 values; consider 4-char hex
|
|
||||||
|
|
||||||
## Build & Dependencies
|
|
||||||
|
|
||||||
26. **No LICENSE file** - Add MIT LICENSE file
|
|
||||||
|
|
||||||
## Priority
|
|
||||||
|
|
||||||
### High
|
|
||||||
- [ ] Add unit tests
|
|
||||||
- [ ] Implement undo functionality
|
|
||||||
- [ ] Add mutation rate limiting
|
|
||||||
- [x] Refactor Program.cs
|
|
||||||
- [x] Add LICENSE file
|
|
||||||
|
|
||||||
### Medium
|
|
||||||
- [ ] Session persistence
|
|
||||||
- [ ] XML documentation
|
|
||||||
- [ ] Error handling consistency
|
|
||||||
- [x] .editorconfig
|
|
||||||
- [ ] Dynamic model list
|
|
||||||
|
|
||||||
### Low
|
|
||||||
- [ ] CHANGELOG.md
|
|
||||||
- [ ] CI/CD pipeline
|
|
||||||
- [ ] Stronger hash algorithm
|
|
||||||
- [ ] Code coverage reporting
|
|
||||||
|
|
||||||
## Quick Wins (<1 hour each)
|
|
||||||
|
|
||||||
- [x] Add <Version> to .csproj
|
|
||||||
- [x] Create LICENSE file
|
|
||||||
- [x] Add .editorconfig
|
|
||||||
- [x] Remove commented code
|
|
||||||
- [x] Extract magic numbers to constants
|
|
||||||
- [x] Add XML docs to Hashline classes
|
|
||||||
- [x] Make regexes static readonly
|
|
||||||
|
|
||||||
*Prioritize based on goals: safety, testability, or user experience.*
|
|
||||||
@@ -5,12 +5,22 @@ 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;
|
||||||
|
|||||||
293
PROVIDERS.md
293
PROVIDERS.md
@@ -1,293 +0,0 @@
|
|||||||
# Provider Support Plan
|
|
||||||
|
|
||||||
## Current Problems
|
|
||||||
|
|
||||||
1. **OpenRouter Hardcoded**: Endpoint, headers, and pricing API calls are hardcoded to OpenRouter
|
|
||||||
2. **Config Ineffective**: SetupTui allows "custom endpoint" but Program.cs ignores it
|
|
||||||
3. **Token Count**: Token usage tracking only works with OpenRouter response headers
|
|
||||||
4. **Pricing Only for One Provider**: Models list shows pricing, but only when using OpenRouter
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
|
|
||||||
1. Make the system **endpoint-agnostic**
|
|
||||||
2. Support pricing/token tracking for **multiple providers**
|
|
||||||
3. Keep **OpenRouter as the default** (familiar)
|
|
||||||
4. Allow users to configure any OpenAI-compatible endpoint
|
|
||||||
5. Show pricing/token info **only when available** for each provider
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Provider Categories
|
|
||||||
|
|
||||||
### Tier 1: Native Support (Built-in)
|
|
||||||
- OpenRouter (default)
|
|
||||||
- Ollama (local, no auth)
|
|
||||||
- Groq (high-speed inference)
|
|
||||||
- Anthropic (native or via API)
|
|
||||||
- OpenAI (official api)
|
|
||||||
|
|
||||||
### Tier 2: Config-Based Support
|
|
||||||
- Cerebras
|
|
||||||
- DeepSeek
|
|
||||||
- Any OpenAI-compatible endpoint that supports custom headers
|
|
||||||
|
|
||||||
### Tier 3: Manual Configuration Required
|
|
||||||
- Self-hosted endpoints
|
|
||||||
- Corporate proxies
|
|
||||||
- Custom middleware layers
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Example: Provider interface
|
|
||||||
class PricingProvider
|
|
||||||
{
|
|
||||||
// Get pricing info from provider's API
|
|
||||||
async Task<List<ModelPricing>> GetModelsAsync(string apiKey);
|
|
||||||
|
|
||||||
// Get tokens from response
|
|
||||||
async Task<TokenUsage> GetTokensFromResponseAsync(HttpResponseMessage response);
|
|
||||||
|
|
||||||
// Add provider-specific headers if needed
|
|
||||||
void AddHeaders(HttpRequestMessage request, string apiKey);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Supported Implementations:**
|
|
||||||
- `OpenRouterProvider` (uses `/api/v1/models` + `x-total-tokens`)
|
|
||||||
- `GroqProvider` (uses Groq's pricing API + response headers)
|
|
||||||
- `OllamaProvider` (free tier, no pricing lookup, basic token counting)
|
|
||||||
- `OpenAIProvider` (uses OpenAI's model list + token counting)
|
|
||||||
- `GenericProvider` (fallback for any OpenAI-compatible endpoint)
|
|
||||||
|
|
||||||
**Configuration:**
|
|
||||||
Store provider selection in `anchor.config.json`:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"apiKey": "your-key",
|
|
||||||
"model": "qwen3.5-27b",
|
|
||||||
"endpoint": "https://openrouter.ai/api/v1",
|
|
||||||
"provider": "openrouter"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Auto-detect provider from endpoint URL if not specified.
|
|
||||||
---
|
|
||||||
|
|
||||||
## Pricing System
|
|
||||||
|
|
||||||
### Current State
|
|
||||||
- Uses OpenRouter's `/api/v1/models` endpoint
|
|
||||||
- Displays pricing in a table during startup
|
|
||||||
- Only works when using OpenRouter
|
|
||||||
|
|
||||||
### Improved Behavior
|
|
||||||
|
|
||||||
**When endpoint matches known provider:**
|
|
||||||
1. Fetch pricing from that provider's API
|
|
||||||
2. Display pricing in the startup table
|
|
||||||
3. Show per-prompt costs in chat output
|
|
||||||
|
|
||||||
**When endpoint is generic/unsupported:**
|
|
||||||
1. Skip API call (no pricing lookup)
|
|
||||||
2. Display `---` or `$` placeholders
|
|
||||||
3. Optional: Show "Pricing not available" note
|
|
||||||
|
|
||||||
**User Feedback:**
|
|
||||||
- Show clear messaging: "Pricing data loaded from OpenRouter"
|
|
||||||
- Show: "Pricing not available for this endpoint" (for unsupported)
|
|
||||||
- Don't break chat functionality if pricing fails
|
|
||||||
|
|
||||||
### Pricing Data Format
|
|
||||||
|
|
||||||
Store in `ModelPricing` class:
|
|
||||||
```csharp
|
|
||||||
class ModelPricing
|
|
||||||
{
|
|
||||||
string ModelId;
|
|
||||||
decimal InputPricePerMTokens;
|
|
||||||
decimal OutputPricePerMTokens;
|
|
||||||
double? CacheCreationPricePerMTokens; // if supported
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Token Tracking System
|
|
||||||
|
|
||||||
### Current State
|
|
||||||
- Uses `x-total-tokens` from OpenRouter headers
|
|
||||||
- Only works with OpenRouter responses
|
|
||||||
|
|
||||||
### Multi-Provider Strategy
|
|
||||||
|
|
||||||
**OpenRouter:**
|
|
||||||
- Use `x-total-tokens` header
|
|
||||||
- Use `x-response-timing` for latency tracking
|
|
||||||
|
|
||||||
**Groq:**
|
|
||||||
- Use `x-groq-tokens` header
|
|
||||||
- Use `x-groq-response-time` for latency
|
|
||||||
|
|
||||||
**OpenAI:**
|
|
||||||
- Use `x-ai-response-tokens` header (if available)
|
|
||||||
- Fall back to response body if needed
|
|
||||||
|
|
||||||
**Ollama:**
|
|
||||||
- No official token counting
|
|
||||||
- Use output length as proxy estimate
|
|
||||||
- Optional: Show message token estimates
|
|
||||||
|
|
||||||
**Generic/Fallback:**
|
|
||||||
- Parse `total_tokens` from response JSON
|
|
||||||
- Fall back to character count estimates
|
|
||||||
- Show placeholder when unavailable
|
|
||||||
|
|
||||||
### Integration Points
|
|
||||||
|
|
||||||
**During Chat Session:**
|
|
||||||
1. After each response, extract tokens from response headers
|
|
||||||
2. Store in `ChatSession.TokensUsed` object
|
|
||||||
3. Display in status bar: `Tokens: 128/2048 • Cost: $0.002`
|
|
||||||
|
|
||||||
**At Session End:**
|
|
||||||
1. Show summary: `Total tokens: 1,024 | Total cost: $0.015`
|
|
||||||
2. Write to session log or history file
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Roadmap
|
|
||||||
|
|
||||||
### Phase 1: Conditional Pricing (Current Issues First)
|
|
||||||
- [ ] Check if endpoint is OpenRouter before fetching pricing
|
|
||||||
- [ ] Skip pricing API call for non-OpenRouter endpoints
|
|
||||||
- [ ] Show placeholder message if pricing not available
|
|
||||||
- [ ] **Time estimate:** 2 hours
|
|
||||||
|
|
||||||
### Phase 2: Provider Configuration
|
|
||||||
- [ ] Add `provider` field to `AnchorConfig` model
|
|
||||||
- [ ] Update `SetupTui` to ask "Which provider?" (openrouter, ollama, groq, etc.)
|
|
||||||
- [ ] Auto-detect provider from endpoint URL (smart default)
|
|
||||||
- [ ] Write provider to config file on setup
|
|
||||||
- [ ] **Time estimate:** 3 hours
|
|
||||||
|
|
||||||
### Phase 3: Provider Abstraction
|
|
||||||
- [ ] Create `IPricingProvider` interface
|
|
||||||
- [ ] Move existing `PricingProvider` to `OpenRouterProvider`
|
|
||||||
- [ ] Create `GenericPricingProvider` for fallback
|
|
||||||
- [ ] Add provider factory: `ProviderFactory.Create(providerName)`
|
|
||||||
- [ ] **Time estimate:** 5 hours
|
|
||||||
|
|
||||||
### Phase 4: Token Tracking Enhancement
|
|
||||||
- [ ] Create `ITokenTracker` interface
|
|
||||||
- [ ] Implement token extraction for multiple providers
|
|
||||||
- [ ] Display token usage in status bar
|
|
||||||
- [ ] Add per-prompt cost calculation
|
|
||||||
- [ ] **Time estimate:** 6 hours
|
|
||||||
|
|
||||||
### Phase 5: Second Provider Implementation
|
|
||||||
- [ ] Implement `GroqProvider` (similar to OpenRouter)
|
|
||||||
- [ ] Test with Groq API
|
|
||||||
- [ ] Update documentation
|
|
||||||
- [ ] **Time estimate:** 4 hours
|
|
||||||
|
|
||||||
### Phase 6: Future-Proofing (Optional)
|
|
||||||
- [ ] Add plugin system for custom providers
|
|
||||||
- [ ] Allow users to define custom pricing rules
|
|
||||||
- [ ] Support OpenRouter-compatible custom endpoints
|
|
||||||
- [ ] **Time estimate:** 8+ hours
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## User Configuration Guide
|
|
||||||
|
|
||||||
### Automatic Setup
|
|
||||||
Run `/setup` in the chat or `anchor setup` in CLI:
|
|
||||||
```
|
|
||||||
Which provider are you using?
|
|
||||||
1) OpenRouter (qwen models)
|
|
||||||
2) Groq (qwen/gemma models)
|
|
||||||
3) Ollama (local models)
|
|
||||||
4) OpenAI (gpt models)
|
|
||||||
5) Custom endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manual Configuration
|
|
||||||
Edit `anchor.config.json` directly:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"apiKey": "your-api-key",
|
|
||||||
"model": "qwen3.5-27b",
|
|
||||||
"endpoint": "https://api.groq.com/openai/v1",
|
|
||||||
"provider": "groq" // optional, auto-detected if missing
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
For custom setup:
|
|
||||||
```
|
|
||||||
ANCHOR_ENDPOINT=https://api.groq.com/openai/v1
|
|
||||||
ANCHOR_PROVIDER=groq
|
|
||||||
ANCHOR_API_KEY=...
|
|
||||||
ANCHOR_MODEL=qwen3.5-27b
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Known Limitations
|
|
||||||
|
|
||||||
### Tier 1 Providers (Full Support)
|
|
||||||
**✓ OpenRouter**
|
|
||||||
- Pricing: ✓ (native API)
|
|
||||||
- Tokens: ✓ (response headers)
|
|
||||||
- Cost tracking: ✓
|
|
||||||
|
|
||||||
**✓ Groq** (after Phase 4)
|
|
||||||
- Pricing: ✓ (will add)
|
|
||||||
- Tokens: ✓ (response headers)
|
|
||||||
- Cost tracking: ✓
|
|
||||||
|
|
||||||
### Tier 2 Providers (Partial Support)
|
|
||||||
**○ Ollama**
|
|
||||||
- Pricing: ○ (free, no lookup needed)
|
|
||||||
- Tokens: ○ (estimated from output)
|
|
||||||
- Cost tracking: ○ (placeholder)
|
|
||||||
|
|
||||||
**○ OpenAI**
|
|
||||||
- Pricing: ○ (manual pricing display)
|
|
||||||
- Tokens: ○ (header extraction)
|
|
||||||
- Cost tracking: ○ (config-based)
|
|
||||||
|
|
||||||
### Tier 3 Providers (Basic Support)
|
|
||||||
**□ Custom Endpoints**
|
|
||||||
- Pricing: □ (manual only)
|
|
||||||
- Tokens: □ (fallback parsing)
|
|
||||||
- Cost tracking: □ (user-defined)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
1. **Pricing Database**: Maintain own pricing database (like OpenRouter's)
|
|
||||||
2. **Cost Estimator**: Predict costs before sending message
|
|
||||||
3. **Usage Alerts**: Warn user when approaching budget limits
|
|
||||||
4. **Multi-Model Support**: Compare costs between different providers
|
|
||||||
5. **Plugin System**: Allow community to add new providers
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
- ✅ Users can choose from 3+ providers in setup
|
|
||||||
- ✅ Pricing displays only for supported endpoints
|
|
||||||
- ✅ Token tracking works for all Tier 1 providers
|
|
||||||
- ✅ No breaking changes to existing OpenRouter users
|
|
||||||
- ✅ Clear documentation on what each provider supports
|
|
||||||
- ✅ Graceful degradation for unsupported features
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Last Updated: 2025-12-23*
|
|
||||||
|
|
||||||
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 */ }
|
|
||||||
|
|
||||||
|
|||||||
342
ReplLoop.cs
342
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,233 +57,116 @@ 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);
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
string? firstChunk = null;
|
HandleCancellation();
|
||||||
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
AnsiConsole.WriteLine();
|
DisplayError(ex);
|
||||||
AnsiConsole.Write(
|
}
|
||||||
new Panel($"[red]{Markup.Escape(ex.Message)}[/]")
|
finally
|
||||||
.Header("[bold red] Error [/]")
|
{
|
||||||
.BorderColor(Color.Red)
|
responseCts?.Dispose();
|
||||||
.RoundedBorder()
|
responseCts = null;
|
||||||
.Padding(1, 0));
|
|
||||||
AnsiConsole.WriteLine();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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; }
|
||||||
|
}
|
||||||
334
SANDBOX.md
334
SANDBOX.md
@@ -1,334 +0,0 @@
|
|||||||
# Sandbox Implementation Plan for AnchorCli
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
By default, all file and directory operations are restricted to the current working directory (CWD).
|
|
||||||
Users can bypass this restriction with the `--no-sandbox` flag.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Default: sandbox enabled (operations limited to CWD)
|
|
||||||
anchor
|
|
||||||
|
|
||||||
# Disable sandbox (allow operations anywhere)
|
|
||||||
anchor --no-sandbox
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
The implementation leverages the existing `ResolvePath()` methods in `FileTools` and `DirTools`.
|
|
||||||
Since tools are static classes without dependency injection, we use a static `SandboxContext` class.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Steps
|
|
||||||
|
|
||||||
### Step 1: Create `SandboxContext.cs`
|
|
||||||
|
|
||||||
Create a new file `Core/SandboxContext.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using System;
|
|
||||||
|
|
||||||
namespace AnchorCli;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Static context holding sandbox configuration.
|
|
||||||
/// Checked by ResolvePath() to validate paths are within working directory.
|
|
||||||
/// </summary>
|
|
||||||
internal static class SandboxContext
|
|
||||||
{
|
|
||||||
private static string? _workingDirectory;
|
|
||||||
private static bool _enabled = true;
|
|
||||||
|
|
||||||
public static bool Enabled
|
|
||||||
{
|
|
||||||
get => _enabled;
|
|
||||||
set => _enabled = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string WorkingDirectory
|
|
||||||
{
|
|
||||||
get => _workingDirectory ?? Environment.CurrentDirectory;
|
|
||||||
set => _workingDirectory = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validates that a resolved path is within the working directory (if sandbox is enabled).
|
|
||||||
/// Returns the resolved path if valid, or null if outside sandbox (no exception thrown).
|
|
||||||
/// When null is returned, the calling tool should return an error message to the agent.
|
|
||||||
/// </summary>
|
|
||||||
public static string? ValidatePath(string resolvedPath)
|
|
||||||
{
|
|
||||||
if (!_enabled)
|
|
||||||
return resolvedPath;
|
|
||||||
|
|
||||||
var workDir = WorkingDirectory;
|
|
||||||
|
|
||||||
// Normalize paths for comparison
|
|
||||||
var normalizedPath = Path.GetFullPath(resolvedPath).TrimEnd(Path.DirectorySeparatorChar);
|
|
||||||
var normalizedWorkDir = Path.GetFullPath(workDir).TrimEnd(Path.DirectorySeparatorChar);
|
|
||||||
|
|
||||||
// Check if path starts with working directory
|
|
||||||
if (!normalizedPath.StartsWith(normalizedWorkDir, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
// Return null to signal violation - caller handles error messaging
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolvedPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void Initialize(bool sandboxEnabled)
|
|
||||||
{
|
|
||||||
_enabled = sandboxEnabled;
|
|
||||||
_workingDirectory = Environment.CurrentDirectory;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 2: Modify `Program.cs`
|
|
||||||
|
|
||||||
Add argument parsing and initialize the sandbox context:
|
|
||||||
|
|
||||||
**After line 15** (after the `setup` subcommand check), add:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// ── Parse sandbox flag ──────────────────────────────────────────────────
|
|
||||||
bool sandboxEnabled = !args.Contains("--no-sandbox");
|
|
||||||
SandboxContext.Initialize(sandboxEnabled);
|
|
||||||
|
|
||||||
if (!sandboxEnabled)
|
|
||||||
{
|
|
||||||
AnsiConsole.MarkupLine("[dim grey]Sandbox disabled (--no-sandbox)[/]");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 3: Update `FileTools.ResolvePath()`
|
|
||||||
|
|
||||||
**Replace lines 322-323** with:
|
|
||||||
|
|
||||||
internal static string? ResolvePath(string path, out string? errorMessage)
|
|
||||||
{
|
|
||||||
errorMessage = null;
|
|
||||||
var resolved = Path.IsPathRooted(path)
|
|
||||||
? path
|
|
||||||
: Path.GetFullPath(path, Environment.CurrentDirectory);
|
|
||||||
|
|
||||||
var validated = SandboxContext.ValidatePath(resolved);
|
|
||||||
if (validated == null)
|
|
||||||
{
|
|
||||||
errorMessage = $"Sandbox violation: Path '{path}' is outside working directory '{SandboxContext.WorkingDirectory}'. Use --no-sandbox to disable restrictions.";
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return validated;
|
|
||||||
}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 4: Update `DirTools.ResolvePath()`
|
|
||||||
|
|
||||||
**Replace lines 84-85** with:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
internal static string? ResolvePath(string path, out string? errorMessage)
|
|
||||||
{
|
|
||||||
errorMessage = null;
|
|
||||||
var resolved = Path.IsPathRooted(path)
|
|
||||||
? path
|
|
||||||
: Path.GetFullPath(path, Environment.CurrentDirectory);
|
|
||||||
|
|
||||||
var validated = SandboxContext.ValidatePath(resolved);
|
|
||||||
if (validated == null)
|
|
||||||
{
|
|
||||||
errorMessage = $"Sandbox violation: Path '{path}' is outside working directory '{SandboxContext.WorkingDirectory}'. Use --no-sandbox to disable restrictions.";
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return validated;
|
|
||||||
}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 5: Update Tool Descriptions (Optional but Recommended)
|
|
||||||
|
|
||||||
Update the `[Description]` attributes to mention sandbox behavior:
|
|
||||||
|
|
||||||
**FileTools.cs - ReadFile** (line 23):
|
|
||||||
```csharp
|
|
||||||
[Description("Read a file. Max 200 lines per call. Returns lines with line:hash| anchors. Sandbox: restricted to working directory unless --no-sandbox is used. IMPORTANT: Call GrepFile first...")]
|
|
||||||
```
|
|
||||||
|
|
||||||
**DirTools.cs - CreateDir** (line 63):
|
|
||||||
```csharp
|
|
||||||
[Description("Create a new directory. Creates parent directories if they don't exist. Sandbox: restricted to working directory unless --no-sandbox is used. Returns OK on success...")]
|
|
||||||
```
|
|
||||||
|
|
||||||
Repeat for other tools as needed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How Tools Handle Sandbox Violations
|
|
||||||
|
|
||||||
Each tool that uses `ResolvePath()` must check for `null` return and handle it gracefully:
|
|
||||||
|
|
||||||
### FileTools Pattern
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Before (old code):
|
|
||||||
var resolvedPath = ResolvePath(path);
|
|
||||||
var content = File.ReadAllText(resolvedPath);
|
|
||||||
|
|
||||||
// After (new code):
|
|
||||||
var resolvedPath = ResolvePath(path, out var errorMessage);
|
|
||||||
if (resolvedPath == null)
|
|
||||||
return $"ERROR: {errorMessage}";
|
|
||||||
|
|
||||||
var content = File.ReadAllText(resolvedPath);
|
|
||||||
```
|
|
||||||
|
|
||||||
### DirTools Pattern
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Before (old code):
|
|
||||||
var resolvedPath = ResolvePath(path);
|
|
||||||
Directory.CreateDirectory(resolvedPath);
|
|
||||||
|
|
||||||
// After (new code):
|
|
||||||
var resolvedPath = ResolvePath(path, out var errorMessage);
|
|
||||||
if (resolvedPath == null)
|
|
||||||
return $"ERROR: {errorMessage}";
|
|
||||||
|
|
||||||
Directory.CreateDirectory(resolvedPath);
|
|
||||||
return "OK";
|
|
||||||
```
|
|
||||||
|
|
||||||
### EditTools
|
|
||||||
|
|
||||||
No changes needed - it already calls `FileTools.ResolvePath()`, so the sandbox check happens there.
|
|
||||||
|
|
||||||
### Tools That Don't Use ResolvePath
|
|
||||||
|
|
||||||
- `ListDir` with no path argument (uses current directory)
|
|
||||||
- `GetFileInfo` - needs to be updated to use `ResolvePath()`
|
|
||||||
- `FindFiles` - needs to be updated to validate the search path
|
|
||||||
|
|
||||||
---
|
|
||||||
---
|
|
||||||
|
|
||||||
## Error Handling - No Crashes
|
|
||||||
|
|
||||||
When a sandbox violation occurs, the program **does not crash**. Instead:
|
|
||||||
|
|
||||||
1. `ResolvePath()` returns `null` and sets `errorMessage`
|
|
||||||
2. The tool returns the error message to the agent
|
|
||||||
3. The agent sees the error and can continue the conversation
|
|
||||||
4. The user sees a clear error message in the chat
|
|
||||||
|
|
||||||
**Example tool implementation pattern:**
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public static async Task<string> ReadFile(string path, int startLine, int endLine)
|
|
||||||
{
|
|
||||||
var resolvedPath = ResolvePath(path, out var errorMessage);
|
|
||||||
if (resolvedPath == null)
|
|
||||||
return $"ERROR: {errorMessage}"; // Return error, don't throw
|
|
||||||
|
|
||||||
// ... rest of the tool logic
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**What the agent sees:**
|
|
||||||
```
|
|
||||||
Tool result: ERROR: Sandbox violation: Path '/home/tomi/.ssh' is outside working directory '/home/tomi/dev/anchor'. Use --no-sandbox to disable restrictions.
|
|
||||||
```
|
|
||||||
|
|
||||||
**What the user sees in chat:**
|
|
||||||
> The agent tried to read `/home/tomi/.ssh` but was blocked by the sandbox. The agent can now adjust its approach or ask you to run with `--no-sandbox`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Edge Cases Handled
|
|
||||||
|
|
||||||
| Case | Behavior |
|
|
||||||
|------|----------|
|
|
||||||
| **Symlinks inside CWD pointing outside** | Follows symlink (user-created link = intentional) |
|
|
||||||
| **Path traversal (`../..`)** | Blocked if result is outside CWD |
|
|
||||||
| **Absolute paths** | Validated against CWD |
|
|
||||||
| **Network paths** | Blocked (not under CWD) |
|
|
||||||
| **Case sensitivity** | Uses `OrdinalIgnoreCase` for cross-platform compatibility |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Security Notes
|
|
||||||
|
|
||||||
⚠️ **The sandbox is a safety feature, not a security boundary.**
|
|
||||||
|
|
||||||
- It prevents **accidental** modifications to system files
|
|
||||||
- It does **not** protect against malicious intent
|
|
||||||
- `CommandTool.ExecuteCommand()` can still run arbitrary shell commands
|
|
||||||
- A determined user can always use `--no-sandbox`
|
|
||||||
|
|
||||||
For true isolation, run anchor in a container or VM.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
- [ ] `ReadFile` on file inside CWD → **Success**
|
|
||||||
- [ ] `ReadFile` on file outside CWD → **Sandbox violation error**
|
|
||||||
- [ ] `ReadFile` with `../` traversal outside CWD → **Sandbox violation error**
|
|
||||||
- [ ] `CreateDir` outside CWD → **Sandbox violation error**
|
|
||||||
- [ ] `anchor --no-sandbox` then read `/etc/passwd` → **Success**
|
|
||||||
- [ ] Symlink inside CWD pointing to `/etc/passwd` → **Success** (user-created link)
|
|
||||||
- [ ] Case variations on Windows (`C:\Users` vs `c:\users`) → **Success**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migration Guide
|
|
||||||
|
|
||||||
### Existing Workflows
|
|
||||||
|
|
||||||
If you have scripts or workflows that rely on accessing files outside the project:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Update your scripts to use --no-sandbox
|
|
||||||
anchor --no-sandbox
|
|
||||||
```
|
|
||||||
|
|
||||||
### CI/CD Integration
|
|
||||||
|
|
||||||
For CI environments where sandbox may not be needed:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# GitHub Actions example
|
|
||||||
- name: Run anchor
|
|
||||||
run: anchor --no-sandbox
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Files Modified
|
|
||||||
|
|
||||||
| File | Changes |
|
|
||||||
|------|---------|
|
|
||||||
| `Core/SandboxContext.cs` | **New file** - Static sandbox state and validation |
|
|
||||||
| `Program.cs` | Add `--no-sandbox` parsing, call `SandboxContext.Initialize()` |
|
|
||||||
| `Tools/FileTools.cs` | Update `ResolvePath()` signature to return `string?` with `out errorMessage`; update all tool methods to check for null |
|
|
||||||
| `Tools/DirTools.cs` | Update `ResolvePath()` signature to return `string?` with `out errorMessage`; update all tool methods to check for null |
|
|
||||||
| `Tools/EditTools.cs` | No changes (uses `FileTools.ResolvePath()`, sandbox check happens there) |
|
|
||||||
| `Tools/CommandTool.cs` | **Not sandboxed** - shell commands can access any path (documented limitation) |
|
|
||||||
---
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
- **Allowlist**: Let users specify additional safe directories via config
|
|
||||||
- **Per-tool sandbox**: Some tools (e.g., `GrepRecursive`) could have different rules
|
|
||||||
- **Audit mode**: Log all file operations for review
|
|
||||||
- **Interactive prompt**: Ask for confirmation before violating sandbox instead of hard fail
|
|
||||||
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