Skip to content
232 changes: 175 additions & 57 deletions src/Verso.PowerShell/Kernel/RunspaceManager.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
using System.Collections.ObjectModel;
using System.Management.Automation;
using System.Management.Automation.Language;
using System.Management.Automation.Runspaces;
using System.Net;
using System.Text;
using System.Collections.ObjectModel;
using System.Collections;
using System.Management.Automation;
using System.Management.Automation.Language;
using System.Management.Automation.Runspaces;
using System.Net;
using System.Reflection;
using System.Text;
using Verso.Abstractions;

namespace Verso.PowerShell.Kernel;
Expand Down Expand Up @@ -61,45 +63,51 @@ public InvokeResult Invoke(string code, CancellationToken ct)

try
{
Collection<PSObject> results = ps.Invoke();

if (results.Count > 0 && HasFormatObjects(results))
{
// Explicit Format-Table / Format-List: render through Out-String
// then parse the text table into HTML for consistent display.
using var renderer = System.Management.Automation.PowerShell.Create();
renderer.Runspace = runspace;
renderer.AddCommand("Out-String").AddParameter("Width", 200);
var rendered = renderer.Invoke(results);
// Out-String returns a single multi-line string; split into individual lines.
var lines = rendered
.SelectMany(r => (r?.ToString() ?? "").Split('\n'))
.Select(s => s.TrimEnd())
.Where(s => s.Length > 0)
.ToList();
if (lines.Count > 0)
{
var html = ParseTextTableToHtml(lines);
if (html is not null)
{
outputLines.Add(html);
outputMimeType = "text/html";
}
else
{
// Not a parseable table (e.g. Format-List) - wrap as styled <pre>
var sb = new StringBuilder();
AppendPreStyles(sb);
sb.Append("<div class=\"verso-ps-result\">");
sb.Append("<pre class=\"verso-ps-pre\">")
.Append(WebUtility.HtmlEncode(string.Join(Environment.NewLine, lines)))
.Append("</pre>");
sb.Append("</div>");
outputLines.Add(sb.ToString());
outputMimeType = "text/html";
}
}
}
Collection<PSObject> results = ps.Invoke();

if (results.Count > 0 && HasFormatObjects(results))
{
var tableHtml = TryRenderFormatTableToHtml(results);
if (tableHtml is not null)
{
outputLines.Add(tableHtml);
outputMimeType = "text/html";
}
else
{
// If metadata-based table rendering is unavailable, fall back to
// Out-String text. Try the legacy text-table parser first, then <pre>.
using var renderer = System.Management.Automation.PowerShell.Create();
renderer.Runspace = runspace;
renderer.AddCommand("Out-String").AddParameter("Width", 200);
var rendered = renderer.Invoke(results);
var lines = rendered
.SelectMany(r => (r?.ToString() ?? "").Split('\n'))
.Select(s => s.TrimEnd())
.Where(s => s.Length > 0)
.ToList();
if (lines.Count > 0)
{
var html = ParseTextTableToHtml(lines);
if (html is not null)
{
outputLines.Add(html);
}
else
{
var sb = new StringBuilder();
AppendPreStyles(sb);
sb.Append("<div class=\"verso-ps-result\">");
sb.Append("<pre class=\"verso-ps-pre\">")
.Append(WebUtility.HtmlEncode(string.Join(Environment.NewLine, lines)))
.Append("</pre>");
sb.Append("</div>");
outputLines.Add(sb.ToString());
}
outputMimeType = "text/html";
}
}
}
else if (results.Count > 0 && HasComplexObjects(results))
{
// Complex objects: render as HTML table using Select-Object for
Expand Down Expand Up @@ -276,7 +284,7 @@ public static IReadOnlyList<Diagnostic> GetDiagnostics(string code)
return diagnostics;
}

public static HoverInfo? GetHoverInfo(string code, int cursorPosition)
public static HoverInfo? GetHoverInfo(string code, int cursorPosition)
{
if (string.IsNullOrWhiteSpace(code)) return null;

Expand Down Expand Up @@ -324,17 +332,127 @@ public static IReadOnlyList<Diagnostic> GetDiagnostics(string code)
content = targetToken.Text;
}

return new HoverInfo(
content,
"text/plain",
(targetToken.Extent.StartLineNumber - 1,
targetToken.Extent.StartColumnNumber - 1,
targetToken.Extent.EndLineNumber - 1,
targetToken.Extent.EndColumnNumber - 1));
}

