using System.ClientModel; using Microsoft.Extensions.AI; using OpenAI; using AnchorCli; using AnchorCli.Tools; using AnchorCli.Commands; using AnchorCli.OpenRouter; using Spectre.Console; // ── Setup subcommand ───────────────────────────────────────────────────── if (args.Length > 0 && args[0].Equals("setup", StringComparison.OrdinalIgnoreCase)) { SetupTui.Run(); return; } // ── Config ────────────────────────────────────────────────────────────── const string endpoint = "https://openrouter.ai/api/v1"; var cfg = AnchorConfig.Load(); string apiKey = cfg.ApiKey; string model = cfg.Model; if (string.IsNullOrWhiteSpace(apiKey)) { AnsiConsole.MarkupLine("[red]No API key configured. Run [bold]anchor setup[/] first.[/]"); return; } // ── Fetch model pricing from OpenRouter ───────────────────────────────── var pricingProvider = new PricingProvider(); var tokenTracker = new TokenTracker(); ModelInfo? modelInfo = null; await AnsiConsole.Status() .Spinner(Spinner.Known.BouncingBar) .SpinnerStyle(Style.Parse("cornflowerblue")) .StartAsync("Fetching model pricing...", async ctx => { try { modelInfo = await pricingProvider.GetModelInfoAsync(model); 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); } } catch { /* pricing is best-effort */ } }); // ── Pretty header ─────────────────────────────────────────────────────── AnsiConsole.Write( new FigletText("anchor") .Color(Color.CornflowerBlue)); 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]Endpoint[/]", $"[blue]OpenRouter[/]"); infoTable.AddRow("[grey]CWD[/]", $"[green]{Markup.Escape(Environment.CurrentDirectory)}[/]"); if (modelInfo?.Pricing != 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 openAiClient = new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClientOptions { Endpoint = new Uri(endpoint) }); IChatClient innerClient = openAiClient.GetChatClient(model).AsIChatClient(); // ── Tool call logging via Spectre ─────────────────────────────────────── object consoleLock = new object(); 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; // ── Collect all tool methods ──────────────────────────────────────────── var jsonOptions = AppJsonContext.Default.Options; var tools = new List { AIFunctionFactory.Create(FileTools.ReadFile, serializerOptions: jsonOptions), AIFunctionFactory.Create(FileTools.GrepFile, serializerOptions: jsonOptions), AIFunctionFactory.Create(FileTools.ListDir, serializerOptions: jsonOptions), AIFunctionFactory.Create(EditTools.ReplaceLines, serializerOptions: jsonOptions), AIFunctionFactory.Create(EditTools.InsertAfter, serializerOptions: jsonOptions), AIFunctionFactory.Create(EditTools.DeleteRange, serializerOptions: jsonOptions), AIFunctionFactory.Create(EditTools.CreateFile, serializerOptions: jsonOptions), AIFunctionFactory.Create(EditTools.DeleteFile, serializerOptions: jsonOptions), AIFunctionFactory.Create(EditTools.RenameFile, serializerOptions: jsonOptions), AIFunctionFactory.Create(EditTools.CopyFile, serializerOptions: jsonOptions), AIFunctionFactory.Create(DirTools.CreateDir, serializerOptions: jsonOptions), AIFunctionFactory.Create(DirTools.RenameDir, serializerOptions: jsonOptions), AIFunctionFactory.Create(DirTools.DeleteDir, serializerOptions: jsonOptions), AIFunctionFactory.Create(FileTools.FindFiles, serializerOptions: jsonOptions), AIFunctionFactory.Create(FileTools.GrepRecursive, serializerOptions: jsonOptions), AIFunctionFactory.Create(FileTools.GetFileInfo, serializerOptions: jsonOptions), AIFunctionFactory.Create(EditTools.AppendToFile, serializerOptions: jsonOptions), AIFunctionFactory.Create(CommandTool.ExecuteCommand, serializerOptions: jsonOptions), }; // Wrap with automatic function invocation IChatClient agent = new ChatClientBuilder(innerClient) .UseFunctionInvocation() .Build(); // ── Context compactor ────────────────────────────────────────────────── var compactor = new ContextCompactor(innerClient); if (modelInfo != null) tokenTracker.ContextLength = modelInfo.ContextLength; // ── Chat history with system prompt ───────────────────────────────────── List history = [ new(ChatRole.System, $$""" You are anchor, a coding assistant that edits files using the Hashline technique. ## Reading files When you read a file, lines are returned in the format: lineNumber:hash|content The "lineNumber:hash|" prefix is METADATA for anchoring — it is NOT part of the file. ## Editing files To edit, reference anchors as "lineNumber:hash" in startAnchor/endAnchor parameters. The newLines/initialLines parameter must contain RAW SOURCE CODE ONLY. ❌ WRONG: ["5:a3| public void Foo()"] ✅ RIGHT: [" public void Foo()"] Never include the "lineNumber:hash|" prefix in content you write — it will corrupt the file. ## Workflow 1. Always read a file before editing it. 2. After a mutation, verify the returned fingerprint. 3. Edit from bottom to top so line numbers don't shift. 4. If an anchor fails validation, re-read the file to get fresh anchors. Keep responses concise. You have access to the current working directory. You are running on: {{System.Runtime.InteropServices.RuntimeInformation.OSDescription}} """) ]; // ── Command system ───────────────────────────────────────────────────── 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(compactor, history)); var commandDispatcher = new CommandDispatcher(commandRegistry); // ── REPL ──────────────────────────────────────────────────────────────── AnsiConsole.MarkupLine("[dim]Type your message, or use [bold]/help[/] to see commands.[/]"); AnsiConsole.MarkupLine("[dim]Press [bold]Ctrl+C[/] to cancel the current response.[/]"); AnsiConsole.WriteLine(); // Ctrl+C cancellation: cancel the current response, not the process CancellationTokenSource? responseCts = null; Console.CancelKeyPress += (_, e) => { e.Cancel = true; // Prevent process termination responseCts?.Cancel(); }; while (true) { string input = ReadLine.Read("❯ "); if (string.IsNullOrWhiteSpace(input)) continue; // Try to execute slash command if (await commandDispatcher.TryExecuteAsync(input, default)) continue; history.Add(new ChatMessage(ChatRole.User, input)); // Track where this turn starts so we can compact previous turns' tool results int turnStartIndex = history.Count; AnsiConsole.WriteLine(); // Create a fresh CancellationTokenSource for this response responseCts = new CancellationTokenSource(); string fullResponse = ""; try { var options = new ChatOptions { Tools = tools }; // Get the async enumerator so we can split into spinner + streaming phases await using var stream = agent .GetStreamingResponseAsync(history, options, responseCts.Token) .GetAsyncEnumerator(responseCts.Token); string? firstChunk = null; int respIn = 0, respOut = 0; // Helper: extract usage from a streaming update's raw OpenAI representation void CaptureUsage(ChatResponseUpdate update) { if (update.RawRepresentation is OpenAI.Chat.StreamingChatCompletionUpdate raw && raw.Usage != null) { respIn += raw.Usage.InputTokenCount; respOut += raw.Usage.OutputTokenCount; } } // Phase 1: Show BouncingBar spinner while agent thinks & invokes tools 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; } }; var spinnerTask = Task.Run(async () => { var frames = Spinner.Known.BouncingBar.Frames; var interval = Spinner.Known.BouncingBar.Interval; int i = 0; // Hide cursor 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 { // Clear the spinner line and show cursor 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; } // Phase 2: Stream text tokens directly to the console 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; } // Record usage and display cost 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"))); AnsiConsole.WriteLine(); history.Add(new ChatMessage(ChatRole.Assistant, fullResponse)); // ── Compact stale ReadFile/Grep results from previous turns ─ int compactedResults = ContextCompactor.CompactStaleToolResults(history, turnStartIndex); if (compactedResults > 0) { AnsiConsole.MarkupLine( $"[dim grey] ♻ Compacted {compactedResults} stale tool result(s) from previous turns[/]"); } // ── Auto-compact context if approaching the limit ─────────── 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 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(); } } catch (OperationCanceledException) { // Keep partial response in history so the agent has context AnsiConsole.WriteLine(); AnsiConsole.MarkupLine("[dim grey] ✗ Cancelled[/]"); AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("grey dim"))); AnsiConsole.WriteLine(); if (!string.IsNullOrEmpty(fullResponse)) { history.Add(new ChatMessage(ChatRole.Assistant, fullResponse)); } 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; } }