1
0

Compare commits

...

17 Commits

Author SHA1 Message Date
3f187348d7 update gitignore 2026-03-16 16:18:33 +01:00
fe006a5256 feat: make cursor visible on exit 2026-03-12 00:49:12 +01:00
ef30a2254b chore: update .gitignore 2026-03-12 00:04:34 +01:00
1e943e6566 refactor: apply Single Responsibility Principle to Program.cs and ReplLoop.cs
extracted responsibilities from Program.cs (208→46 lines) and ReplLoop.cs (274→174 lines) into focused service classes: HeaderRenderer, SessionManager, ApplicationStartup, ResponseStreamer, SpinnerService, UsageDisplayer, and ContextCompactionService. Each class now has a single, well-defined responsibility, improving testability and maintainability.
2026-03-11 16:59:06 +01:00
ccfa7e1b9d chore: Remove obsolete planning and documentation files. 2026-03-11 14:28:37 +01:00
c9515a822d feat: add new input processors (readline), add new figlet font 2026-03-11 14:19:57 +01:00
75bbdda37d feat: enable UTF-8 encoding for console, and fix tool log formatting 2026-03-11 14:03:17 +01:00
46d32c43ba feat: update tool logs to be more coherent 2026-03-11 13:56:00 +01:00
35c8840ed4 refactor: Use collection expressions and add explicit names for AI tool registration. 2026-03-11 13:48:53 +01:00
e2ab10813c Refactor README to condense content, move detailed sections to external documentation files, and update slash commands. 2026-03-06 23:44:18 +01:00
acc04af4bc docs: restructure README, move detailed content to docs/ directory 2026-03-06 23:42:55 +01:00
977d772229 feat: decreased deduplication amount of read results from 5 to 3 2026-03-06 08:40:11 +01:00
a776d978ea feat: saving token data to sessions 2026-03-06 08:32:37 +01:00
91a44bb2a4 feat: add version string to startup and status menu 2026-03-06 08:13:39 +01:00
f687360c2b feat: document new batch editing feature in README.md 2026-03-06 03:22:08 +01:00
4fbbde32e3 feat: add EditTools.cs to implement file manipulation functions like replace_lines, delete_range, and batch_edit, updating README.md to reflect new tools and command descriptions. 2026-03-06 03:20:48 +01:00
8f2c72b3c5 docs: add batch_edit tool and load/save commands to README 2026-03-06 03:07:56 +01:00
37 changed files with 2072 additions and 2659 deletions

3
.gitignore vendored
View File

@@ -3,3 +3,6 @@ obj
.vscode .vscode
publish publish
.anchor .anchor
.idea
.vs
.crush

View File

@@ -19,6 +19,10 @@
<PublishAot>false</PublishAot> <PublishAot>false</PublishAot>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Assets\3d.flf" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI" Version="10.3.0" /> <PackageReference Include="Microsoft.Extensions.AI" Version="10.3.0" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.3.0" /> <PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.3.0" />

View File

@@ -22,6 +22,7 @@ namespace AnchorCli;
[JsonSerializable(typeof(AnchorConfig))] [JsonSerializable(typeof(AnchorConfig))]
[JsonSerializable(typeof(BatchOperation))] [JsonSerializable(typeof(BatchOperation))]
[JsonSerializable(typeof(BatchOperation[]))] [JsonSerializable(typeof(BatchOperation[]))]
[JsonSerializable(typeof(TokenMetadata))]
[JsonSourceGenerationOptions( [JsonSourceGenerationOptions(
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]

176
ApplicationStartup.cs Normal file
View 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);
}
}

818
Assets/3d.flf Normal file
View File

