Skip to content

Commit 2e2fe08

Browse files
committed
Allow AIShell to run command in the connected PowerShell session and collect all output and error.
1. Add 'Invoke-AICommand' cmdlet (alias: 'airun') to 'AIShell' module. Commands sent from the side-car AIShell will be executed through this command in the form of `airun { <command> }`. This command is designed to collect all output and error messages as they are displayed in the terminal, while preserving the streaming behavior as expected. 2. Add 'RunCommand' and 'PostResult' messages to the protocol. 3. Update the 'Channel' class in 'AIShell' module to support the 'OnRunCommand' action. We already support posting command to the PowerShell's prompt, but it turns out not easy to make the command be accepted. On Windows, we have to call 'AcceptLine' within an 'OnIdle' event handler and it also requires changes to PsReadLine. - 'AcceptLine' only set a flag in PSReadLine to indicate the line was accepted. The flag is checked in 'InputLoop', however, when PSReadLine is waiting for input, it's blocked in the 'ReadKey' call within 'InputLoop', so even if the flag is set, 'InputLoop' won't be able to check it until after 'ReadKey' call is returned. - I need to change PSReadLine a bit: After it finishes handling the 'OnIdle' event, it checks if the '_lineAccepted' flag is set. If it's set, it means 'AcceptLine' got called within the 'OnIdle' handler, and it throws a 'LineAcceptedException' to break out from 'ReadKey'. I catch this exception in 'InputLoop' to continue with the flag check. - However, a problem with this change is: the "readkey thread" is still blocked on 'Console.ReadKey' when the command is returned to PowerShell to execute. On Windows, this could cause minor issues if the command also calls 'Console.ReadKey' -- 2 threads calling 'Console.ReadKey' in parallel, so it's uncertian which will get the next keystroke input. On macOS and Linux, the problem is way much bigger -- any subsequent writing to the terminal may be blocked, because on non-Windows, reading cursor position will be blocked if another thread is calling 'Console.ReadKey'. - So, this approach can only work on Windows. On macOS, we depend on iTerm2, which has a Python API server and it's possible to send keystrokes to a tab using the Python API, so we could use that for macOS. But Windows Terminal doesn't support that, and thus we will have to use the above approach to accept the command on Windows. - On macOS, if the Python API approach works fine, then we could even consider using it for the 'PostCode' action. 4. Add '/code run <command>' to test out the 'RunCommand' functionality end-to-end.
1 parent cd50156 commit 2e2fe08

File tree

7 files changed

+421
-12
lines changed

7 files changed

+421
-12
lines changed

shell/AIShell.Abstraction/NamedPipe.cs

Lines changed: 136 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ public enum MessageType : int
3333
/// A message from AIShell to command-line shell to send code block.
3434
/// </summary>
3535
PostCode = 4,
36+
37+
/// <summary>
38+
/// A message from AIShell to command-line shell to run a command.
39+
/// </summary>
40+
RunCommand = 5,
41+
42+
/// <summary>
43+
/// A message from command-line shell to AIShell to post the result of a command.
44+
/// </summary>
45+
PostResult = 6,
3646
}
3747

3848
/// <summary>
@@ -201,6 +211,74 @@ public PostCodeMessage(List<string> codeBlocks)
201211
}
202212
}
203213

214+
/// <summary>
215+
/// Message for <see cref="MessageType.RunCommand"/>.
216+
/// </summary>
217+
public sealed class RunCommandMessage : PipeMessage
218+
{
219+
/// <summary>
220+
/// Gets the command to run.
221+
/// </summary>
222+
public string Command { get; }
223+
224+
/// <summary>
225+
/// Gets whether the command should be run in blocking mode.
226+
/// </summary>
227+
public bool Blocking { get; }
228+
229+
/// <summary>
230+
/// Creates an instance of <see cref="RunCommandMessage"/>.
231+
/// </summary>
232+
public RunCommandMessage(string command, bool blocking)
233+
: base(MessageType.RunCommand)
234+
{
235+
ArgumentException.ThrowIfNullOrEmpty(command);
236+
237+
Command = command;
238+
Blocking = blocking;
239+
}
240+
}
241+
242+
/// <summary>
243+
/// Message for <see cref="MessageType.PostResult"/>.
244+
/// </summary>
245+
public sealed class PostResultMessage : PipeMessage
246+
{
247+
/// <summary>
248+
/// Gets the result of the command for a blocking 'run_command' too call.
249+
/// Or, for a non-blocking call, gets the id for retrieving the result later.
250+
/// </summary>
251+
public string Output { get; }
252+
253+
/// <summary>
254+
/// Gets whether the command execution had any error.
255+
/// i.e. a native command returned a non-zero exit code, or a powershell command threw any errors.
256+
/// </summary>
257+
public bool HadError { get; }
258+
259+
/// <summary>
260+
/// Gets a value indicating whether the operation was canceled by the user.
261+
/// </summary>
262+
public bool UserCancelled { get; }
263+
264+
/// <summary>
265+
/// Gets the internal exception message that is thrown when trying to run the command.
266+
/// </summary>
267+
public string Exception { get; }
268+
269+
/// <summary>
270+
/// Creates an instance of <see cref="PostResultMessage"/>.
271+
/// </summary>
272+
public PostResultMessage(string output, bool hadError, bool userCancelled, string exception)
273+
: base(MessageType.PostResult)
274+
{
275+
Output = output;
276+
HadError = hadError;
277+
UserCancelled = userCancelled;
278+
Exception = exception;
279+
}
280+
}
281+
204282
/// <summary>
205283
/// The base type for common pipe operations.
206284
/// </summary>
@@ -301,7 +379,7 @@ protected async Task<PipeMessage> GetMessageAsync(CancellationToken cancellation
301379
return null;
302380
}
303381