/// <summary>
/// Parses Out-String text table output (from Format-Table) into an HTML table.
return new HoverInfo(
content,
"text/plain",
(targetToken.Extent.StartLineNumber - 1,
targetToken.Extent.StartColumnNumber - 1,
targetToken.Extent.EndLineNumber - 1,
targetToken.Extent.EndColumnNumber - 1));
}

private static string? TryRenderFormatTableToHtml(Collection<PSObject> results)
{
try
{
var formatStart = results
.Select(r => r?.BaseObject)
.FirstOrDefault(baseObject => string.Equals(baseObject?.GetType().Name, "FormatStartData", StringComparison.Ordinal));
if (formatStart is null) return null;

var tableHeaderInfo = GetMemberValue(formatStart, "shapeInfo");
if (tableHeaderInfo is null || !string.Equals(tableHeaderInfo.GetType().Name, "TableHeaderInfo", StringComparison.Ordinal))
return null;

if (GetMemberValue(tableHeaderInfo, "tableColumnInfoList") is not IEnumerable columnInfos)
return null;

var columns = new List<(string Header, bool RightAlign)>();
foreach (var columnInfo in columnInfos)
{
if (columnInfo is null) continue;
var label = (GetMemberValue(columnInfo, "label")?.ToString() ?? string.Empty).Trim();
if (string.IsNullOrEmpty(label))
label = (GetMemberValue(columnInfo, "propertyName")?.ToString() ?? string.Empty).Trim();
Comment thread
eosfor marked this conversation as resolved.
Outdated

var alignment = GetMemberValue(columnInfo, "alignment")?.ToString();
var rightAlign = string.Equals(alignment, "Right", StringComparison.OrdinalIgnoreCase);
columns.Add((label, rightAlign));
}

if (columns.Count == 0) return null;

var rows = new List<List<string>>();
foreach (var result in results)
{
var baseObject = result?.BaseObject;
if (!string.Equals(baseObject?.GetType().Name, "FormatEntryData", StringComparison.Ordinal))
continue;
Comment thread
eosfor marked this conversation as resolved.

var tableRowEntry = baseObject is null ? null : GetMemberValue(baseObject, "formatEntryInfo");
var fieldList = tableRowEntry is null ? null : GetMemberValue(tableRowEntry, "formatPropertyFieldList") as IEnumerable;
if (fieldList is null) continue;

var row = new List<string>();
foreach (var field in fieldList)
{
var text = field is null ? string.Empty : GetMemberValue(field, "propertyValue")?.ToString() ?? string.Empty;
row.Add(text);
}

if (row.Count > 0)
rows.Add(row);
}

var sb = new StringBuilder();
AppendTableStyles(sb);
sb.Append("<div class=\"verso-ps-result\">");
sb.Append("<table><thead><tr>");
foreach (var column in columns)
sb.Append("<th>").Append(WebUtility.HtmlEncode(column.Header)).Append("</th>");
sb.Append("</tr></thead><tbody>");

foreach (var row in rows)
{
sb.Append("<tr>");
for (var i = 0; i < columns.Count; i++)
{
sb.Append(columns[i].RightAlign ? "<td style=\"text-align:right;\">" : "<td>");
var cellValue = i < row.Count ? row[i] : string.Empty;
sb.Append(WebUtility.HtmlEncode(cellValue));
sb.Append("</td>");
}
sb.Append("</tr>");
}

sb.Append("</tbody></table>");
sb.Append("<div class=\"verso-ps-footer\">")
.Append(rows.Count.ToString("N0"))
.Append(" object(s)</div>");
sb.Append("</div>");

return sb.ToString();
}
catch
{
return null;
}
}