@@ -0,0 +1,818 @@
flf2a$ 8 8 20 -1 1
3d font created by xero <x@xero.nu>
$$@
$$@
$$@
$$@
$$@
$$@
$$@
$$@@
██@
░██@
░██@
░██@
░██@
░░ @
██@
░░ @@
█ █@
░█ ░█@
░ ░ @
@
@
@
@
@@
@
██ ██ @
████████████@
░░░██░░░░██░ @
░██ ░██ @
████████████@
░░░██░░░░██░ @
░░ ░░ @@
█ @
█████@
░█░█░ @
░█████@
░░░█░█@
█████@
░░░█░ @
░ @@
@
██ ██ @
░░ ██ @
██ @
██ @
██ @
██ ██ @
░░ ░░ @@
██ @
█░ █ @
░ ██ @
█░ █ █@
█ ░ █ @
░█ ░█ @
░ ████ █@
░░░░ ░ @@
██@
░░█@
░ @
@
@
@
@
@@
██@
██ @
██ @
░██ @
░██ @
░░██ @
░░██@
░░ @@
██ @
░░██ @
░░██@
░██@
░██@
██ @
██ @
░░ @@
██ @
██ ░██ ██ @
░░██ ░██ ██ @
██████████████@
░░░██░░██░░██░ @
██ ░██ ░░██ @
░░ ░██ ░░ @
░░ @@
@
█ @
░█ @
█████████@
░░░░░█░░░ @
░█ @
░ @
@@
@
@
@
@
@
██@
░░█@
░ @@
@
@
@
█████@
░░░░░ @
@
@
@@
@
@
@
@
@
██@
░██@
░░ @@
██@
██ @
██ @
██ @
██ @
██ @
██ @
░░ @@
████ @
█░░░██@
░█ █░█@
░█ █ ░█@
░██ ░█@
░█ ░█@
░ ████ @
░░░░ @@
██ @
███ @
░░██ @
░██ @
░██ @
░██ @
████@
░░░░ @@
████ @
█░░░ █@
░ ░█@
███ @
█░░ @
█ @
░██████@
░░░░░░ @@
████ @
█░░░ █@
░ ░█@
███ @
░░░ █@
█ ░█@
░ ████ @
░░░░ @@
██ @
█░█ @
█ ░█ @
██████@
░░░░░█ @
░█ @
░█ @
░ @@
██████@
░█░░░░ @
░█████ @
░░░░░ █@
░█@
█ ░█@
░ ████ @
░░░░ @@
████ @
█░░░ █@
░█ ░ @
░█████ @
░█░░░ █@
░█ ░█@
░ ████ @
░░░░ @@
██████@
░░░░░░█@
░█@
█ @
█ @
█ @
█ @
░ @@
████ @
█░░░ █@
░█ ░█@
░ ████ @
█░░░ █@
░█ ░█@
░ ████ @
░░░░ @@
████ @
█░░░ █@
░█ ░█@
░ ████ @
░░░█ @
█ @
█ @
░ @@
@
@
@
@
██@
░░ @
██@
░░ @@
@
@
@
██@
░░ @
██@
░░█@
░ @@
██@
██░ @
██░ @
██░ @
░░ ██ @
░░ ██ @
░░ ██@
░░ @@
@
@
██████@
░░░░░░ @
██████@
░░░░░░ @
@
@@
██ @
░░ ██ @
░░ ██ @
░░ ██@
██░ @
██░ @
██░ @
░░ @@
████ @
██░░██@
░██ ░██@
░░ ██ @
██ @
░░ @
██ @
░░ @@
████ @
█░░░ █@
░█ ██░█@
░█░█ ░█@
░█░ ██ @
░█ ░░ @
░ █████@
░░░░░ @@
██ @
████ @
██░░██ @
██ ░░██ @
██████████@
░██░░░░░░██@
░██ ░██@
░░ ░░ @@
██████ @
░█░░░░██ @
░█ ░██ @
░██████ @
░█░░░░ ██@
░█ ░██@
░███████ @
░░░░░░░ @@
██████ @
██░░░░██@
██ ░░ @
░██ @
░██ @
░░██ ██@
░░██████ @
░░░░░░ @@
███████ @
░██░░░░██ @
░██ ░██@
░██ ░██@
░██ ░██@
░██ ██ @
░███████ @
░░░░░░░ @@
████████@
░██░░░░░ @
░██ @
░███████ @
░██░░░░ @
░██ @
░████████@
░░░░░░░░ @@
████████@
░██░░░░░ @
░██ @
░███████ @
░██░░░░ @
░██ @
░██ @
░░ @@
████████ @
██░░░░░░██@
██ ░░ @
░██ @
░██ █████@
░░██ ░░░░██@
░░████████ @
░░░░░░░░ @@
██ ██@
░██ ░██@
░██ ░██@
░██████████@
░██░░░░░░██@
░██ ░██@
░██ ░██@
░░ ░░ @@
██@
░██@
░██@
░██@
░██@
░██@
░██@
░░ @@
██@
░██@
░██@
░██@
░██@
██ ░██@
░░█████ @
░░░░░ @@
██ ██@
░██ ██ @
░██ ██ @
░████ @
░██░██ @
░██░░██ @
░██ ░░██@
░░ ░░ @@
██ @
░██ @
░██ @
░██ @
░██ @
░██ @
░████████@
░░░░░░░░ @@
████ ████@
░██░██ ██░██@
░██░░██ ██ ░██@
░██ ░░███ ░██@
░██ ░░█ ░██@
░██ ░ ░██@
░██ ░██@
░░ ░░ @@
████ ██@
░██░██ ░██@
░██░░██ ░██@
░██ ░░██ ░██@
░██ ░░██░██@
░██ ░░████@
░██ ░░███@
░░ ░░░ @@
███████ @
██░░░░░██ @
██ ░░██@
░██ ░██@
░██ ░██@
░░██ ██ @
░░███████ @
░░░░░░░ @@
███████ @
░██░░░░██@
░██ ░██@
░███████ @
░██░░░░ @
░██ @
░██ @
░░ @@
███████ @
██░░░░░██ @
██ ░░██ @
░██ ░██ @
░██ ██░██ @
░░██ ░░ ██ @
░░███████ ██@
░░░░░░░ ░░ @@
███████ @
░██░░░░██ @
░██ ░██ @
░███████ @
░██░░░██ @
░██ ░░██ @
░██ ░░██@
░░ ░░ @@
████████@
██░░░░░░ @
░██ @
░█████████@
░░░░░░░░██@
░██@
████████ @
░░░░░░░░ @@
██████████@
░░░░░██░░░ @
░██ @
░██ @
░██ @
░██ @
░██ @
░░ @@
██ ██@
░██ ░██@
░██ ░██@
░██ ░██@
░██ ░██@
░██ ░██@
░░███████ @
░░░░░░░ @@
██ ██@
░██ ░██@
░██ ░██@
░░██ ██ @
░░██ ██ @
░░████ @
░░██ @
░░ @@
██ ██@
░██ ░██@
░██ █ ░██@
░██ ███ ░██@
░██ ██░██░██@
░████ ░░████@
░██░ ░░░██@
░░ ░░ @@
██ ██@
░░██ ██ @
░░██ ██ @
░░███ @
██░██ @
██ ░░██ @
██ ░░██@
░░ ░░ @@
██ ██@
░░██ ██ @
░░████ @
░░██ @
░██ @
░██ @
░██ @
░░ @@
████████@
░░░░░░██ @
██ @
██ @
██ @
██ @
████████@
░░░░░░░░ @@
█████@
░██░░ @
░██ @
░██ @
░██ @
░██ @
░█████@
░░░░░ @@
██ @
░░██ @
░░██ @
░░██ @
░░██ @
░░██ @
░░██@
░░ @@
█████@
░░░░██@
░██@
░██@
░██@
░██@
█████@
░░░░░ @@
██ @
██░ ██ @
██ ░░ ██@
░░ ░░ @
@
@
@
@@
@
@
@
@
@
@
█████@
░░░░░ @@
██@
░█ @
░ @
@
@
@
@
@@
@
@
██████ @
░░░░░░██ @
███████ @
██░░░░██ @
░░████████@
░░░░░░░░ @@
██ @
░██ @
░██ @
░██████ @
░██░░░██@
░██ ░██@
░██████ @
░░░░░ @@
@
@
█████ @
██░░░██@
░██ ░░ @
░██ ██@
░░█████ @
░░░░░ @@
██@
░██@
░██@
██████@
██░░░██@
░██ ░██@
░░██████@
░░░░░░ @@
@
@
█████ @
██░░░██@
░███████@
░██░░░░ @
░░██████@
░░░░░░ @@
████@
░██░ @
██████@
░░░██░ @
░██ @
░██ @
░██ @
░░ @@
@
█████ @
██░░░██@
░██ ░██@
░░██████@
░░░░░██@
█████ @
░░░░░ @@
██ @
░██ @
░██ @
░██████ @
░██░░░██@
░██ ░██@
░██ ░██@
░░ ░░ @@
██@
░░ @
██@
░██@
░██@
░██@
░██@
░░ @@
██@
░░ @
██@
░██@
░██@
██░██@
░░███ @
░░░ @@
██ @
░██ @
░██ ██@
░██ ██ @
░████ @
░██░██ @
░██░░██@
░░ ░░ @@
██@
░██@
░██@
░██@
░██@
░██@
███@
░░░ @@
@
@
██████████ @
░░██░░██░░██@
░██ ░██ ░██@
░██ ░██ ░██@
███ ░██ ░██@
░░░ ░░ ░░ @@
@
@
███████ @
░░██░░░██@
░██ ░██@
░██ ░██@
███ ░██@
░░░ ░░ @@
@
@
██████ @
██░░░░██@
░██ ░██@
░██ ░██@
░░██████ @
░░░░░░ @@
@
██████ @
░██░░░██@
░██ ░██@
░██████ @
░██░░░ @
░██ @
░░ @@
@
████ @
██░░██ @
░██ ░██ @
░░█████ @
░░░░██ @
░███@
░░░ @@
@
@
██████@
░░██░░█@
░██ ░ @
░██ @
░███ @
░░░ @@
@
@
██████@
██░░░░ @
░░█████ @
░░░░░██@
██████ @
░░░░░░ @@
██ @
░██ @
██████@
░░░██░ @
░██ @
░██ @
░░██ @
░░ @@
@
@
██ ██@
░██ ░██@
░██ ░██@
░██ ░██@
░░██████@
░░░░░░ @@
@
@
██ ██@
░██ ░██@
░░██ ░██ @
░░████ @
░░██ @
░░ @@
@
@
███ ██@
░░██ █ ░██@
░██ ███░██@
░████░████@
███░ ░░░██@
░░░ ░░░ @@
@
@
██ ██@
░░██ ██ @
░░███ @
██░██ @
██ ░░██@
░░ ░░ @@
@
██ ██@
░░██ ██ @
░░███ @
░██ @
██ @
██ @
░░ @@
@
@
██████@
░░░░██ @
██ @
██ @
██████@
░░░░░░ @@
███@
██░ @
░██ @
███ @
░░░██ @
░██ @
░░███@
░░░ @@
█@
░█@
░█@
░ @
█@
░█@
░█@
░ @@
███ @
░░░██ @
░██ @
░░███@
██░ @
░██ @
███ @
░░░ @@
██ ███ @
░░███░░██@
░░░ ░░ @
@
@
@
@
@@
@
@
@
@
@
@
@
@@
@
@
@
@
@
@
@
@@
@
@
@
@
@
@
@
@@
@
@
@
@
@
@
@
@@
@
@
@
@
@
@
@
@@
@
@
@
@
@
@
@
@@
@
@
@
@
@
@
@
@@