304-
if (type > (int)MessageType.PostCode)
382+
if (type > (int)MessageType.PostResult)
305383
{
306384
_pipeStream.Close();
307385
throw new IOException($"Unknown message type received: {type}. Connection was dropped.");
@@ -344,9 +422,11 @@ private static PipeMessage DeserializePayload(int type, ReadOnlySpan<byte> bytes
344422
{
345423
(int)MessageType.PostQuery => JsonSerializer.Deserialize<PostQueryMessage>(bytes),
346424
(int)MessageType.AskConnection => JsonSerializer.Deserialize<AskConnectionMessage>(bytes),
347-
(int)MessageType.PostContext => JsonSerializer.Deserialize<PostContextMessage>(bytes),
348425
(int)MessageType.AskContext => JsonSerializer.Deserialize<AskContextMessage>(bytes),
426+
(int)MessageType.PostContext => JsonSerializer.Deserialize<PostContextMessage>(bytes),
349427
(int)MessageType.PostCode => JsonSerializer.Deserialize<PostCodeMessage>(bytes),
428+
(int)MessageType.RunCommand => JsonSerializer.Deserialize<RunCommandMessage>(bytes),
429+
(int)MessageType.PostResult => JsonSerializer.Deserialize<PostResultMessage>(bytes),
350430
_ => throw new NotSupportedException("Unreachable code"),
351431
};
352432
}
@@ -465,6 +545,11 @@ public async Task StartProcessingAsync(int timeout, CancellationToken cancellati
465545
InvokeOnPostCode((PostCodeMessage)message);
466546
break;
467547

548+
case MessageType.RunCommand:
549+
var result = InvokeOnRunCommand((RunCommandMessage)message);
550+
SendMessage(result);
551+
break;
552+
468553
default:
469554
// Log: unexpected messages ignored.
470555
break;
@@ -537,6 +622,33 @@ private PostContextMessage InvokeOnAskContext(AskContextMessage message)
537622
return null;
538623
}
539624

625+
private PostResultMessage InvokeOnRunCommand(RunCommandMessage message)
626+
{
627+
if (OnRunCommand is null)
628+
{
629+
// Log: event handler not set.
630+
return new PostResultMessage(
631+
output: "Command execution is not supported.",
632+
hadError: true,
633+
userCancelled: false,
634+
exception: null);
635+
}
636+
637+
try
638+
{
639+
return OnRunCommand(message);
640+
}
641+
catch (Exception e)
642+
{
643+
// Log: exception when invoking 'OnRunCommand'
644+
return new PostResultMessage(
645+
output: "Failed to execute the command due to an internal error.",
646+
hadError: true,
647+
userCancelled: false,
648+
exception: e.Message);
649+
}
650+
}
651+
540652
/// <summary>
541653
/// Event for handling the <see cref="MessageType.PostCode"/> message.
542654
/// </summary>
@@ -551,6 +663,11 @@ private PostContextMessage InvokeOnAskContext(AskContextMessage message)
551663
/// Event for handling the <see cref="MessageType.AskContext"/> message.
552664
/// </summary>
553665
public event Func<AskContextMessage, PostContextMessage> OnAskContext;
666+
667+
/// <summary>
668+
/// Event for handling the <see cref="MessageType.RunCommand"/> message.
669+
/// </summary>
670+
public event Func<RunCommandMessage, PostResultMessage> OnRunCommand;
554671
}
555672

556673
/// <summary>
@@ -771,4 +888,21 @@ public async Task<PostContextMessage> AskContext(AskContextMessage message, Canc
771888

772889
return postContext;
773890
}
891+
892+
public async Task<PostResultMessage> RunCommand(RunCommandMessage message, CancellationToken cancellationToken)
893+
{
894+
// Send the request message to the shell.
895+
SendMessage(message);
896+
897+
// Receiving response from the shell.
898+
var response = await GetMessageAsync(cancellationToken);
899+
if (response is not PostResultMessage postResult)
900+
{
901+
// Log: unexpected message. drop connection.
902+
_client.Close();
903+
throw new IOException($"Expecting '{MessageType.PostResult}' response, but received '{message.Type}' message.");
904+
}
905+
906+
return postResult;
907+
}
774908
}

shell/AIShell.Integration/AIShell.psd1

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010
PowerShellVersion = '7.4.6'
1111
PowerShellHostName = 'ConsoleHost'
1212
FunctionsToExport = @()
13-
CmdletsToExport = @('Start-AIShell','Invoke-AIShell','Resolve-Error')
13+
CmdletsToExport = @('Start-AIShell','Invoke-AIShell', 'Invoke-AICommand', 'Resolve-Error')
1414
VariablesToExport = '*'
15-
AliasesToExport = @('aish', 'askai', 'fixit')
15+
AliasesToExport = @('aish', 'askai', 'fixit', 'airun')
1616
HelpInfoURI = 'https://aka.ms/aishell-help'
1717
PrivateData = @{ PSData = @{ Prerelease = 'preview5'; ProjectUri = 'https://github.com/PowerShell/AIShell' } }
1818
}

0 commit comments

Comments
 (0)