private static object? GetMemberValue(object instance, string memberName)
{
const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.IgnoreCase;
var type = instance.GetType();
var property = type.GetProperty(memberName, flags);
try
{
if (property is not null)
return property.GetValue(instance);

var field = type.GetField(memberName, flags);
if (field is not null)
return field.GetValue(instance);
}
catch
{
return null;
}

return null;
}

/// <summary>
/// Parses Out-String text table output (from Format-Table) into an HTML table.
/// Returns null if the text doesn't match the expected header/dashes/data pattern,
/// so the caller can fall back to &lt;pre&gt; rendering (e.g. for Format-List).
/// </summary>
Expand Down
47 changes: 38 additions & 9 deletions tests/Verso.PowerShell.Tests/Kernel/ExecutionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -147,12 +147,41 @@ public async Task StringInterpolation_Works()
}

[TestMethod]
public async Task MultipleOutputs_AllCaptured()
{
var outputs = await _kernel.ExecuteAsync(
"Write-Output 'first'\nWrite-Output 'second'", _context);
var allText = string.Join(" ", outputs.Select(o => o.Content));
Assert.IsTrue(allText.Contains("first"), $"Expected 'first', got: {allText}");
Assert.IsTrue(allText.Contains("second"), $"Expected 'second', got: {allText}");
}
}
public async Task MultipleOutputs_AllCaptured()
{
var outputs = await _kernel.ExecuteAsync(
"Write-Output 'first'\nWrite-Output 'second'", _context);
var allText = string.Join(" ", outputs.Select(o => o.Content));
Assert.IsTrue(allText.Contains("first"), $"Expected 'first', got: {allText}");
Assert.IsTrue(allText.Contains("second"), $"Expected 'second', got: {allText}");
}

[TestMethod]
public async Task ExplicitFormatTable_RendersWideValuesWithoutColumnSplitting()
{
var outputs = await _kernel.ExecuteAsync(
"@(" +
"[pscustomobject]@{ Name = 'PSGraphView'; Version = '0.1.0'; ModuleBase = '/Users/example/PSGraphView' }," +
"[pscustomobject]@{ Name = 'PSQuickGraph'; Version = '2.5.0'; ModuleBase = '/Users/example/PSQuickGraph' }" +
") | Format-Table Name, Version, ModuleBase -AutoSize",
_context);

var htmlOutput = outputs.FirstOrDefault(o => o.MimeType == "text/html");
Assert.IsNotNull(htmlOutput, "Expected HTML output for explicit Format-Table.");

var html = htmlOutput.Content;
Assert.IsTrue(html.Contains("<th>Name</th>"), $"Expected Name header, got: {html}");
Assert.IsTrue(html.Contains("<th>Version</th>"), $"Expected Version header, got: {html}");
Assert.IsTrue(html.Contains("<th>ModuleBase</th>"), $"Expected ModuleBase header, got: {html}");
Assert.IsTrue(html.Contains("<td>PSGraphView</td>"), $"Expected full value PSGraphView in a single cell, got: {html}");
Assert.IsTrue(html.Contains("<td>PSQuickGraph</td>"), $"Expected full value PSQuickGraph in a single cell, got: {html}");
Assert.IsTrue(html.Contains("<td>/Users/example/PSGraphView</td>"), $"Expected full ModuleBase path for PSGraphView, got: {html}");
Assert.IsTrue(html.Contains("<td>/Users/example/PSQuickGraph</td>"), $"Expected full ModuleBase path for PSQuickGraph, got: {html}");
Assert.IsFalse(
html.Contains("<td>PSGraphV</td><td>iew</td>", StringComparison.Ordinal),
$"Did not expect split PSGraphView cells, got: {html}");
Assert.IsFalse(
html.Contains("<td>PSQuickG</td><td>raph</td>", StringComparison.Ordinal),
$"Did not expect split PSQuickGraph cells, got: {html}");
}
}