View File

@@ -9,6 +9,11 @@ internal sealed class ChatSession
public ContextCompactor Compactor { get; } public ContextCompactor Compactor { get; }
public List<ChatMessage> History { get; } public List<ChatMessage> History { get; }
// Token tracking state persisted across sessions
public long SessionInputTokens { get; set; }
public long SessionOutputTokens { get; set; }
public int RequestCount { get; set; }
public ChatSession(IChatClient innerClient) public ChatSession(IChatClient innerClient)
{ {
Compactor = new ContextCompactor(innerClient); Compactor = new ContextCompactor(innerClient);
@@ -84,6 +89,18 @@ internal sealed class ChatSession
}; };
var json = JsonSerializer.Serialize(messagesToSave, AppJsonContext.Default.ListChatMessage); var json = JsonSerializer.Serialize(messagesToSave, AppJsonContext.Default.ListChatMessage);
// Save token stats to a separate metadata file
var metadataPath = Path.ChangeExtension(filePath, ".metadata.json");
var metadata = new TokenMetadata
{
SessionInputTokens = SessionInputTokens,
SessionOutputTokens = SessionOutputTokens,
RequestCount = RequestCount
};
var metadataJson = JsonSerializer.Serialize(metadata, AppJsonContext.Default.TokenMetadata);
await File.WriteAllTextAsync(metadataPath, metadataJson, cancellationToken);
await File.WriteAllTextAsync(filePath, json, cancellationToken); await File.WriteAllTextAsync(filePath, json, cancellationToken);
} }
@@ -104,5 +121,33 @@ internal sealed class ChatSession
History.Clear(); History.Clear();
History.Add(systemMessage); History.Add(systemMessage);
History.AddRange(messages); History.AddRange(messages);
// Load token stats from metadata file if it exists
var metadataPath = Path.ChangeExtension(filePath, ".metadata.json");
if (File.Exists(metadataPath))
{
try
{
var metadataJson = await File.ReadAllTextAsync(metadataPath, cancellationToken);
var metadata = JsonSerializer.Deserialize<TokenMetadata>(metadataJson, AppJsonContext.Default.TokenMetadata);
if (metadata != null)
{
SessionInputTokens = metadata.SessionInputTokens;
SessionOutputTokens = metadata.SessionOutputTokens;
RequestCount = metadata.RequestCount;
}
}
catch { /* Ignore metadata load errors */ }
}
} }
} }
/// <summary>
/// Token tracking metadata serialized with the session.
/// </summary>
internal sealed class TokenMetadata
{
public long SessionInputTokens { get; set; }
public long SessionOutputTokens { get; set; }
public int RequestCount { get; set; }
}

View File

@@ -9,6 +9,7 @@ public class ExitCommand : ICommand
public Task ExecuteAsync(string[] args, CancellationToken ct) public Task ExecuteAsync(string[] args, CancellationToken ct)
{ {
AnsiConsole.MarkupLine("[green]Goodbye![/]"); AnsiConsole.MarkupLine("[green]Goodbye![/]");
Console.CursorVisible = true;
Environment.Exit(0); Environment.Exit(0);
return Task.CompletedTask; return Task.CompletedTask;
} }

View File

@@ -1,5 +1,5 @@
using Spectre.Console; using Spectre.Console;
using System.Linq; using System.Reflection;
namespace AnchorCli.Commands; namespace AnchorCli.Commands;
public class HelpCommand : ICommand public class HelpCommand : ICommand
@@ -16,6 +16,8 @@ public class HelpCommand : ICommand
public Task ExecuteAsync(string[] args, CancellationToken ct) public Task ExecuteAsync(string[] args, CancellationToken ct)
{ {
var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
AnsiConsole.MarkupLine($"[cyan]Anchor CLI v{version}[/]");
AnsiConsole.MarkupLine("[cyan]Available commands:[/]"); AnsiConsole.MarkupLine("[cyan]Available commands:[/]");
var table = new Table(); var table = new Table();

View File

@@ -1,31 +0,0 @@
using Spectre.Console;
namespace AnchorCli.Commands;
internal class LoadCommand(ChatSession session) : ICommand
{
public string Name => "load";
public string Description => "Load a chat session from a file";
public async Task ExecuteAsync(string[] args, CancellationToken ct)
{
string filePath = args.Length > 0 ? args[0] : ".anchor/session.json";
if (!File.Exists(filePath))
{
AnsiConsole.MarkupLine($"[yellow]No session file found at {Markup.Escape(filePath)}[/]");
return;
}
try
{
await session.LoadAsync(filePath, ct);
AnsiConsole.MarkupLine($"[green]Session loaded from {Markup.Escape(filePath)}[/]");
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]Failed to load session: {Markup.Escape(ex.Message)}[/]");
}
}
}

View File

@@ -1,31 +0,0 @@
using Spectre.Console;
namespace AnchorCli.Commands;
internal class SaveCommand(ChatSession session) : ICommand
{
public string Name => "save";
public string Description => "Save the current chat session to a file";
public async Task ExecuteAsync(string[] args, CancellationToken ct)
{
string filePath = args.Length > 0 ? args[0] : ".anchor/session.json";
try
{
var directory = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
await session.SaveAsync(filePath, ct);
AnsiConsole.MarkupLine($"[green]Session saved to {Markup.Escape(filePath)}[/]");
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]Failed to save session: {Markup.Escape(ex.Message)}[/]");
}
}
}

View File

@@ -1,4 +1,5 @@
using Spectre.Console; using Spectre.Console;
using System.Reflection;
namespace AnchorCli.Commands; namespace AnchorCli.Commands;
public class StatusCommand : ICommand public class StatusCommand : ICommand
@@ -25,6 +26,7 @@ public class StatusCommand : ICommand
table.AddRow("[grey]Model[/]", $"[cyan]{Markup.Escape(_model)}[/]"); table.AddRow("[grey]Model[/]", $"[cyan]{Markup.Escape(_model)}[/]");
table.AddRow("[grey]Endpoint[/]", $"[blue]{Markup.Escape(_endpoint)}[/]"); table.AddRow("[grey]Endpoint[/]", $"[blue]{Markup.Escape(_endpoint)}[/]");
table.AddRow("[grey]Version[/]", $"[magenta]{Assembly.GetExecutingAssembly().GetName().Version}[/]");
table.AddRow("[grey]CWD[/]", $"[green]{Markup.Escape(Environment.CurrentDirectory)}[/]"); table.AddRow("[grey]CWD[/]", $"[green]{Markup.Escape(Environment.CurrentDirectory)}[/]");
AnsiConsole.Write(table); AnsiConsole.Write(table);

View 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;
}
}

View File

@@ -71,7 +71,7 @@ internal sealed partial class ContextCompactor(IChatClient client)
string reason = ""; string reason = "";
// Rule 1: Deduplication. If we have already seen this file in a newer message (since we are walking backward), redact this one. // Rule 1: Deduplication. If we have already seen this file in a newer message (since we are walking backward), redact this one.
if (filesRead.TryGetValue(filePath, out int count) && count >= 5) if (filesRead.TryGetValue(filePath, out int count) && count >= 3)
{ {
shouldRedact = true; shouldRedact = true;
reason = "deduplication — you read this file 5 or more times later"; reason = "deduplication — you read this file 5 or more times later";

110
HeaderRenderer.cs Normal file
View 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();
}
}

