From 31cf7cb4c184967a3409d3ccecfe53b7692a1d30 Mon Sep 17 00:00:00 2001 From: Tomi Eckert Date: Wed, 4 Mar 2026 14:54:36 +0100 Subject: [PATCH] feat: Introduce Hashline encoding/validation, implement core REPL and chat session logic, and establish initial project structure with a license and editor configuration. --- .editorconfig | 12 ++ AnchorCli.csproj | 1 + ChatSession.cs | 59 +++++++ Hashline/HashlineEncoder.cs | 2 + Hashline/HashlineValidator.cs | 7 + IMPROVEME.md | 90 ++++++++++ LICENSE | 21 +++ OpenRouter/TokenTracker.cs | 6 +- Program.cs | 313 +--------------------------------- ReplLoop.cs | 251 +++++++++++++++++++++++++++ ToolRegistry.cs | 34 ++++ Tools/EditTools.cs | 23 +-- 12 files changed, 492 insertions(+), 327 deletions(-) create mode 100644 .editorconfig create mode 100644 ChatSession.cs create mode 100644 IMPROVEME.md create mode 100644 LICENSE create mode 100644 ReplLoop.cs create mode 100644 ToolRegistry.cs diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ed6c154 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 4 +end_of_line = crlf +trim_trailing_whitespace = true +insert_final_newline = true + +[*.cs] +dotnet_sort_system_directives_first = true diff --git a/AnchorCli.csproj b/AnchorCli.csproj index 366e3b8..e3555cb 100644 --- a/AnchorCli.csproj +++ b/AnchorCli.csproj @@ -10,6 +10,7 @@ true true anchor + 0.1.0 diff --git a/ChatSession.cs b/ChatSession.cs new file mode 100644 index 0000000..866ca0b --- /dev/null +++ b/ChatSession.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.AI; + +namespace AnchorCli; + +internal sealed class ChatSession +{ + private readonly IChatClient _agent; + public ContextCompactor Compactor { get; } + public List History { get; } + + public ChatSession(IChatClient innerClient) + { + Compactor = new ContextCompactor(innerClient); + + var tools = ToolRegistry.GetTools(); + _agent = new ChatClientBuilder(innerClient) + .UseFunctionInvocation() + .Build(); + + History = new List + { + 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}} + """) + }; + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var options = new ChatOptions { Tools = ToolRegistry.GetTools() }; + var stream = _agent.GetStreamingResponseAsync(History, options, cancellationToken); + + await foreach (var update in stream.WithCancellation(cancellationToken)) + { + yield return update; + } + } +} diff --git a/Hashline/HashlineEncoder.cs b/Hashline/HashlineEncoder.cs index b62fcc5..79afa19 100644 --- a/Hashline/HashlineEncoder.cs +++ b/Hashline/HashlineEncoder.cs @@ -71,6 +71,8 @@ internal static class HashlineEncoder /// Computes a short file-level fingerprint: XOR of all per-line hashes (as bytes). /// Useful for cheap full-file staleness checks. /// + /// All lines of the file (without trailing newlines). + /// A 2-character hex fingerprint. public static string FileFingerprint(string[] lines) { int fp = 0; diff --git a/Hashline/HashlineValidator.cs b/Hashline/HashlineValidator.cs index baca6ba..692a5e8 100644 --- a/Hashline/HashlineValidator.cs +++ b/Hashline/HashlineValidator.cs @@ -83,6 +83,13 @@ internal static class HashlineValidator /// /// Validates both a start and end anchor, and ensures start <= end. /// + /// The starting anchor string. + /// The ending anchor string. + /// Current file lines (without newlines). + /// Resolved 0-based start index on success. + /// Resolved 0-based end index on success. + /// Human-readable error message on failure. + /// True if the range is valid; false otherwise. public static bool TryResolveRange( string startAnchor, string endAnchor, diff --git a/IMPROVEME.md b/IMPROVEME.md new file mode 100644 index 0000000..bd44c2a --- /dev/null +++ b/IMPROVEME.md @@ -0,0 +1,90 @@ +# 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 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 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.* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cba4aac --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 AnchorCli Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/OpenRouter/TokenTracker.cs b/OpenRouter/TokenTracker.cs index d96d3bb..7ab75b2 100644 --- a/OpenRouter/TokenTracker.cs +++ b/OpenRouter/TokenTracker.cs @@ -35,6 +35,8 @@ internal sealed class TokenTracker RequestCount++; } + private const int MaxContextReserve = 150_000; + /// /// Returns true if the context is getting too large and should be compacted. /// Triggers at min(75% of model context, 150K tokens). @@ -44,8 +46,8 @@ internal sealed class TokenTracker if (LastInputTokens <= 0) return false; int threshold = ContextLength > 0 - ? Math.Min((int)(ContextLength * 0.75), 150_000) - : 150_000; + ? Math.Min((int)(ContextLength * 0.75), MaxContextReserve) + : MaxContextReserve; return LastInputTokens >= threshold; } diff --git a/Program.cs b/Program.cs index a5ebab4..a5ddf8b 100644 --- a/Program.cs +++ b/Program.cs @@ -112,322 +112,25 @@ 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); +// ── Instantiate Core Components ────────────────────────────────────────── +var session = new ChatSession(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)); +commandRegistry.Register(new CompactCommand(session.Compactor, session.History)); commandRegistry.Register(new SetupCommand()); var commandDispatcher = new CommandDispatcher(commandRegistry); +// ── Run Repl ──────────────────────────────────────────────────────────── -// ── 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; - } -} - +var repl = new ReplLoop(session, tokenTracker, commandDispatcher); +await repl.RunAsync(); diff --git a/ReplLoop.cs b/ReplLoop.cs new file mode 100644 index 0000000..254c7d5 --- /dev/null +++ b/ReplLoop.cs @@ -0,0 +1,251 @@ +using Microsoft.Extensions.AI; +using OpenAI; +using Spectre.Console; +using AnchorCli.OpenRouter; +using AnchorCli.Commands; +using AnchorCli.Tools; + +namespace AnchorCli; + +internal sealed class ReplLoop +{ + private readonly ChatSession _session; + private readonly TokenTracker _tokenTracker; + private readonly CommandDispatcher _commandDispatcher; + + public ReplLoop(ChatSession session, TokenTracker tokenTracker, CommandDispatcher commandDispatcher) + { + _session = session; + _tokenTracker = tokenTracker; + _commandDispatcher = commandDispatcher; + } + + public async Task RunAsync() + { + AnsiConsole.MarkupLine("[dim]Type your message, or use [bold]/help[/] to see commands.[/]"); + AnsiConsole.MarkupLine("[dim]Press [bold]Ctrl+C[/] to cancel the current response.[/]"); + AnsiConsole.WriteLine(); + + CancellationTokenSource? responseCts = null; + + Console.CancelKeyPress += (_, e) => + { + e.Cancel = true; // Prevent process termination + responseCts?.Cancel(); + }; + + while (true) + { + string input = ReadLine.Read("❯ "); + + if (string.IsNullOrWhiteSpace(input)) continue; + + if (await _commandDispatcher.TryExecuteAsync(input, default)) continue; + + _session.History.Add(new ChatMessage(ChatRole.User, input)); + int turnStartIndex = _session.History.Count; + + AnsiConsole.WriteLine(); + + responseCts = new CancellationTokenSource(); + string fullResponse = ""; + + try + { + await using var stream = _session + .GetStreamingResponseAsync(responseCts.Token) + .GetAsyncEnumerator(responseCts.Token); + + string? firstChunk = null; + int respIn = 0, respOut = 0; + + void CaptureUsage(ChatResponseUpdate update) + { + if (update.RawRepresentation is OpenAI.Chat.StreamingChatCompletionUpdate raw + && raw.Usage != null) + { + respIn += raw.Usage.InputTokenCount; + respOut += raw.Usage.OutputTokenCount; + } + } + + 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; + } + }; + + 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; + } + + 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"))); + AnsiConsole.WriteLine(); + + _session.History.Add(new ChatMessage(ChatRole.Assistant, fullResponse)); + + int compactedResults = ContextCompactor.CompactStaleToolResults(_session.History, turnStartIndex); + if (compactedResults > 0) + { + AnsiConsole.MarkupLine( + $"[dim grey] ♻ Compacted {compactedResults} stale tool result(s) from previous turns[/]"); + } + + 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(); + } + } + 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; + } + } + } +} diff --git a/ToolRegistry.cs b/ToolRegistry.cs new file mode 100644 index 0000000..f2d0766 --- /dev/null +++ b/ToolRegistry.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.AI; +using AnchorCli.Tools; + +namespace AnchorCli; + +internal static class ToolRegistry +{ + public static List GetTools() + { + var jsonOptions = AppJsonContext.Default.Options; + + return 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), + }; + } +} diff --git a/Tools/EditTools.cs b/Tools/EditTools.cs index ce37f0a..77d479d 100644 --- a/Tools/EditTools.cs +++ b/Tools/EditTools.cs @@ -64,15 +64,7 @@ internal static partial class EditTools Log($"REPLACE_LINES: {path}"); Log($" Range: {startAnchor} -> {endAnchor}"); Log($" Replacing {endAnchor.Split(':')[0]}-{startAnchor.Split(':')[0]} lines with {newLines.Length} new lines"); - /*Log($" New content (first 5 lines):"); - foreach (var line in newLines.Take(5)) - { - Log($" + {line}"); - } - if (newLines.Length > 5) - { - Log($" ... and {newLines.Length - 5} more lines"); - }*/ + if (!File.Exists(path)) return $"ERROR: File not found: {path}"; @@ -109,15 +101,7 @@ internal static partial class EditTools Log($"INSERT_AFTER: {path}"); Log($" Anchor: {anchor}"); Log($" Inserting {newLines.Length} lines after line {anchor.Split(':')[0]}"); - /*Log($" New content (first 5 lines):"); - foreach (var line in newLines.Take(5)) - { - Log($" + {line}"); - } - if (newLines.Length > 5) - { - Log($" ... and {newLines.Length - 5} more lines"); - }*/ + if (!File.Exists(path)) return $"ERROR: File not found: {path}"; @@ -129,8 +113,7 @@ internal static partial class EditTools if (!HashlineValidator.TryResolve(anchor, lines, out int idx, out string error)) return $"ERROR: {error}"; - // Log the anchor line we're inserting after - //Log($" Inserting after line {idx + 1}: {lines[idx]}"); + var result = new List(lines.Length + newLines.Length); result.AddRange(lines[..(idx + 1)]);