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)]);