View File

@@ -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.

View File

@@ -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.*

125
InputProcessor.cs Normal file
View File

@@ -0,0 +1,125 @@
using Spectre.Console;
namespace AnchorCli
{
internal class InputProcessor
{
private static void DisplayText(int left, string buffer, int index = -1, string placeholder = "", int viewportOffset = 0)
{
Console.CursorLeft = left;
if (buffer.Length == 0 && index == 0)
{
AnsiConsole.Markup($"[grey dim]{placeholder}{new string(' ', Console.WindowWidth - 1 - left - placeholder.Length)}[/]");
return;
}
var visibleWidth = Console.WindowWidth - left - 1;
var displayStart = Math.Min(viewportOffset, Math.Max(0, buffer.Length - 1));
var displayEnd = Math.Min(displayStart + visibleWidth, buffer.Length);
var displayBuffer = string.Concat(buffer.AsSpan(displayStart, displayEnd - displayStart), " ");
for (var i = 0; i < displayBuffer.Length; i++)
{
var actualIndex = displayStart + i;
if (index != -1 && actualIndex == index)
{
Console.ForegroundColor = ConsoleColor.Black;
Console.BackgroundColor = ConsoleColor.White;
}
Console.Write(displayBuffer[i]);
if (index != -1 && actualIndex == index)
Console.ResetColor();
}
// Fill remaining space with spaces
var remainingSpaces = visibleWidth - displayBuffer.Length;
if (remainingSpaces > 0)
Console.Write(new string(' ', remainingSpaces));
}
public static string ReadLine(string placeholder = "")
{
Console.CursorVisible = false;
var buffer = string.Empty;
var index = 0;
var viewportOffset = 0;
var left = Console.CursorLeft;
DisplayText(left, buffer, index, placeholder, viewportOffset);
while (true)
{
var inputKey = Console.ReadKey(intercept: true);
switch (inputKey)
{
case { Key: ConsoleKey.Enter } when buffer.Length > 0:
DisplayText(left, buffer);
Console.WriteLine();
return buffer;
case { Key: ConsoleKey.Backspace } when index > 0:
index--;
buffer = buffer.Remove(index, 1);
break;
case { Key: ConsoleKey.Delete } when index < buffer.Length:
buffer = buffer.Remove(index, 1);
break;
case { Key: ConsoleKey.LeftArrow, Modifiers: ConsoleModifiers.Control }:
while (index > 0 && buffer[index - 1] == ' ')
index--;
while (index > 0 && buffer[index - 1] != ' ')
index--;
break;
case { Key: ConsoleKey.RightArrow, Modifiers: ConsoleModifiers.Control }:
while (index < buffer.Length && buffer[index] == ' ')
index++;
while (index < buffer.Length && buffer[index] != ' ')
index++;
break;
case { Key: ConsoleKey.LeftArrow } when index > 0:
index--;
break;
case { Key: ConsoleKey.RightArrow } when index < buffer.Length:
index++;
break;
case { Key: ConsoleKey.W, Modifiers: ConsoleModifiers.Control } when index > 0:
var deleteStart = index;
while (deleteStart > 0 && buffer[deleteStart - 1] == ' ')
deleteStart--;
while (deleteStart > 0 && buffer[deleteStart - 1] != ' ')
deleteStart--;
var charsToDelete = index - deleteStart;
buffer = buffer.Remove(deleteStart, charsToDelete);
index = deleteStart;
break;
default:
var keyChar = inputKey.KeyChar;
if (!(char.IsLetterOrDigit(keyChar) || char.IsWhiteSpace(keyChar) || char.IsPunctuation(keyChar) || char.IsSymbol(keyChar)))
break;
buffer = buffer.Insert(index, inputKey.KeyChar.ToString());
index++;
break;
}
// Adjust viewport for scrolling
var visibleWidth = Console.WindowWidth - left - 1;
if (index < viewportOffset)
viewportOffset = index;
else if (index >= viewportOffset + visibleWidth)
viewportOffset = index - visibleWidth + 1;
DisplayText(left, buffer, index, placeholder, viewportOffset);
}
}
}
}

View File

@@ -5,10 +5,27 @@ namespace AnchorCli.OpenRouter;
/// </summary> /// </summary>
internal sealed class TokenTracker internal sealed class TokenTracker
{ {
private ChatSession _session;
public TokenTracker(ChatSession 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 { get; private set; } public long SessionInputTokens => _session.SessionInputTokens;
public long SessionOutputTokens { get; private set; } public long SessionOutputTokens => _session.SessionOutputTokens;
public int RequestCount { get; private set; } public int RequestCount => _session.RequestCount;
/// <summary>Maximum context window for the model (tokens). 0 = unknown.</summary> /// <summary>Maximum context window for the model (tokens). 0 = unknown.</summary>
public int ContextLength { get; set; } public int ContextLength { get; set; }
@@ -29,16 +46,16 @@ internal sealed class TokenTracker
/// </summary> /// </summary>
public void AddUsage(int inputTokens, int outputTokens) public void AddUsage(int inputTokens, int outputTokens)
{ {
SessionInputTokens += inputTokens; _session.SessionInputTokens += inputTokens;
SessionOutputTokens += outputTokens; _session.SessionOutputTokens += outputTokens;
LastInputTokens = inputTokens; LastInputTokens = inputTokens;
RequestCount++; _session.RequestCount++;
} }
public void Reset() public void Reset()
{ {
SessionInputTokens = 0; _session.SessionInputTokens = 0;
SessionOutputTokens = 0; _session.SessionOutputTokens = 0;
RequestCount = 0; _session.RequestCount = 0;
LastInputTokens = 0; LastInputTokens = 0;
} }

View File

@@ -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*

View File

@@ -1,189 +1,45 @@
using System.ClientModel;
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;
// ── Setup subcommand ───────────────────────────────────────────────────── Console.InputEncoding = System.Text.Encoding.UTF8;
if (args.Length > 0 && args[0].Equals("setup", StringComparison.OrdinalIgnoreCase)) Console.OutputEncoding = System.Text.Encoding.UTF8;
// ── Application entry point ───────────────────────────────────────────────
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();
var tokenTracker = new TokenTracker { Provider = tokenExtractor.ProviderName };
// ── Fetch model pricing (only for supported providers) ─────────────────
ModelInfo? modelInfo = 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);
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]Provider[/]", $"[blue]{tokenExtractor.ProviderName}[/]");
infoTable.AddRow("[grey]Endpoint[/]", $"[dim]{endpoint}[/]");
infoTable.AddRow("[grey]CWD[/]", $"[green]{Markup.Escape(Environment.CurrentDirectory)}[/]");
if (modelInfo?.Pricing != null)
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 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);
if (modelInfo != null)
{
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));
commandRegistry.Register(new SaveCommand(session));
commandRegistry.Register(new LoadCommand(session));
// 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 */ }

135
README.md
View File

