1
0

feat: Add CommandTool for user-approved shell command execution, integrating it with a custom spinner that can be paused during interaction.

This commit is contained in:
2026-03-04 11:17:25 +01:00
parent 2b122a28c9
commit 2e94985975
2 changed files with 88 additions and 27 deletions

View File

@@ -239,23 +239,70 @@ while (true)
} }
// Phase 1: Show BouncingBar spinner while agent thinks & invokes tools // Phase 1: Show BouncingBar spinner while agent thinks & invokes tools
await AnsiConsole.Status() using var spinnerCts = CancellationTokenSource.CreateLinkedTokenSource(responseCts.Token);
.Spinner(Spinner.Known.BouncingBar) bool showSpinner = true;
.SpinnerStyle(Style.Parse("cornflowerblue"))
.StartAsync("Thinking...", async ctx => CommandTool.PauseSpinner = () =>
{
showSpinner = false;
Console.Write("\r" + new string(' ', 40) + "\r");
};
CommandTool.ResumeSpinner = () =>
{
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 (await stream.MoveNextAsync()) while (!spinnerCts.Token.IsCancellationRequested)
{ {
responseCts.Token.ThrowIfCancellationRequested(); if (showSpinner)
CaptureUsage(stream.Current);
if (!string.IsNullOrEmpty(stream.Current.Text))
{ {
firstChunk = stream.Current.Text; var frame = frames[i % frames.Count];
fullResponse = firstChunk; Console.Write($"\r\x1b[38;5;69m{frame}\x1b[0m Thinking...");
return; // Break out → stops the spinner i++;
} }
try { await Task.Delay(interval, spinnerCts.Token); } catch { }
} }
}); }
finally
{
// Clear the spinner line and show cursor
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 // Phase 2: Stream text tokens directly to the console
if (firstChunk != null) if (firstChunk != null)

View File

@@ -10,6 +10,8 @@ namespace AnchorCli.Tools;
internal static class CommandTool internal static class CommandTool
{ {
public static Action<string> Log { get; set; } = Console.WriteLine; public static Action<string> Log { get; set; } = Console.WriteLine;
public static Action? PauseSpinner { get; set; }
public static Action? ResumeSpinner { get; set; }
#if WINDOWS #if WINDOWS
[Description("Execute a PowerShell command after user approval. Prompts with [Y/n] before running. Note: For file editing and operations, use the built-in file tools (ReadFile, ReplaceLines, InsertAfter, DeleteRange, CreateFile, etc.) instead of shell commands.")] [Description("Execute a PowerShell command after user approval. Prompts with [Y/n] before running. Note: For file editing and operations, use the built-in file tools (ReadFile, ReplaceLines, InsertAfter, DeleteRange, CreateFile, etc.) instead of shell commands.")]
@@ -22,22 +24,34 @@ internal static class CommandTool
Log($"Command request: {command}"); Log($"Command request: {command}");
// Prompt for user approval // Prompt for user approval
AnsiConsole.WriteLine(); PauseSpinner?.Invoke();
AnsiConsole.Write( try
new Panel($"[yellow]{Markup.Escape(command)}[/]")
.Header("[bold yellow] Run command? [/]")
.BorderColor(Color.Yellow)
.RoundedBorder()
.Padding(1, 0));
// Drain any buffered keystrokes so stale input doesn't auto-answer
while (Console.KeyAvailable)
Console.ReadKey(intercept: true);
if (!AnsiConsole.Confirm("Execute?", defaultValue: true))
{ {
AnsiConsole.MarkupLine("[dim grey] ✗ Cancelled by user[/]"); AnsiConsole.WriteLine();
return "ERROR: Command execution cancelled by user"; AnsiConsole.Write(
new Panel($"[yellow]{Markup.Escape(command)}[/]")
.Header("[bold yellow] Run command? [/]")
.BorderColor(Color.Yellow)
.RoundedBorder()
.Padding(1, 0));
// Drain any buffered keystrokes so stale input doesn't auto-answer
try
{
while (Console.KeyAvailable)
Console.ReadKey(intercept: true);
}
catch { }
if (!AnsiConsole.Confirm("Execute?", defaultValue: true))
{
AnsiConsole.MarkupLine("[dim grey] ✗ Cancelled by user[/]");
return "ERROR: Command execution cancelled by user";
}
}
finally
{
ResumeSpinner?.Invoke();
} }
// Execute the command // Execute the command