Skip to content

Commit 274ff5d

Browse files
authored
Capture native command output using screen scraping API on Windows (#335)
1 parent edd7ded commit 274ff5d

File tree

1 file changed

+112
-13
lines changed

1 file changed

+112
-13
lines changed

shell/AIShell.Integration/Commands/ResolveErrorCommand.cs

Lines changed: 112 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using System.Collections;
2+
using System.Text;
23
using System.Management.Automation;
4+
using System.Management.Automation.Host;
35
using Microsoft.PowerShell.Commands;
46
using AIShell.Abstraction;
57

@@ -59,7 +61,7 @@ protected override void EndProcessing()
5961
targetObject: null);
6062
ThrowTerminatingError(error);
6163
}
62-
else if (UseClipboardForCommandOutput(lastExitCode))
64+
else
6365
{
6466
// '$? == False' but no 'ErrorRecord' can be found that is associated with the last command line,
6567
// and '$LASTEXITCODE' is non-zero, which indicates the last failed command is a native command.
@@ -68,19 +70,27 @@ Running the command line `{commandLine}` in PowerShell v{channel.PSVersion} fail
6870
Please try to explain the failure and suggest the right fix.
6971
Output of the command line can be found in the context information below.
7072
""";
71-
IncludeOutputFromClipboard = true;
72-
}
73-
else
74-
{
75-
ThrowTerminatingError(new(
76-
new NotSupportedException($"The output content is needed for suggestions on native executable failures."),
77-
errorId: "OutputNeededForNativeCommand",
78-
ErrorCategory.InvalidData,
79-
targetObject: null
80-
));
73+
74+
context = ScrapeScreenForNativeCommandOutput(commandLine);
75+
if (context is null)
76+
{
77+
if (UseClipboardForCommandOutput())
78+
{
79+
IncludeOutputFromClipboard = true;
80+
}
81+
else
82+
{
83+
ThrowTerminatingError(new(
84+
new NotSupportedException($"The output content is needed for suggestions on native executable failures."),
85+
errorId: "OutputNeededForNativeCommand",
86+
ErrorCategory.InvalidData,
87+
targetObject: null
88+
));
89+
}
90+
}
8191
}
8292

83-
if (IncludeOutputFromClipboard)
93+
if (context is null && IncludeOutputFromClipboard)
8494
{
8595
pwsh.Commands.Clear();
8696
var r = pwsh
@@ -94,7 +104,7 @@ Output of the command line can be found in the context information below.
94104
channel.PostQuery(new PostQueryMessage(query, context, Agent));
95105
}
96106

97-
private bool UseClipboardForCommandOutput(int lastExitCode)
107+
private bool UseClipboardForCommandOutput()
98108
{
99109
if (IncludeOutputFromClipboard)
100110
{
@@ -127,4 +137,93 @@ private bool TryGetLastError(HistoryInfo lastHistory, out ErrorRecord lastError)
127137

128138
return true;
129139
}
140+
141+
private string ScrapeScreenForNativeCommandOutput(string lastCommandLine)
142+
{
143+
if (!OperatingSystem.IsWindows())
144+
{
145+
return null;
146+
}
147+
148+
try
149+
{
150+
PSHostRawUserInterface rawUI = Host.UI.RawUI;
151+
Coordinates start = new(0, 0), end = rawUI.CursorPosition;
152+
153+
string currentCommandLine = MyInvocation.Line;
154+
end.X = rawUI.BufferSize.Width - 1;
155+
156+
BufferCell[,] content = rawUI.GetBufferContents(new Rectangle(start, end));
157+
StringBuilder line = new(), buffer = new();
158+
159+
bool collect = false;
160+
int rows = content.GetLength(0);
161+
int columns = content.GetLength(1);
162+
163+
for (int row = 0; row < rows; row++)
164+
{
165+
line.Clear();
166+
for (int column = 0; column < columns; column++)
167+
{
168+
line.Append(content[row, column].Character);
169+
}
170+
171+
string lineStr = line.ToString().TrimEnd();
172+
if (!collect && IsStartOfCommand(lineStr, columns, lastCommandLine))
173+
{
174+
collect = true;
175+
buffer.Append(lineStr);
176+
continue;
177+
}
178+
179+
if (collect)
180+
{
181+
// The current command line is just `Resolve-Error` or `fixit`, which should be on the same line
182+
// and thus there is no need to check for the span-to-the-next-line case.
183+
if (lineStr.EndsWith(currentCommandLine, StringComparison.Ordinal))
184+
{
185+
break;
186+
}
187+
188+
buffer.Append('\n').Append(lineStr);
189+
}
190+
}
191+
192+
return buffer.Length is 0 ? null : buffer.ToString();
193+
}
194+
catch
195+
{
196+
return null;
197+
}
198+
199+
static bool IsStartOfCommand(string lineStr, int columns, string commandLine)
200+
{
201+
if (lineStr.EndsWith(commandLine, StringComparison.Ordinal))
202+
{
203+
return true;
204+
}
205+
206+
// Handle the case where the command line is too long and spans to the next line on screen,
207+
// like az, gcloud, and aws CLI commands which are usually long with many parameters.
208+
if (columns - lineStr.Length > 3 || commandLine.Length < 20)
209+
{
210+
// The line on screen unlikely spanned to the next line in this case.
211+
return false;
212+
}
213+
214+
// We check if the prefix of the command line is the suffix of the current line on screen.
215+
ReadOnlySpan<char> lineStrSpan = lineStr.AsSpan();
216+
ReadOnlySpan<char> cmdLineSpan = commandLine.AsSpan();
217+
218+
// We assume the first 20 chars of the command line should be in the current line on screen.
219+
// This assumption is not perfect but practically good enough.
220+
int index = lineStrSpan.IndexOf(cmdLineSpan[..20], StringComparison.Ordinal);
221+
if (index >= 0 && cmdLineSpan.StartsWith(lineStrSpan[index..], StringComparison.Ordinal))
222+
{
223+
return true;
224+
}
225+
226+
return false;
227+
}
228+
}
130229
}

0 commit comments

Comments
 (0)