@@ -2,30 +2,14 @@
An AI-powered coding assistant built as a .NET 10.0 console application, featuring the **Hashline** technique for safe, precise file editing. An AI-powered coding assistant built as a .NET 10.0 console application, featuring the **Hashline** technique for safe, precise file editing.
## What is Hashline?
AnchorCli's unique approach to file editing. Every line returned by file tools is prefixed with a content-derived hash anchor:
```
1:a3| function hello() {
2:f1| return "world";
3:0e| }
```
When editing, you reference these `line:hash` anchors instead of reproducing old content. Before any mutation, both the line number **and** hash are validated — stale anchors are rejected immediately.
This eliminates:
- Whitespace/indentation reproduction errors
- Silent edits to the wrong line in large files
- Entire-file rewrites just to change one line
## Features ## Features
- **Batch Editing**: Apply multiple replace/delete operations atomically in a single pass without line drift
- **Interactive REPL**: Chat with an AI model to edit files, manage directories, and execute commands - **Interactive REPL**: Chat with an AI model to edit files, manage directories, and execute commands
- **Slash Commands**: `/setup`, `/help`, `/exit`, `/clear`, `/status`, `/compact`, `/reset` - **Slash Commands**: `/setup`, `/help`, `/exit`, `/clear`, `/status`, `/compact`, `/reset`, `/load`, `/save`
- **Token Tracking**: Real-time token usage and cost per response, plus session totals - **Token Tracking**: Real-time token usage and cost per response, plus session totals
- **Model Pricing Display**: Shows current model pricing from OpenRouter in the header - **Model Pricing Display**: Shows current model pricing from OpenRouter in the header
- **Context Compaction**: Automatic conversation history compression when approaching context limits, including stale tool result compaction - **Context Compaction**: Automatic conversation history compression when approaching context limits
- **Comprehensive Toolset**: 15 tools for file operations, editing, directory management, and command execution - **Comprehensive Toolset**: 15 tools for file operations, editing, directory management, and command execution
- **AOT-Ready**: Native AOT compilation for ~12 MB binaries with no .NET runtime dependency - **AOT-Ready**: Native AOT compilation for ~12 MB binaries with no .NET runtime dependency
- **Rich CLI**: Beautiful terminal output using Spectre.Console with tables, rules, and colored text - **Rich CLI**: Beautiful terminal output using Spectre.Console with tables, rules, and colored text
@@ -33,12 +17,14 @@ This eliminates:
- **OpenAI-Compatible**: Works with any OpenAI-compatible API (OpenAI, Ollama, Cerebras, Groq, OpenRouter, etc.) - **OpenAI-Compatible**: Works with any OpenAI-compatible API (OpenAI, Ollama, Cerebras, Groq, OpenRouter, etc.)
- **Ctrl+C Support**: Cancel in-progress responses without exiting - **Ctrl+C Support**: Cancel in-progress responses without exiting
## Requirements ## Installation
### Requirements
- .NET 10 SDK - .NET 10 SDK
- `clang` (for native AOT publish on Linux) - `clang` (for native AOT publish on Linux)
## Quick Start ### Quick Start
```bash ```bash
# Run the application # Run the application
@@ -46,11 +32,10 @@ dotnet run --project AnchorCli
# First time? The app will prompt you to run /setup # First time? The app will prompt you to run /setup
# Or run it explicitly: # Or run it explicitly:
dotnet run --project AnchorCli
/setup /setup
``` ```
## Native AOT Build ### Native AOT Build
```bash ```bash
dotnet publish AnchorCli -r linux-x64 -c Release dotnet publish AnchorCli -r linux-x64 -c Release
@@ -59,91 +44,17 @@ dotnet publish AnchorCli -r linux-x64 -c Release
The resulting binary is ~12 MB, has no .NET runtime dependency, and starts instantly. The resulting binary is ~12 MB, has no .NET runtime dependency, and starts instantly.
## Slash Commands ## Usage
| Command | Description |
|---|---|
| `/setup` | Run interactive TUI to configure API key and model (also accessible via `anchor setup` subcommand) |
| `/help` | Show available tools and commands |
| `/exit` | Exit the application |
| `/clear` | Clear the conversation history |
| `/status` | Show session token usage and cost |
| `/compact` | Manually trigger context compaction |
| `/reset` | Clear session and reset token tracker |
## Available Tools
**File Operations:**
- `read_file` - Read a file (or a window) with Hashline-tagged lines
- `grep_file` - Search a file by regex — results are pre-tagged for immediate editing
- `grep_recursive` - Search for a regex pattern across all files in a directory tree
- `find_files` - Search for files matching glob patterns
- `get_file_info` - Get detailed file information (size, permissions, etc.)
**Edit Operations:**
- `replace_lines` - Replace a range identified by `line:hash` anchors
- `insert_after` - Insert lines after an anchor
- `delete_range` - Delete a range between two anchors
- `create_file` - Create a new file with optional initial content
- `delete_file` - Delete a file permanently
- `rename_file` - Rename or move a file
- `copy_file` - Copy a file to a new location
- `append_to_file` - Append lines to the end of a file
**Directory Operations:**
- `list_dir` - List directory contents
- `create_dir` - Create a new directory (with parent directories)
- `rename_dir` - Rename or move a directory
- `delete_dir` - Delete a directory and all its contents
**Command Execution:**
- `execute_command` - Run shell commands (with user approval)
```
AnchorCli/
├── Program.cs # Entry point + CLI parsing
├── ReplLoop.cs # Main REPL loop with streaming, spinners, and cancellation
├── ChatSession.cs # AI chat client wrapper with message history
├── ToolRegistry.cs # Centralized tool registration and dispatch
├── AnchorConfig.cs # JSON file-based configuration (~APPDATA~/anchor/config.json)
├── ContextCompactor.cs # Conversation history compression
├── AppJsonContext.cs # Source-generated JSON context (AOT)
├── SetupTui.cs # Interactive setup TUI
├── Hashline/
│ ├── HashlineEncoder.cs # Adler-8 + position-seed hashing
│ └── HashlineValidator.cs # Anchor resolution + validation
├── Tools/
│ ├── FileTools.cs # read_file, grep_file, grep_recursive, find_files, get_file_info
│ ├── EditTools.cs # replace_lines, insert_after, delete_range, create/delete/rename/copy/append
│ ├── DirTools.cs # list_dir, create_dir, rename_dir, delete_dir
│ └── CommandTool.cs # execute_command
├── Commands/
│ ├── ICommand.cs # Command interface
│ ├── CommandRegistry.cs # Command registration
│ ├── CommandDispatcher.cs # Command dispatch logic
│ ├── ExitCommand.cs # /exit command
│ ├── HelpCommand.cs # /help command
│ ├── ClearCommand.cs # /clear command
│ ├── StatusCommand.cs # /status command
│ ├── CompactCommand.cs # /compact command
│ ├── ResetCommand.cs # /reset command
│ └── SetupCommand.cs # /setup command
└── OpenRouter/
└── PricingProvider.cs # Fetch model pricing from OpenRouter
```
## How It Works
1. **Setup**: Configure API credentials via the `/setup` command (or `anchor setup` subcommand) 1. **Setup**: Configure API credentials via the `/setup` command (or `anchor setup` subcommand)
2. **REPL Loop**: You interact with the AI through a conversational interface 2. **REPL Loop**: Interact with the AI through a conversational interface
3. **Tool Calling**: The AI can call any of the available tools to read/edit files, manage directories, or execute commands 3. **Tool Calling**: The AI can call tools to read/edit files, manage directories, or execute commands
4. **Hashline Validation**: All file edits are validated using the Hashline technique to ensure precision 4. **Safe Execution**: Commands require explicit user approval before running
5. **Token Tracking**: Responses show token usage and cost; session totals are maintained
6. **Context Compaction**: When approaching context limits, conversation history is automatically compressed
7. **Safe Execution**: Commands require explicit user approval before running
## Supported Models ### Supported Models
AnchorCli works with any OpenAI-compatible API endpoint:
AnchorCli works with any OpenAI-compatible API endpoint, including:
- OpenAI (gpt-4o, gpt-4.1, etc.) - OpenAI (gpt-4o, gpt-4.1, etc.)
- Ollama (local models) - Ollama (local models)
- Cerebras - Cerebras
@@ -151,6 +62,22 @@ AnchorCli works with any OpenAI-compatible API endpoint, including:
- OpenRouter (qwen3.5-27b, etc.) - OpenRouter (qwen3.5-27b, etc.)
- Any custom OpenAI-compatible server - Any custom OpenAI-compatible server
## Configuration
Configuration is stored in `~APPDATA~/anchor/config.json` on Windows or `~/.anchor/config.json` on Linux/macOS.
Use the `/setup` command or run `anchor setup` from the command line to configure:
- API Key
- API Endpoint (default: OpenRouter)
- Model selection
## Documentation
- [**Available Tools**](docs/TOOLS.md) - Complete list of all tools organized by category
- [**Hashline Technique**](docs/HASHLINE.md) - Detailed explanation of Hashline editing with examples
- [**Slash Commands**](docs/COMMANDS.md) - All available slash commands with descriptions
## License ## License
MIT License - See LICENSE file for details. MIT License - See LICENSE file for details.

