433 lines
16 KiB
C#
433 lines
16 KiB
C#
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<AITool>
|
||
{
|
||
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<ChatMessage> 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;
|
||
}
|
||
}
|
||
|