Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 94 additions & 6 deletions CodeUI.Core/Services/CliExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using CliWrap;
using CliWrap.EventStream;
using CodeUI.Core.Models;
using System.IO.Pipes;

namespace CodeUI.Core.Services;

Expand All @@ -17,6 +18,9 @@ public partial class CliExecutor : ICliExecutor
private Command? _currentCommand;
private CancellationTokenSource? _currentCancellationSource;
private volatile ProcessInfo? _currentProcess;
private AnonymousPipeServerStream? _stdinPipeServer;
private AnonymousPipeClientStream? _stdinPipeClient;
private StreamWriter? _stdinWriter;
private bool _disposed;

/// <summary>
Expand All @@ -33,6 +37,19 @@ public partial class CliExecutor : ICliExecutor

/// <inheritdoc />
public async Task<ProcessInfo> StartProcessAsync(string command, string arguments, string? workingDirectory = null, CancellationToken cancellationToken = default)
{
return await StartProcessInternalAsync(command, arguments, workingDirectory, enableInteractiveInput: false, cancellationToken);
}

/// <summary>
/// Starts an interactive CLI process that can receive stdin input.
/// </summary>
public async Task<ProcessInfo> StartInteractiveProcessAsync(string command, string arguments, string? workingDirectory = null, CancellationToken cancellationToken = default)
{
return await StartProcessInternalAsync(command, arguments, workingDirectory, enableInteractiveInput: true, cancellationToken);
}

private async Task<ProcessInfo> StartProcessInternalAsync(string command, string arguments, string? workingDirectory, bool enableInteractiveInput, CancellationToken cancellationToken = default)
{
if (_disposed)
throw new ObjectDisposedException(nameof(CliExecutor));
Expand All @@ -53,11 +70,24 @@ public async Task<ProcessInfo> StartProcessAsync(string command, string argument

workingDirectory ??= Directory.GetCurrentDirectory();

_currentCommand = Cli.Wrap(command)
var commandBuilder = Cli.Wrap(command)
.WithArguments(arguments)
.WithWorkingDirectory(workingDirectory)
.WithValidation(CommandResultValidation.None);

// Only set up interactive input if requested
if (enableInteractiveInput)
{
// Create anonymous pipe for stdin
_stdinPipeServer = new AnonymousPipeServerStream(PipeDirection.Out, HandleInheritability.Inheritable);
_stdinPipeClient = new AnonymousPipeClientStream(PipeDirection.In, _stdinPipeServer.GetClientHandleAsString());
_stdinWriter = new StreamWriter(_stdinPipeServer) { AutoFlush = true };

commandBuilder = commandBuilder.WithStandardInputPipe(PipeSource.FromStream(_stdinPipeClient));
}

_currentCommand = commandBuilder;

var processInfo = new ProcessInfo
{
ProcessId = 0, // Will be updated when process starts
Expand Down Expand Up @@ -192,11 +222,26 @@ public async Task SendInputAsync(string input, CancellationToken cancellationTok
throw new InvalidOperationException("No process is currently running to send input to.");
}

// Note: CliWrap doesn't support sending input to a running process directly.
// This would require a different approach using Process class directly or
// preparing input before starting the command.
await Task.CompletedTask;
throw new NotSupportedException("Sending input to a running process is not currently supported. Consider using ExecuteAsync with pre-prepared input.");
if (_stdinWriter == null)
{
throw new InvalidOperationException("Stdin stream is not available for the current process.");
}

try
{
await _stdinWriter.WriteAsync(input);
await _stdinWriter.FlushAsync();
}
catch (Exception ex)
{
var errorLine = new OutputLine
{
Text = $"Error sending input to process: {ex.Message}\r\n",
IsStdOut = false
};
_outputSubject.OnNext(errorLine);
throw;
}
}

/// <inheritdoc />
Expand Down Expand Up @@ -289,6 +334,46 @@ private async Task StopProcessInternalAsync(bool graceful, CancellationToken can
}
}

// Close stdin to signal process termination
if (_stdinWriter != null)
{
try
{
await _stdinWriter.DisposeAsync();
_stdinWriter = null;
}
catch
{
// Ignore errors during cleanup
}
}

if (_stdinPipeClient != null)
{
try
{
_stdinPipeClient.Dispose();
_stdinPipeClient = null;
}
catch
{
// Ignore errors during cleanup
}
}

if (_stdinPipeServer != null)
{
try
{
_stdinPipeServer.Dispose();
_stdinPipeServer = null;
}
catch
{
// Ignore errors during cleanup
}
}

if (_currentProcess?.State == ProcessState.Running)
{
_currentProcess = _currentProcess with
Expand Down Expand Up @@ -318,6 +403,9 @@ public void Dispose()
// Ignore exceptions during disposal
}

_stdinWriter?.Dispose();
_stdinPipeClient?.Dispose();
_stdinPipeServer?.Dispose();
_currentCancellationSource?.Dispose();
_outputSubject.Dispose();
_executionSemaphore.Dispose();
Expand Down
10 changes: 10 additions & 0 deletions CodeUI.Core/Services/ICliExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ public interface ICliExecutor : IDisposable
/// <returns>A task that completes when the process starts, containing the process information.</returns>
Task<ProcessInfo> StartProcessAsync(string command, string arguments, string? workingDirectory = null, CancellationToken cancellationToken = default);

/// <summary>
/// Starts an interactive CLI process that can receive stdin input.
/// </summary>
/// <param name="command">The command to execute (e.g., "claude-code", "bash").</param>
/// <param name="arguments">The arguments to pass to the command.</param>
/// <param name="workingDirectory">The working directory for the process. Defaults to current directory.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A task that completes when the process starts, containing the process information.</returns>
Task<ProcessInfo> StartInteractiveProcessAsync(string command, string arguments, string? workingDirectory = null, CancellationToken cancellationToken = default);

/// <summary>
/// Starts a CLI process and waits for it to complete.
/// </summary>
Expand Down
128 changes: 128 additions & 0 deletions CodeUI.Tests/Services/CliExecutorInteractiveTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
using CodeUI.Core.Models;
using CodeUI.Core.Services;
using System.Reactive.Linq;
using Xunit;

namespace CodeUI.Tests.Services;

/// <summary>
/// Tests for interactive CLI process functionality.
/// </summary>
public class CliExecutorInteractiveTests : IDisposable
{
private readonly CliExecutor _executor;

public CliExecutorInteractiveTests()
{
_executor = new CliExecutor();
}

public void Dispose()
{
_executor.Dispose();
}

[Fact]
public async Task SendInputAsync_ShouldThrowWhenNoProcessRunning()
{
// Arrange & Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(
() => _executor.SendInputAsync("test input"));
}

[Fact]
public async Task SendInputAsync_ShouldThrowWhenNonInteractiveProcessRunning()
{
// Arrange
var processInfo = await _executor.StartProcessAsync("echo", "test");

// Wait a moment for process to start
await Task.Delay(100);

// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(
() => _executor.SendInputAsync("test input"));
}