View File

@@ -1,28 +1,42 @@
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()
{ {
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.MarkupLine("[dim]Press [bold]Ctrl+C[/] to cancel the current response.[/]");
AnsiConsole.WriteLine(); AnsiConsole.WriteLine();
@@ -30,228 +44,36 @@ internal sealed class ReplLoop
Console.CancelKeyPress += (_, e) => Console.CancelKeyPress += (_, e) =>
{ {
e.Cancel = true; // Prevent process termination e.Cancel = true;
responseCts?.Cancel(); responseCts?.Cancel();
}; };
while (true) while (true)
{ {
string input = ReadLine.Read(" "); AnsiConsole.Markup("[grey] [/]");
string input = InputProcessor.ReadLine("Type your message, or use [bold]/help[/] to see commands.");
if (string.IsNullOrWhiteSpace(input)) continue; if (string.IsNullOrWhiteSpace(input)) continue;
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);
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; // 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")));
AnsiConsole.WriteLine();
_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) catch (OperationCanceledException)
{ {
AnsiConsole.WriteLine(); HandleCancellation();
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) catch (Exception ex)
{ {
AnsiConsole.WriteLine(); DisplayError(ex);
AnsiConsole.Write(
new Panel($"[red]{Markup.Escape(ex.Message)}[/]")
.Header("[bold red] Error [/]")
.BorderColor(Color.Red)
.RoundedBorder()
.Padding(1, 0));
AnsiConsole.WriteLine();
} }
finally finally
{ {
@@ -259,7 +81,84 @@ internal sealed class ReplLoop
responseCts = null; responseCts = null;
} }
} }
catch (Exception ex) }
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.WriteLine();
AnsiConsole.Write( AnsiConsole.Write(
@@ -270,6 +169,4 @@ internal sealed class ReplLoop
.Padding(1, 0)); .Padding(1, 0));
AnsiConsole.WriteLine(); AnsiConsole.WriteLine();
} }
}
}
} }

60
ResponseStreamer.cs Normal file
View 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; }
}

View File

@@ -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
View 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
View 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;
}
}

View File

@@ -9,22 +9,22 @@ internal static class ToolRegistry
{ {
var jsonOptions = AppJsonContext.Default.Options; var jsonOptions = AppJsonContext.Default.Options;
return new List<AITool> return
{ [
AIFunctionFactory.Create(FileTools.ReadFile, serializerOptions: jsonOptions), AIFunctionFactory.Create(FileTools.ReadFile, name: "read_file", serializerOptions: jsonOptions),
AIFunctionFactory.Create(FileTools.Grep, serializerOptions: jsonOptions), AIFunctionFactory.Create(FileTools.Grep, name: "grep", serializerOptions: jsonOptions),
AIFunctionFactory.Create(FileTools.ListDir, serializerOptions: jsonOptions), AIFunctionFactory.Create(FileTools.ListDir, name: "list_dir", serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.ReplaceLines, serializerOptions: jsonOptions), AIFunctionFactory.Create(EditTools.ReplaceLines, name: "replace_lines", serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.DeleteRange, serializerOptions: jsonOptions), AIFunctionFactory.Create(EditTools.DeleteRange, name: "delete_range", serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.BatchEdit, serializerOptions: jsonOptions), AIFunctionFactory.Create(EditTools.BatchEdit, name: "batch_edit", serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.Delete, serializerOptions: jsonOptions), AIFunctionFactory.Create(EditTools.Delete, name: "delete_file", serializerOptions: jsonOptions),
AIFunctionFactory.Create(FileTools.FindFiles, serializerOptions: jsonOptions), AIFunctionFactory.Create(FileTools.FindFiles, name: "find_files", serializerOptions: jsonOptions),
AIFunctionFactory.Create(FileTools.GetFileInfo, serializerOptions: jsonOptions), AIFunctionFactory.Create(FileTools.GetFileInfo, name: "get_file_info", serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.WriteToFile, serializerOptions: jsonOptions), AIFunctionFactory.Create(EditTools.WriteToFile, name: "write_to_file", serializerOptions: jsonOptions),
AIFunctionFactory.Create(CommandTool.ExecuteCommand, serializerOptions: jsonOptions), AIFunctionFactory.Create(CommandTool.ExecuteCommand, name: "execute_command", serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.MoveFile, serializerOptions: jsonOptions), AIFunctionFactory.Create(EditTools.MoveFile, name: "rename_file", serializerOptions: jsonOptions),
AIFunctionFactory.Create(DirTools.RenameDir, serializerOptions: jsonOptions), AIFunctionFactory.Create(DirTools.RenameDir, name: "rename_dir", serializerOptions: jsonOptions),
AIFunctionFactory.Create(DirTools.CreateDir, serializerOptions: jsonOptions), AIFunctionFactory.Create(DirTools.CreateDir, name: "create_dir", serializerOptions: jsonOptions),
}; ];
} }
} }

View File

