feat: Introduce Hashline encoding/validation, implement core REPL and chat session logic, and establish initial project structure with a license and editor configuration.
This commit is contained in:
12
.editorconfig
Normal file
12
.editorconfig
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
end_of_line = crlf
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.cs]
|
||||||
|
dotnet_sort_system_directives_first = true
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
<InvariantGlobalization>true</InvariantGlobalization>
|
<InvariantGlobalization>true</InvariantGlobalization>
|
||||||
<StripSymbols>true</StripSymbols>
|
<StripSymbols>true</StripSymbols>
|
||||||
<AssemblyName>anchor</AssemblyName>
|
<AssemblyName>anchor</AssemblyName>
|
||||||
|
<Version>0.1.0</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup Condition="$([MSBuild]::IsOSPlatform('Windows'))">
|
<PropertyGroup Condition="$([MSBuild]::IsOSPlatform('Windows'))">
|
||||||
|
|||||||
59
ChatSession.cs
Normal file
59
ChatSession.cs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
|
namespace AnchorCli;
|
||||||
|
|
||||||
|
internal sealed class ChatSession
|
||||||
|
{
|
||||||
|
private readonly IChatClient _agent;
|
||||||
|
public ContextCompactor Compactor { get; }
|
||||||
|
public List<ChatMessage> History { get; }
|
||||||
|
|
||||||
|
public ChatSession(IChatClient innerClient)
|
||||||
|
{
|
||||||
|
Compactor = new ContextCompactor(innerClient);
|
||||||
|
|
||||||
|
var tools = ToolRegistry.GetTools();
|
||||||
|
_agent = new ChatClientBuilder(innerClient)
|
||||||
|
.UseFunctionInvocation()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
History = new List<ChatMessage>
|
||||||
|
{
|
||||||
|
new(ChatRole.System, $$"""
|
||||||
|
You are anchor, a coding assistant that edits files using the Hashline technique.
|
||||||
|
|
||||||
|
## Reading files
|
||||||
|
When you read a file, lines are returned in the format: lineNumber:hash|content
|
||||||
|
The "lineNumber:hash|" prefix is METADATA for anchoring — it is NOT part of the file.
|
||||||
|
|
||||||
|
## Editing files
|
||||||
|
To edit, reference anchors as "lineNumber:hash" in startAnchor/endAnchor parameters.
|
||||||
|
The newLines/initialLines parameter must contain RAW SOURCE CODE ONLY.
|
||||||
|
❌ WRONG: ["5:a3| public void Foo()"]
|
||||||
|
✅ RIGHT: [" public void Foo()"]
|
||||||
|
Never include the "lineNumber:hash|" prefix in content you write — it will corrupt the file.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
1. Always read a file before editing it.
|
||||||
|
2. After a mutation, verify the returned fingerprint.
|
||||||
|
3. Edit from bottom to top so line numbers don't shift.
|
||||||
|
4. If an anchor fails validation, re-read the file to get fresh anchors.
|
||||||
|
|
||||||
|
Keep responses concise. You have access to the current working directory.
|
||||||
|
You are running on: {{System.Runtime.InteropServices.RuntimeInformation.OSDescription}}
|
||||||
|
""")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
||||||
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var options = new ChatOptions { Tools = ToolRegistry.GetTools() };
|
||||||
|
var stream = _agent.GetStreamingResponseAsync(History, options, cancellationToken);
|
||||||
|
|
||||||
|
await foreach (var update in stream.WithCancellation(cancellationToken))
|
||||||
|
{
|
||||||
|
yield return update;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,8 @@ internal static class HashlineEncoder
|
|||||||
/// Computes a short file-level fingerprint: XOR of all per-line hashes (as bytes).
|
/// Computes a short file-level fingerprint: XOR of all per-line hashes (as bytes).
|
||||||
/// Useful for cheap full-file staleness checks.
|
/// Useful for cheap full-file staleness checks.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="lines">All lines of the file (without trailing newlines).</param>
|
||||||
|
/// <returns>A 2-character hex fingerprint.</returns>
|
||||||
public static string FileFingerprint(string[] lines)
|
public static string FileFingerprint(string[] lines)
|
||||||
{
|
{
|
||||||
int fp = 0;
|
int fp = 0;
|
||||||
|
|||||||
@@ -83,6 +83,13 @@ internal static class HashlineValidator
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates both a start and end anchor, and ensures start <= end.
|
/// Validates both a start and end anchor, and ensures start <= end.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="startAnchor">The starting anchor string.</param>
|
||||||
|
/// <param name="endAnchor">The ending anchor string.</param>
|
||||||
|
/// <param name="lines">Current file lines (without newlines).</param>
|
||||||
|
/// <param name="startIndex">Resolved 0-based start index on success.</param>
|
||||||
|
/// <param name="endIndex">Resolved 0-based end index on success.</param>
|
||||||
|
/// <param name="error">Human-readable error message on failure.</param>
|
||||||
|
/// <returns>True if the range is valid; false otherwise.</returns>
|
||||||
public static bool TryResolveRange(
|
public static bool TryResolveRange(
|
||||||
string startAnchor,
|
string startAnchor,
|
||||||
string endAnchor,
|
string endAnchor,
|
||||||
|
|||||||
90
IMPROVEME.md
Normal file
90
IMPROVEME.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Improvements for AnchorCli
|
||||||
|
|
||||||
|
This document contains criticisms and suggestions for improving the AnchorCli project.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
1. **Program.cs is too large (433 lines)** - Split into smaller classes: ChatSession, ReplLoop, ResponseStreamer
|
||||||
|
2. **No dependency injection** - Use Microsoft.Extensions.DependencyInjection for testability
|
||||||
|
3. **Static tool classes with global Log delegates** - Convert to instance classes with injected ILogger
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
4. **No unit tests** - Add xUnit project, test HashlineEncoder/Validator, tools, and ContextCompactor
|
||||||
|
5. **No integration tests** - Use Spectre.Console.Testing for TUI workflows
|
||||||
|
6. **No CI/CD** - Add GitHub Actions for test runs on push/PR
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
7. **Missing XML docs** - Add summary docs to public APIs
|
||||||
|
8. **Incomplete README** - Add contributing, development, troubleshooting sections
|
||||||
|
9. **No CHANGELOG.md** - Track releases and changes
|
||||||
|
|
||||||
|
## Security & Safety
|
||||||
|
|
||||||
|
10. **Command execution unsandboxed** - Add allowlist/denylist, time limits, output size limits
|
||||||
|
11. **No mutation rate limiting** - Track edits per turn, add configurable limits
|
||||||
|
12. **API key in plain text** - Use OS keychain or env var, set restrictive file permissions
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
13. **No file read caching** - Cache file content per-turn with invalidation on write
|
||||||
|
14. **Regex not static** - Make compiled regexes static readonly
|
||||||
|
|
||||||
|
## User Experience
|
||||||
|
|
||||||
|
15. **No undo** - Store edit history, add /undo command
|
||||||
|
16. **No session persistence** - Add /save and /load commands
|
||||||
|
17. **Limited error recovery** - Better error messages, /debug mode
|
||||||
|
|
||||||
|
## Developer Experience
|
||||||
|
|
||||||
|
18. **No .editorconfig** - Add code style enforcement
|
||||||
|
19. **No solution file** - Create AnchorCli.sln
|
||||||
|
20. **Hardcoded model list** - Fetch from OpenRouter API dynamically
|
||||||
|
21. **No version info** - Add <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.*
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 AnchorCli Authors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -35,6 +35,8 @@ internal sealed class TokenTracker
|
|||||||
RequestCount++;
|
RequestCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const int MaxContextReserve = 150_000;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns true if the context is getting too large and should be compacted.
|
/// Returns true if the context is getting too large and should be compacted.
|
||||||
/// Triggers at min(75% of model context, 150K tokens).
|
/// Triggers at min(75% of model context, 150K tokens).
|
||||||
@@ -44,8 +46,8 @@ internal sealed class TokenTracker
|
|||||||
if (LastInputTokens <= 0) return false;
|
if (LastInputTokens <= 0) return false;
|
||||||
|
|
||||||
int threshold = ContextLength > 0
|
int threshold = ContextLength > 0
|
||||||
? Math.Min((int)(ContextLength * 0.75), 150_000)
|
? Math.Min((int)(ContextLength * 0.75), MaxContextReserve)
|
||||||
: 150_000;
|
: MaxContextReserve;
|
||||||
|
|
||||||
return LastInputTokens >= threshold;
|
return LastInputTokens >= threshold;
|
||||||
}
|
}
|
||||||
|
|||||||
313
Program.cs
313
Program.cs
@@ -112,322 +112,25 @@ DirTools.Log =
|
|||||||
FileTools.Log =
|
FileTools.Log =
|
||||||
EditTools.Log = ToolLog;
|
EditTools.Log = ToolLog;
|
||||||
|
|
||||||
// ── Collect all tool methods ────────────────────────────────────────────
|
// ── Instantiate Core Components ──────────────────────────────────────────
|
||||||
var jsonOptions = AppJsonContext.Default.Options;
|
|
||||||
|
|
||||||
var tools = new List<AITool>
|
|
||||||
{
|
|
||||||
AIFunctionFactory.Create(FileTools.ReadFile, serializerOptions: jsonOptions),
|
|
||||||
AIFunctionFactory.Create(FileTools.GrepFile, serializerOptions: jsonOptions),
|
|
||||||
AIFunctionFactory.Create(FileTools.ListDir, serializerOptions: jsonOptions),
|
|
||||||
AIFunctionFactory.Create(EditTools.ReplaceLines, serializerOptions: jsonOptions),
|
|
||||||
AIFunctionFactory.Create(EditTools.InsertAfter, serializerOptions: jsonOptions),
|
|
||||||
AIFunctionFactory.Create(EditTools.DeleteRange, serializerOptions: jsonOptions),
|
|
||||||
AIFunctionFactory.Create(EditTools.CreateFile, serializerOptions: jsonOptions),
|
|
||||||
AIFunctionFactory.Create(EditTools.DeleteFile, serializerOptions: jsonOptions),
|
|
||||||
AIFunctionFactory.Create(EditTools.RenameFile, serializerOptions: jsonOptions),
|
|
||||||
AIFunctionFactory.Create(EditTools.CopyFile, serializerOptions: jsonOptions),
|
|
||||||
AIFunctionFactory.Create(DirTools.CreateDir, serializerOptions: jsonOptions),
|
|
||||||
AIFunctionFactory.Create(DirTools.RenameDir, serializerOptions: jsonOptions),
|
|
||||||
AIFunctionFactory.Create(DirTools.DeleteDir, serializerOptions: jsonOptions),
|
|
||||||
AIFunctionFactory.Create(FileTools.FindFiles, serializerOptions: jsonOptions),
|
|
||||||
AIFunctionFactory.Create(FileTools.GrepRecursive, serializerOptions: jsonOptions),
|
|
||||||
AIFunctionFactory.Create(FileTools.GetFileInfo, serializerOptions: jsonOptions),
|
|
||||||
AIFunctionFactory.Create(EditTools.AppendToFile, serializerOptions: jsonOptions),
|
|
||||||
AIFunctionFactory.Create(CommandTool.ExecuteCommand, serializerOptions: jsonOptions),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Wrap with automatic function invocation
|
|
||||||
IChatClient agent = new ChatClientBuilder(innerClient)
|
|
||||||
.UseFunctionInvocation()
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
// ── Context compactor ──────────────────────────────────────────────────
|
|
||||||
var compactor = new ContextCompactor(innerClient);
|
|
||||||
|
|
||||||
|
var session = new ChatSession(innerClient);
|
||||||
if (modelInfo != null)
|
if (modelInfo != null)
|
||||||
|
{
|
||||||
tokenTracker.ContextLength = modelInfo.ContextLength;
|
tokenTracker.ContextLength = modelInfo.ContextLength;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Chat history with system prompt ─────────────────────────────────────
|
|
||||||
List<ChatMessage> history =
|
|
||||||
[
|
|
||||||
new(ChatRole.System, $$"""
|
|
||||||
You are anchor, a coding assistant that edits files using the Hashline technique.
|
|
||||||
|
|
||||||
## Reading files
|
|
||||||
When you read a file, lines are returned in the format: lineNumber:hash|content
|
|
||||||
The "lineNumber:hash|" prefix is METADATA for anchoring — it is NOT part of the file.
|
|
||||||
|
|
||||||
## Editing files
|
|
||||||
To edit, reference anchors as "lineNumber:hash" in startAnchor/endAnchor parameters.
|
|
||||||
The newLines/initialLines parameter must contain RAW SOURCE CODE ONLY.
|
|
||||||
❌ WRONG: ["5:a3| public void Foo()"]
|
|
||||||
✅ RIGHT: [" public void Foo()"]
|
|
||||||
Never include the "lineNumber:hash|" prefix in content you write — it will corrupt the file.
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
1. Always read a file before editing it.
|
|
||||||
2. After a mutation, verify the returned fingerprint.
|
|
||||||
3. Edit from bottom to top so line numbers don't shift.
|
|
||||||
4. If an anchor fails validation, re-read the file to get fresh anchors.
|
|
||||||
|
|
||||||
Keep responses concise. You have access to the current working directory.
|
|
||||||
You are running on: {{System.Runtime.InteropServices.RuntimeInformation.OSDescription}}
|
|
||||||
""")
|
|
||||||
];
|
|
||||||
|
|
||||||
// ── Command system ─────────────────────────────────────────────────────
|
|
||||||
var commandRegistry = new CommandRegistry();
|
var commandRegistry = new CommandRegistry();
|
||||||
commandRegistry.Register(new ExitCommand());
|
commandRegistry.Register(new ExitCommand());
|
||||||
commandRegistry.Register(new HelpCommand(commandRegistry));
|
commandRegistry.Register(new HelpCommand(commandRegistry));
|
||||||
commandRegistry.Register(new ClearCommand());
|
commandRegistry.Register(new ClearCommand());
|
||||||
commandRegistry.Register(new StatusCommand(model, endpoint));
|
commandRegistry.Register(new StatusCommand(model, endpoint));
|
||||||
commandRegistry.Register(new CompactCommand(compactor, history));
|
commandRegistry.Register(new CompactCommand(session.Compactor, session.History));
|
||||||
commandRegistry.Register(new SetupCommand());
|
commandRegistry.Register(new SetupCommand());
|
||||||
|
|
||||||
var commandDispatcher = new CommandDispatcher(commandRegistry);
|
var commandDispatcher = new CommandDispatcher(commandRegistry);
|
||||||
|
|
||||||
|
// ── Run Repl ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// ── REPL ────────────────────────────────────────────────────────────────
|
var repl = new ReplLoop(session, tokenTracker, commandDispatcher);
|
||||||
AnsiConsole.MarkupLine("[dim]Type your message, or use [bold]/help[/] to see commands.[/]");
|
await repl.RunAsync();
|
||||||
AnsiConsole.MarkupLine("[dim]Press [bold]Ctrl+C[/] to cancel the current response.[/]");
|
|
||||||
AnsiConsole.WriteLine();
|
|
||||||
|
|
||||||
// Ctrl+C cancellation: cancel the current response, not the process
|
|
||||||
CancellationTokenSource? responseCts = null;
|
|
||||||
|
|
||||||
Console.CancelKeyPress += (_, e) =>
|
|
||||||
{
|
|
||||||
e.Cancel = true; // Prevent process termination
|
|
||||||
responseCts?.Cancel();
|
|
||||||
};
|
|
||||||
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
string input = ReadLine.Read("❯ ");
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(input)) continue;
|
|
||||||
|
|
||||||
// Try to execute slash command
|
|
||||||
if (await commandDispatcher.TryExecuteAsync(input, default)) continue;
|
|
||||||
|
|
||||||
history.Add(new ChatMessage(ChatRole.User, input));
|
|
||||||
|
|
||||||
// Track where this turn starts so we can compact previous turns' tool results
|
|
||||||
int turnStartIndex = history.Count;
|
|
||||||
|
|
||||||
AnsiConsole.WriteLine();
|
|
||||||
|
|
||||||
// Create a fresh CancellationTokenSource for this response
|
|
||||||
responseCts = new CancellationTokenSource();
|
|
||||||
string fullResponse = "";
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var options = new ChatOptions { Tools = tools };
|
|
||||||
|
|
||||||
// Get the async enumerator so we can split into spinner + streaming phases
|
|
||||||
await using var stream = agent
|
|
||||||
.GetStreamingResponseAsync(history, options, responseCts.Token)
|
|
||||||
.GetAsyncEnumerator(responseCts.Token);
|
|
||||||
|
|
||||||
string? firstChunk = null;
|
|
||||||
int respIn = 0, respOut = 0;
|
|
||||||
|
|
||||||
// Helper: extract usage from a streaming update's raw OpenAI representation
|
|
||||||
void CaptureUsage(ChatResponseUpdate update)
|
|
||||||
{
|
|
||||||
if (update.RawRepresentation is OpenAI.Chat.StreamingChatCompletionUpdate raw
|
|
||||||
&& raw.Usage != null)
|
|
||||||
{
|
|
||||||
respIn += raw.Usage.InputTokenCount;
|
|
||||||
respOut += raw.Usage.OutputTokenCount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 1: Show BouncingBar spinner while agent thinks & invokes tools
|
|
||||||
using var spinnerCts = CancellationTokenSource.CreateLinkedTokenSource(responseCts.Token);
|
|
||||||
bool showSpinner = true;
|
|
||||||
|
|
||||||
CommandTool.PauseSpinner = () =>
|
|
||||||
{
|
|
||||||
lock (consoleLock)
|
|
||||||
{
|
|
||||||
showSpinner = false;
|
|
||||||
Console.Write("\r" + new string(' ', 40) + "\r");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
CommandTool.ResumeSpinner = () =>
|
|
||||||
{
|
|
||||||
lock (consoleLock)
|
|
||||||
{
|
|
||||||
showSpinner = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var spinnerTask = Task.Run(async () =>
|
|
||||||
{
|
|
||||||
var frames = Spinner.Known.BouncingBar.Frames;
|
|
||||||
var interval = Spinner.Known.BouncingBar.Interval;
|
|
||||||
int i = 0;
|
|
||||||
|
|
||||||
// Hide cursor
|
|
||||||
Console.Write("\x1b[?25l");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
while (!spinnerCts.Token.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
lock (consoleLock)
|
|
||||||
{
|
|
||||||
if (showSpinner && !spinnerCts.Token.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
var frame = frames[i % frames.Count];
|
|
||||||
Console.Write($"\r\x1b[38;5;69m{frame}\x1b[0m Thinking...");
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try { await Task.Delay(interval, spinnerCts.Token); } catch { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
// Clear the spinner line and show cursor
|
|
||||||
lock (consoleLock)
|
|
||||||
{
|
|
||||||
if (showSpinner)
|
|
||||||
Console.Write("\r" + new string(' ', 40) + "\r");
|
|
||||||
Console.Write("\x1b[?25h");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
while (await stream.MoveNextAsync())
|
|
||||||
{
|
|
||||||
responseCts.Token.ThrowIfCancellationRequested();
|
|
||||||
CaptureUsage(stream.Current);
|
|
||||||
if (!string.IsNullOrEmpty(stream.Current.Text))
|
|
||||||
{
|
|
||||||
firstChunk = stream.Current.Text;
|
|
||||||
fullResponse = firstChunk;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
spinnerCts.Cancel();
|
|
||||||
await Task.WhenAny(spinnerTask);
|
|
||||||
CommandTool.PauseSpinner = null;
|
|
||||||
CommandTool.ResumeSpinner = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: Stream text tokens directly to the console
|
|
||||||
if (firstChunk != null)
|
|
||||||
{
|
|
||||||
AnsiConsole.Markup(Markup.Escape(firstChunk));
|
|
||||||
}
|
|
||||||
|
|
||||||
while (await stream.MoveNextAsync())
|
|
||||||
{
|
|
||||||
responseCts.Token.ThrowIfCancellationRequested();
|
|
||||||
CaptureUsage(stream.Current);
|
|
||||||
var text = stream.Current.Text;
|
|
||||||
if (!string.IsNullOrEmpty(text))
|
|
||||||
{
|
|
||||||
AnsiConsole.Markup(Markup.Escape(text));
|
|
||||||
}
|
|
||||||
fullResponse += text;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record usage and display cost
|
|
||||||
if (respIn > 0 || respOut > 0)
|
|
||||||
{
|
|
||||||
tokenTracker.AddUsage(respIn, respOut);
|
|
||||||
var cost = tokenTracker.CalculateCost(respIn, respOut);
|
|
||||||
var ctxPct = tokenTracker.ContextUsagePercent;
|
|
||||||
AnsiConsole.WriteLine();
|
|
||||||
AnsiConsole.MarkupLine(
|
|
||||||
$"[dim grey] {TokenTracker.FormatTokens(respIn)}↑ {TokenTracker.FormatTokens(respOut)}↓" +
|
|
||||||
$" {TokenTracker.FormatCost(cost)}" +
|
|
||||||
(ctxPct >= 0 ? $" ctx:{ctxPct:F0}%" : "") +
|
|
||||||
$" │ session: {TokenTracker.FormatCost(tokenTracker.SessionCost)}[/]");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
AnsiConsole.WriteLine();
|
|
||||||
}
|
|
||||||
|
|
||||||
AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("grey dim")));
|
|
||||||
AnsiConsole.WriteLine();
|
|
||||||
|
|
||||||
history.Add(new ChatMessage(ChatRole.Assistant, fullResponse));
|
|
||||||
|
|
||||||
// ── Compact stale ReadFile/Grep results from previous turns ─
|
|
||||||
int compactedResults = ContextCompactor.CompactStaleToolResults(history, turnStartIndex);
|
|
||||||
if (compactedResults > 0)
|
|
||||||
{
|
|
||||||
AnsiConsole.MarkupLine(
|
|
||||||
$"[dim grey] ♻ Compacted {compactedResults} stale tool result(s) from previous turns[/]");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Auto-compact context if approaching the limit ───────────
|
|
||||||
if (tokenTracker.ShouldCompact())
|
|
||||||
{
|
|
||||||
var pct = tokenTracker.ContextUsagePercent;
|
|
||||||
AnsiConsole.MarkupLine(
|
|
||||||
$"[yellow]⚠ Context at {pct:F0}% — compacting conversation history...[/]");
|
|
||||||
|
|
||||||
bool compacted = await AnsiConsole.Status()
|
|
||||||
.Spinner(Spinner.Known.BouncingBar)
|
|
||||||
.SpinnerStyle(Style.Parse("yellow"))
|
|
||||||
.StartAsync("Compacting context...", async ctx =>
|
|
||||||
await compactor.TryCompactAsync(history, default));
|
|
||||||
|
|
||||||
if (compacted)
|
|
||||||
{
|
|
||||||
AnsiConsole.MarkupLine(
|
|
||||||
$"[green]✓ Context compacted ({history.Count} messages remaining)[/]");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
AnsiConsole.MarkupLine(
|
|
||||||
"[dim grey] (compaction skipped — not enough history to compress)[/]");
|
|
||||||
}
|
|
||||||
AnsiConsole.WriteLine();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
// Keep partial response in history so the agent has context
|
|
||||||
AnsiConsole.WriteLine();
|
|
||||||
AnsiConsole.MarkupLine("[dim grey] ✗ Cancelled[/]");
|
|
||||||
AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("grey dim")));
|
|
||||||
AnsiConsole.WriteLine();
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(fullResponse))
|
|
||||||
{
|
|
||||||
history.Add(new ChatMessage(ChatRole.Assistant, fullResponse));
|
|
||||||
}
|
|
||||||
history.Add(new ChatMessage(ChatRole.User,
|
|
||||||
"[Response cancelled by user. Acknowledge briefly and wait for the next instruction. Do not repeat what was already said.]"));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
AnsiConsole.WriteLine();
|
|
||||||
AnsiConsole.Write(
|
|
||||||
new Panel($"[red]{Markup.Escape(ex.Message)}[/]")
|
|
||||||
.Header("[bold red] Error [/]")
|
|
||||||
.BorderColor(Color.Red)
|
|
||||||
.RoundedBorder()
|
|
||||||
.Padding(1, 0));
|
|
||||||
AnsiConsole.WriteLine();
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
responseCts?.Dispose();
|
|
||||||
responseCts = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
251
ReplLoop.cs
Normal file
251
ReplLoop.cs
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
using OpenAI;
|
||||||
|
using Spectre.Console;
|
||||||
|
using AnchorCli.OpenRouter;
|
||||||
|
using AnchorCli.Commands;
|
||||||
|
using AnchorCli.Tools;
|
||||||
|
|
||||||
|
namespace AnchorCli;
|
||||||
|
|
||||||
|
internal sealed class ReplLoop
|
||||||
|
{
|
||||||
|
private readonly ChatSession _session;
|
||||||
|
private readonly TokenTracker _tokenTracker;
|
||||||
|
private readonly CommandDispatcher _commandDispatcher;
|
||||||
|
|
||||||
|
public ReplLoop(ChatSession session, TokenTracker tokenTracker, CommandDispatcher commandDispatcher)
|
||||||
|
{
|
||||||
|
_session = session;
|
||||||
|
_tokenTracker = tokenTracker;
|
||||||
|
_commandDispatcher = commandDispatcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RunAsync()
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine("[dim]Type your message, or use [bold]/help[/] to see commands.[/]");
|
||||||
|
AnsiConsole.MarkupLine("[dim]Press [bold]Ctrl+C[/] to cancel the current response.[/]");
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
|
||||||
|
CancellationTokenSource? responseCts = null;
|
||||||
|
|
||||||
|
Console.CancelKeyPress += (_, e) =>
|
||||||
|
{
|
||||||
|
e.Cancel = true; // Prevent process termination
|
||||||
|
responseCts?.Cancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
string input = ReadLine.Read("❯ ");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(input)) continue;
|
||||||
|
|
||||||
|
if (await _commandDispatcher.TryExecuteAsync(input, default)) continue;
|
||||||
|
|
||||||
|
_session.History.Add(new ChatMessage(ChatRole.User, input));
|
||||||
|
int turnStartIndex = _session.History.Count;
|
||||||
|
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
|
||||||
|
responseCts = new CancellationTokenSource();
|
||||||
|
string fullResponse = "";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var stream = _session
|
||||||
|
.GetStreamingResponseAsync(responseCts.Token)
|
||||||
|
.GetAsyncEnumerator(responseCts.Token);
|
||||||
|
|
||||||
|
string? firstChunk = null;
|
||||||
|
int respIn = 0, respOut = 0;
|
||||||
|
|
||||||
|
void CaptureUsage(ChatResponseUpdate update)
|
||||||
|
{
|
||||||
|
if (update.RawRepresentation is OpenAI.Chat.StreamingChatCompletionUpdate raw
|
||||||
|
&& raw.Usage != null)
|
||||||
|
{
|
||||||
|
respIn += raw.Usage.InputTokenCount;
|
||||||
|
respOut += raw.Usage.OutputTokenCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object consoleLock = new();
|
||||||
|
using var spinnerCts = CancellationTokenSource.CreateLinkedTokenSource(responseCts.Token);
|
||||||
|
bool showSpinner = true;
|
||||||
|
|
||||||
|
CommandTool.PauseSpinner = () =>
|
||||||
|
{
|
||||||
|
lock (consoleLock)
|
||||||
|
{
|
||||||
|
showSpinner = false;
|
||||||
|
Console.Write("\r" + new string(' ', 40) + "\r");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
CommandTool.ResumeSpinner = () =>
|
||||||
|
{
|
||||||
|
lock (consoleLock)
|
||||||
|
{
|
||||||
|
showSpinner = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var spinnerTask = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
var frames = Spinner.Known.BouncingBar.Frames;
|
||||||
|
var interval = Spinner.Known.BouncingBar.Interval;
|
||||||
|
int i = 0;
|
||||||
|
|
||||||
|
Console.Write("\x1b[?25l");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!spinnerCts.Token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
lock (consoleLock)
|
||||||
|
{
|
||||||
|
if (showSpinner && !spinnerCts.Token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
var frame = frames[i % frames.Count];
|
||||||
|
Console.Write($"\r\x1b[38;5;69m{frame}\x1b[0m Thinking...");
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try { await Task.Delay(interval, spinnerCts.Token); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
lock (consoleLock)
|
||||||
|
{
|
||||||
|
if (showSpinner)
|
||||||
|
Console.Write("\r" + new string(' ', 40) + "\r");
|
||||||
|
Console.Write("\x1b[?25h");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (await stream.MoveNextAsync())
|
||||||
|
{
|
||||||
|
responseCts.Token.ThrowIfCancellationRequested();
|
||||||
|
CaptureUsage(stream.Current);
|
||||||
|
if (!string.IsNullOrEmpty(stream.Current.Text))
|
||||||
|
{
|
||||||
|
firstChunk = stream.Current.Text;
|
||||||
|
fullResponse = firstChunk;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
spinnerCts.Cancel();
|
||||||
|
await Task.WhenAny(spinnerTask);
|
||||||
|
CommandTool.PauseSpinner = null;
|
||||||
|
CommandTool.ResumeSpinner = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstChunk != null)
|
||||||
|
{
|
||||||
|
AnsiConsole.Markup(Markup.Escape(firstChunk));
|
||||||
|
}
|
||||||
|
|
||||||
|
while (await stream.MoveNextAsync())
|
||||||
|
{
|
||||||
|
responseCts.Token.ThrowIfCancellationRequested();
|
||||||
|
CaptureUsage(stream.Current);
|
||||||
|
var text = stream.Current.Text;
|
||||||
|
if (!string.IsNullOrEmpty(text))
|
||||||
|
{
|
||||||
|
AnsiConsole.Markup(Markup.Escape(text));
|
||||||
|
}
|
||||||
|
fullResponse += text;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (respIn > 0 || respOut > 0)
|
||||||
|
{
|
||||||
|
_tokenTracker.AddUsage(respIn, respOut);
|
||||||
|
var cost = _tokenTracker.CalculateCost(respIn, respOut);
|
||||||
|
var ctxPct = _tokenTracker.ContextUsagePercent;
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
AnsiConsole.MarkupLine(
|
||||||
|
$"[dim grey] {TokenTracker.FormatTokens(respIn)}↑ {TokenTracker.FormatTokens(respOut)}↓" +
|
||||||
|
$" {TokenTracker.FormatCost(cost)}" +
|
||||||
|
(ctxPct >= 0 ? $" ctx:{ctxPct:F0}%" : "") +
|
||||||
|
$" │ session: {TokenTracker.FormatCost(_tokenTracker.SessionCost)}[/]");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("grey dim")));
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
|
||||||
|
_session.History.Add(new ChatMessage(ChatRole.Assistant, fullResponse));
|
||||||
|
|
||||||
|
int compactedResults = ContextCompactor.CompactStaleToolResults(_session.History, turnStartIndex);
|
||||||
|
if (compactedResults > 0)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine(
|
||||||
|
$"[dim grey] ♻ Compacted {compactedResults} stale tool result(s) from previous turns[/]");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_tokenTracker.ShouldCompact())
|
||||||
|
{
|
||||||
|
var pct = _tokenTracker.ContextUsagePercent;
|
||||||
|
AnsiConsole.MarkupLine(
|
||||||
|
$"[yellow]⚠ Context at {pct:F0}% — compacting conversation history...[/]");
|
||||||
|
|
||||||
|
bool compacted = await AnsiConsole.Status()
|
||||||
|
.Spinner(Spinner.Known.BouncingBar)
|
||||||
|
.SpinnerStyle(Style.Parse("yellow"))
|
||||||
|
.StartAsync("Compacting context...", async ctx =>
|
||||||
|
await _session.Compactor.TryCompactAsync(_session.History, default));
|
||||||
|
|
||||||
|
if (compacted)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine(
|
||||||
|
$"[green]✓ Context compacted ({_session.History.Count} messages remaining)[/]");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine(
|
||||||
|
"[dim grey] (compaction skipped — not enough history to compress)[/]");
|
||||||
|
}
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
AnsiConsole.MarkupLine("[dim grey] ✗ Cancelled[/]");
|
||||||
|
AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("grey dim")));
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(fullResponse))
|
||||||
|
{
|
||||||
|
_session.History.Add(new ChatMessage(ChatRole.Assistant, fullResponse));
|
||||||
|
}
|
||||||
|
_session.History.Add(new ChatMessage(ChatRole.User,
|
||||||
|
"[Response cancelled by user. Acknowledge briefly and wait for the next instruction. Do not repeat what was already said.]"));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
AnsiConsole.Write(
|
||||||
|
new Panel($"[red]{Markup.Escape(ex.Message)}[/]")
|
||||||
|
.Header("[bold red] Error [/]")
|
||||||
|
.BorderColor(Color.Red)
|
||||||
|
.RoundedBorder()
|
||||||
|
.Padding(1, 0));
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
responseCts?.Dispose();
|
||||||
|
responseCts = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
ToolRegistry.cs
Normal file
34
ToolRegistry.cs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
using AnchorCli.Tools;
|
||||||
|
|
||||||
|
namespace AnchorCli;
|
||||||
|
|
||||||
|
internal static class ToolRegistry
|
||||||
|
{
|
||||||
|
public static List<AITool> GetTools()
|
||||||
|
{
|
||||||
|
var jsonOptions = AppJsonContext.Default.Options;
|
||||||
|
|
||||||
|
return new List<AITool>
|
||||||
|
{
|
||||||
|
AIFunctionFactory.Create(FileTools.ReadFile, serializerOptions: jsonOptions),
|
||||||
|
AIFunctionFactory.Create(FileTools.GrepFile, serializerOptions: jsonOptions),
|
||||||
|
AIFunctionFactory.Create(FileTools.ListDir, serializerOptions: jsonOptions),
|
||||||
|
AIFunctionFactory.Create(EditTools.ReplaceLines, serializerOptions: jsonOptions),
|
||||||
|
AIFunctionFactory.Create(EditTools.InsertAfter, serializerOptions: jsonOptions),
|
||||||
|
AIFunctionFactory.Create(EditTools.DeleteRange, serializerOptions: jsonOptions),
|
||||||
|
AIFunctionFactory.Create(EditTools.CreateFile, serializerOptions: jsonOptions),
|
||||||
|
AIFunctionFactory.Create(EditTools.DeleteFile, serializerOptions: jsonOptions),
|
||||||
|
AIFunctionFactory.Create(EditTools.RenameFile, serializerOptions: jsonOptions),
|
||||||
|
AIFunctionFactory.Create(EditTools.CopyFile, serializerOptions: jsonOptions),
|
||||||
|
AIFunctionFactory.Create(DirTools.CreateDir, serializerOptions: jsonOptions),
|
||||||
|
AIFunctionFactory.Create(DirTools.RenameDir, serializerOptions: jsonOptions),
|
||||||
|
AIFunctionFactory.Create(DirTools.DeleteDir, serializerOptions: jsonOptions),
|
||||||
|
AIFunctionFactory.Create(FileTools.FindFiles, serializerOptions: jsonOptions),
|
||||||
|
AIFunctionFactory.Create(FileTools.GrepRecursive, serializerOptions: jsonOptions),
|
||||||
|
AIFunctionFactory.Create(FileTools.GetFileInfo, serializerOptions: jsonOptions),
|
||||||
|
AIFunctionFactory.Create(EditTools.AppendToFile, serializerOptions: jsonOptions),
|
||||||
|
AIFunctionFactory.Create(CommandTool.ExecuteCommand, serializerOptions: jsonOptions),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,15 +64,7 @@ internal static partial class EditTools
|
|||||||
Log($"REPLACE_LINES: {path}");
|
Log($"REPLACE_LINES: {path}");
|
||||||
Log($" Range: {startAnchor} -> {endAnchor}");
|
Log($" Range: {startAnchor} -> {endAnchor}");
|
||||||
Log($" Replacing {endAnchor.Split(':')[0]}-{startAnchor.Split(':')[0]} lines with {newLines.Length} new lines");
|
Log($" Replacing {endAnchor.Split(':')[0]}-{startAnchor.Split(':')[0]} lines with {newLines.Length} new lines");
|
||||||
/*Log($" New content (first 5 lines):");
|
|
||||||
foreach (var line in newLines.Take(5))
|
|
||||||
{
|
|
||||||
Log($" + {line}");
|
|
||||||
}
|
|
||||||
if (newLines.Length > 5)
|
|
||||||
{
|
|
||||||
Log($" ... and {newLines.Length - 5} more lines");
|
|
||||||
}*/
|
|
||||||
if (!File.Exists(path))
|
if (!File.Exists(path))
|
||||||
return $"ERROR: File not found: {path}";
|
return $"ERROR: File not found: {path}";
|
||||||
|
|
||||||
@@ -109,15 +101,7 @@ internal static partial class EditTools
|
|||||||
Log($"INSERT_AFTER: {path}");
|
Log($"INSERT_AFTER: {path}");
|
||||||
Log($" Anchor: {anchor}");
|
Log($" Anchor: {anchor}");
|
||||||
Log($" Inserting {newLines.Length} lines after line {anchor.Split(':')[0]}");
|
Log($" Inserting {newLines.Length} lines after line {anchor.Split(':')[0]}");
|
||||||
/*Log($" New content (first 5 lines):");
|
|
||||||
foreach (var line in newLines.Take(5))
|
|
||||||
{
|
|
||||||
Log($" + {line}");
|
|
||||||
}
|
|
||||||
if (newLines.Length > 5)
|
|
||||||
{
|
|
||||||
Log($" ... and {newLines.Length - 5} more lines");
|
|
||||||
}*/
|
|
||||||
|
|
||||||
if (!File.Exists(path))
|
if (!File.Exists(path))
|
||||||
return $"ERROR: File not found: {path}";
|
return $"ERROR: File not found: {path}";
|
||||||
@@ -129,8 +113,7 @@ internal static partial class EditTools
|
|||||||
if (!HashlineValidator.TryResolve(anchor, lines, out int idx, out string error))
|
if (!HashlineValidator.TryResolve(anchor, lines, out int idx, out string error))
|
||||||
return $"ERROR: {error}";
|
return $"ERROR: {error}";
|
||||||
|
|
||||||
// Log the anchor line we're inserting after
|
|
||||||
//Log($" Inserting after line {idx + 1}: {lines[idx]}");
|
|
||||||
|
|
||||||
var result = new List<string>(lines.Length + newLines.Length);
|
var result = new List<string>(lines.Length + newLines.Length);
|
||||||
result.AddRange(lines[..(idx + 1)]);
|
result.AddRange(lines[..(idx + 1)]);
|
||||||
|
|||||||
Reference in New Issue
Block a user