[Fact]
public async Task StartInteractiveProcessAsync_ShouldAllowSendInput()
{
// Arrange
var outputLines = new List<OutputLine>();
var subscription = _executor.Output.Subscribe(outputLines.Add);

try
{
// Start an interactive cat process (reads stdin and echoes to stdout)
var processInfo = await _executor.StartInteractiveProcessAsync("cat", "");

// Assert process started
Assert.Equal(ProcessState.Running, processInfo.State);
Assert.Equal("cat", processInfo.Command);

// Wait a longer time for process to initialize
await Task.Delay(500);

// Verify process is still running
Assert.NotNull(_executor.CurrentProcess);
Assert.Equal(ProcessState.Running, _executor.CurrentProcess.State);

// Act - Send input to the process
await _executor.SendInputAsync("Hello World\n");

// Wait for output
await Task.Delay(200);

// Assert - Should not throw exception and process should still be running
Assert.NotNull(_executor.CurrentProcess);
Assert.Equal(ProcessState.Running, _executor.CurrentProcess.State);

// Stop the process
await _executor.StopProcessAsync(graceful: false);
}
finally
{
subscription.Dispose();
}
}

[Fact]
public async Task InteractiveProcess_ShouldReceiveMultipleInputs()
{
// Arrange
var outputLines = new List<OutputLine>();
var subscription = _executor.Output.Subscribe(outputLines.Add);

try
{
// Start interactive cat process
var processInfo = await _executor.StartInteractiveProcessAsync("cat", "");

// Wait longer for process to start
await Task.Delay(500);

// Verify process is running before sending input
Assert.NotNull(_executor.CurrentProcess);
Assert.Equal(ProcessState.Running, _executor.CurrentProcess.State);

// Act - Send multiple inputs
await _executor.SendInputAsync("First line\n");
await Task.Delay(100);
await _executor.SendInputAsync("Second line\n");
await Task.Delay(100);

// Wait for all outputs
await Task.Delay(300);

// Assert - Should have received outputs
Assert.True(outputLines.Count > 0, "Should have received some output from cat command");

// Stop the process
await _executor.StopProcessAsync(graceful: false);
}
finally
{
subscription.Dispose();
}
}
}
Loading