@@ -21,7 +21,7 @@ internal static class CommandTool
public static string ExecuteCommand( public static string ExecuteCommand(
[Description("The shell command to execute.")] string command) [Description("The shell command to execute.")] string command)
{ {
Log($"Command request: {command}"); Log($" ● execute_command: {command}");
// Prompt for user approval // Prompt for user approval
PauseSpinner?.Invoke(); PauseSpinner?.Invoke();

View File

@@ -17,7 +17,7 @@ internal static class DirTools
{ {
sourcePath = ResolvePath(sourcePath); sourcePath = ResolvePath(sourcePath);
destinationPath = ResolvePath(destinationPath); destinationPath = ResolvePath(destinationPath);
Log($"Renaming/moving directory: {sourcePath} -> {destinationPath}"); Log($" ● rename_dir: {sourcePath} -> {destinationPath}");
if (!Directory.Exists(sourcePath)) if (!Directory.Exists(sourcePath))
return $"ERROR: Directory not found: {sourcePath}"; return $"ERROR: Directory not found: {sourcePath}";
@@ -44,7 +44,7 @@ internal static class DirTools
[Description("Path to the directory to create.")] string path) [Description("Path to the directory to create.")] string path)
{ {
path = ResolvePath(path); path = ResolvePath(path);
Log($"Creating directory: {path}"); Log($" ● create_dir: {path}");
if (Directory.Exists(path)) if (Directory.Exists(path))
return $"ERROR: Directory already exists: {path}"; return $"ERROR: Directory already exists: {path}";

View File

@@ -70,9 +70,8 @@ internal static partial class EditTools
{ {
newLines = SanitizeNewLines(newLines); newLines = SanitizeNewLines(newLines);
path = FileTools.ResolvePath(path); path = FileTools.ResolvePath(path);
Log($"REPLACE_LINES: {path}"); Log($" ● replace_lines: {path}");
Log($" Range: {startAnchor} -> {endAnchor}"); Log($" {startAnchor.Split(':')[0]}-{endAnchor.Split(':')[0]} lines -> {newLines.Length} new lines");
Log($" Replacing {endAnchor.Split(':')[0]}-{startAnchor.Split(':')[0]} lines with {newLines.Length} new lines");
if (!File.Exists(path)) if (!File.Exists(path))
return $"ERROR: File not found: {path}\n Check the correct path and try again."; return $"ERROR: File not found: {path}\n Check the correct path and try again.";
@@ -107,7 +106,8 @@ internal static partial class EditTools
[Description("Last line's line:hash anchor (e.g. '6:19').")] string endAnchor) [Description("Last line's line:hash anchor (e.g. '6:19').")] string endAnchor)
{ {
path = FileTools.ResolvePath(path); path = FileTools.ResolvePath(path);
Log($"Deleting lines in file: {path}"); Log($" ● delete_range: {path}");
Log($" {startAnchor.Split(':')[0]}-{endAnchor.Split(':')[0]} lines");
if (!File.Exists(path)) if (!File.Exists(path))
return $"ERROR: File not found: {path}"; return $"ERROR: File not found: {path}";
@@ -142,7 +142,7 @@ internal static partial class EditTools
{ {
path = FileTools.ResolvePath(path); path = FileTools.ResolvePath(path);
string targetType = mode.Equals("dir", StringComparison.CurrentCultureIgnoreCase) ? "directory" : "file"; string targetType = mode.Equals("dir", StringComparison.CurrentCultureIgnoreCase) ? "directory" : "file";
Log($"Deleting {targetType}: {path}"); Log($" ● delete_{targetType}: {path}");
if (mode.Equals("dir", StringComparison.CurrentCultureIgnoreCase)) if (mode.Equals("dir", StringComparison.CurrentCultureIgnoreCase))
{ {
@@ -184,8 +184,8 @@ internal static partial class EditTools
{ {
sourcePath = FileTools.ResolvePath(sourcePath); sourcePath = FileTools.ResolvePath(sourcePath);
destinationPath = FileTools.ResolvePath(destinationPath); destinationPath = FileTools.ResolvePath(destinationPath);
string action = copy ? "Copying" : "Moving"; string action = copy ? "copy" : "move";
Log($"{action} file: {sourcePath} -> {destinationPath}"); Log($"{action}_file: {sourcePath} -> {destinationPath}");
if (!File.Exists(sourcePath)) if (!File.Exists(sourcePath))
return $"ERROR: Source file not found: {sourcePath}"; return $"ERROR: Source file not found: {sourcePath}";
@@ -221,9 +221,8 @@ internal static partial class EditTools
{ {
content = SanitizeNewLines(content); content = SanitizeNewLines(content);
path = FileTools.ResolvePath(path); path = FileTools.ResolvePath(path);
Log($"WRITE_TO_FILE: {path}"); Log($" ● write_to_file: {path}");
Log($" Mode: {mode}"); Log($" mode: {mode} with {content.Length} lines");
Log($" Writing {content.Length} lines");
try try
{ {
@@ -298,8 +297,8 @@ internal static partial class EditTools
[Description("Array of operations to apply. Operations are applied in bottom-to-top order automatically.")] BatchOperation[] operations) [Description("Array of operations to apply. Operations are applied in bottom-to-top order automatically.")] BatchOperation[] operations)
{ {
path = FileTools.ResolvePath(path); path = FileTools.ResolvePath(path);
Log($"BATCH_EDIT: {path}"); Log($" ● batch_edit: {path}");
Log($" Operations: {operations.Length}"); Log($" operations: {operations.Length}");
if (!File.Exists(path)) if (!File.Exists(path))
return $"ERROR: File not found: {path}"; return $"ERROR: File not found: {path}";
@@ -352,8 +351,8 @@ internal static partial class EditTools
} }
} }
// Sort operations bottom-to-top (by start index descending) for safe application // Sort operations top-to-bottom (by start index ascending) because we build a new list sequentially
var sortedOps = resolvedOps.OrderByDescending(x => x.StartIdx).ToList(); var sortedOps = resolvedOps.OrderBy(x => x.StartIdx).ToList();
// Apply all operations to a single buffer // Apply all operations to a single buffer
var result = new List<string>(lines.Length); var result = new List<string>(lines.Length);

View File

@@ -27,7 +27,7 @@ internal static class FileTools
[Description("Last line to return (inclusive). Use 0 for EOF. Defaults to 0.")] int endLine = 0) [Description("Last line to return (inclusive). Use 0 for EOF. Defaults to 0.")] int endLine = 0)
{ {
path = ResolvePath(path); path = ResolvePath(path);
Log($"Reading file: {path} {startLine}:{endLine}L"); Log($" ● read_file: {path} {startLine}:{endLine}L");
if (!File.Exists(path)) if (!File.Exists(path))
return $"ERROR: File not found: {path}"; return $"ERROR: File not found: {path}";
@@ -62,7 +62,7 @@ internal static class FileTools
[Description("Path to the directory.")] string path = ".") [Description("Path to the directory.")] string path = ".")
{ {
path = ResolvePath(path); path = ResolvePath(path);
Log($"Listing directory: {path}"); Log($" ● list_dir: {path}");
if (!Directory.Exists(path)) if (!Directory.Exists(path))
return $"ERROR: Directory not found: {path}"; return $"ERROR: Directory not found: {path}";
@@ -95,7 +95,7 @@ internal static class FileTools
[Description("Glob pattern (supports * and **).")] string pattern) [Description("Glob pattern (supports * and **).")] string pattern)
{ {
path = ResolvePath(path); path = ResolvePath(path);
Log($"Finding files: {pattern} in {path}"); Log($" ● find_files: {pattern} in {path}");
if (!Directory.Exists(path)) if (!Directory.Exists(path))
return $"ERROR: Directory not found: {path}"; return $"ERROR: Directory not found: {path}";
@@ -139,7 +139,7 @@ internal static class FileTools
if (mode == "file") if (mode == "file")
{ {
Log($"Searching file: {path}"); Log($" ● grep_file: {path}");
if (!File.Exists(path)) if (!File.Exists(path))
if (Directory.Exists(path)) if (Directory.Exists(path))
@@ -186,7 +186,7 @@ internal static class FileTools
} }
else if (mode == "recursive") else if (mode == "recursive")
{ {
Log($"Recursive grep: {pattern} in {path}" + (filePattern != null ? $" (files: {filePattern})" : "")); Log($" ● grep_recursive: {pattern} in {path}" + (filePattern != null ? $" (files: {filePattern})" : ""));
if (!Directory.Exists(path)) if (!Directory.Exists(path))
if (File.Exists(path)) if (File.Exists(path))
@@ -297,7 +297,7 @@ internal static class FileTools
[Description("Path to the file.")] string path) [Description("Path to the file.")] string path)
{ {
path = ResolvePath(path); path = ResolvePath(path);
Log($"Getting file info: {path}"); Log($" ● get_file_info: {path}");
if (!File.Exists(path)) if (!File.Exists(path))
return $"ERROR: File not found: {path}"; return $"ERROR: File not found: {path}";

48
UsageDisplayer.cs Normal file
View 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")));
}
}

File diff suppressed because it is too large Load Diff

36
docs/COMMANDS.md Normal file
View File

@@ -0,0 +1,36 @@
# Slash Commands
AnchorCli provides several slash commands for managing your session and interacting with the AI.
## Available Commands
| Command | Description |
|---------|-------------|
| `/setup` | Run interactive TUI to configure API key and model (also accessible via `anchor setup` subcommand) |
| `/help` | Show available tools and commands |
| `/exit` | Exit the application |
| `/clear` | Clear the conversation history |
| `/status` | Show session token usage and cost |
| `/compact` | Manually trigger context compaction |
| `/reset` | Clear session and reset token tracker |
| `/load` | Load a previous session from disk |
| `/save` | Save current session to disk |
## Usage
Type any command starting with `/` in the REPL to execute it:
```
> /status
Session: 1,234 tokens used ($0.0015)
```
```
> /help
Available tools: read_file, grep_file, replace_lines, ...
Available commands: /setup, /help, /exit, /clear, ...
```
---
*Back to [README.md](../README.md)*

55
docs/HASHLINE.md Normal file
View File

@@ -0,0 +1,55 @@
# The Hashline Technique
Hashline is AnchorCli's unique approach to safe, precise file editing.
## How It Works
Every line returned by file tools is prefixed with a content-derived hash anchor:
```
function hello() {
return "world";
}
```
The format is `lineNumber:hash|content` where:
- `lineNumber` is the current line number in the file
- `hash` is an Adler-8 checksum derived from the line content and position
- `content` is the actual line text
## Editing with Anchors
When editing, you reference these `line:hash` anchors instead of reproducing old content. Before any mutation, both the line number **and** hash are validated — stale anchors are rejected immediately.
### Example
To replace lines 2-3 from the example above:
```json
{
"tool": "replace_lines",
"path": "example.js",
"startAnchor": "2:f1",
"endAnchor": "3:0e",
"newLines": [" return \"hello world\";"]
}
```
## Benefits
This eliminates:
- **Whitespace/indentation reproduction errors** — No need to match exact spacing
- **Silent edits to the wrong line** — Hash validation ensures you're editing the right content
- **Entire-file rewrites** — Change just one line without touching the rest
- **Line drift in batch operations** — BatchEdit validates all anchors upfront and applies changes bottom-to-top
## Key Principles
1. **Never include anchors in your content** — When using `replace_lines`, `insert_after`, or similar tools, the `newLines` parameter should contain raw source code only, WITHOUT the `lineNumber:hash|` prefix
2. **Always validate before editing** — Re-read files to get fresh anchors if your previous anchors fail validation
3. **Use BatchEdit for multiple changes** — This validates all anchors upfront and applies operations atomically
---
*Back to [README.md](../README.md)* | *See [TOOLS.md](TOOLS.md) for all available tools*

53
docs/TOOLS.md Normal file
View File

@@ -0,0 +1,53 @@
# Available Tools
AnchorCli provides a comprehensive set of tools for file operations, editing, directory management, and command execution.
## File Operations
| Tool | Description |
|------|-------------|
| `read_file` | Read a file (or a window) with Hashline-tagged lines |
| `grep_file` | Search a file by regex — results are pre-tagged for immediate editing |
| `grep_recursive` | Search for a regex pattern across all files in a directory tree |
| `find_files` | Search for files matching glob patterns |
| `get_file_info` | Get detailed file information (size, permissions, etc.) |
## Edit Operations
| Tool | Description |
|------|-------------|
| `replace_lines` | Replace a range identified by `line:hash` anchors |
| `insert_after` | Insert lines after an anchor |
| `delete_range` | Delete a range between two anchors |
| `create_file` | Create a new file with optional initial content |
| `delete_file` | Delete a file permanently |
| `rename_file` | Rename or move a file |
| `copy_file` | Copy a file to a new location |
| `append_to_file` | Append lines to the end of a file |
| `batch_edit` | Apply multiple replace/delete operations atomically in a single call |
## Directory Operations
| Tool | Description |
|------|-------------|
| `list_dir` | List directory contents |
| `create_dir` | Create a new directory (with parent directories) |
| `rename_dir` | Rename or move a directory |
| `delete_dir` | Delete a directory and all its contents |
## Command Execution
| Tool | Description |
|------|-------------|
| `execute_command` | Run shell commands (with user approval) |
## Tool Usage Guidelines
- All file editing tools use **Hashline anchors** (`line:hash`) for precise, safe edits
- Before any mutation, both the line number **and** hash are validated — stale anchors are rejected
- `batch_edit` is preferred for multiple operations to prevent line drift
- `grep_file` results are pre-tagged with anchors for immediate editing
---
*For more details on the Hashline technique, see [HASHLINE.md](HASHLINE.md)*

View File

@@ -1,105 +0,0 @@
# Tool Consolidation Ideas
This document outlines opportunities to merge similar tools to simplify the API.
## 1. File Write Operations
**Current tools:** `CreateFile`, `InsertAfter`, `AppendToFile`
**Proposed merge:** `WriteToFile`
```csharp
public static string WriteToFile(
string path,
string[] content,
string? mode = "create",
string? anchor = null)
```
**Behavior:**
- `mode="create"` - Creates new file (error if exists)
- `mode="append"` - Appends to EOF (creates if missing)
- `mode="insert"` - Inserts after anchor (requires existing file)
**Benefits:**
- Reduces 3 tools to 1
- Cleaner API for LLM
- Unified error handling
## 2. File Move Operations
**Current tools:** `RenameFile`, `CopyFile`
**Proposed merge:** `MoveFile`
```csharp
public static string MoveFile(
string sourcePath,
string destinationPath,
bool copy = false)
```
**Behavior:**
- `copy=false` - Moves file (current RenameFile behavior)
- `copy=true` - Copies file (current CopyFile behavior)
**Benefits:**
- 90% identical logic
- Only difference is File.Move vs File.Copy
- Both create parent directories
- Similar error handling patterns
## 4. Grep Operations ✅ DONE
**Current tools:** `GrepFile`, `GrepRecursive`
**Proposed merge:** `Grep`
```csharp
public static string Grep(
string path,
string pattern,
string mode = "recursive",
string? filePattern = null)
```
**Behavior:**
- `mode="file"` - Searches single file (current GrepFile)
- `mode="recursive"` - Searches directory recursively (current GrepRecursive)
- `filePattern` - Optional glob to filter files when recursive
**Benefits:**
- Very similar logic
- Reduces 2 tools to 1
- Cleaner API for LLM
## 5. Delete Operations ✅ DONE
**Current tools:** `DeleteFile`, `DeleteDir`
**Proposed merge:** `Delete`
```csharp
public static string Delete(
string path,
string mode = "file")
```
**Behavior:**
- `mode="file"` - Deletes a file
- `mode="dir"` - Deletes a directory (recursive)
**Benefits:**
- Unified interface for all deletion
- Similar error handling patterns
- Reduces 2 tools to 1
These consolidations reduced the tool count from 17 to 13 tools (4 completed), making the API simpler and easier for the LLM to use effectively.
**Completed merges**:
1. ✅ File Move Operations (2 → 1) - **DONE**
2. ✅ File Write Operations (3 → 1) - **DONE**
3. ✅ Delete Operations (2 → 1) - **DONE**
4. ✅ Grep Operations (2 → 1) - **DONE**
**All high priority merges completed!**