diff --git a/Verso.sln b/Verso.sln index b1fdc60..4840e9c 100644 --- a/Verso.sln +++ b/Verso.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 diff --git a/docs/architecture/execution-pipeline.md b/docs/architecture/execution-pipeline.md index 060c7ce..1e69c38 100644 --- a/docs/architecture/execution-pipeline.md +++ b/docs/architecture/execution-pipeline.md @@ -31,6 +31,8 @@ All three methods call `EnsureParametersInjected()` first, which pushes notebook - `Func` -- language ID resolution for a cell - `Func` -- execution count lookup - `Func` -- magic command resolution delegate +- Optional `Action` -- notified when a running cell appends output +- Optional input requester delegate -- asks the current host for interactive input The pipeline has no direct reference to `Scaffold`. All access goes through these delegates and interfaces. @@ -96,11 +98,14 @@ See the [Magic Commands](#magic-commands) section below for details. An `ExecutionContext` is constructed with the remaining code (after magic commands are stripped), the variable store, theme context, and all other shared services. This context implements `IExecutionContext`, which extends `IVersoContext`. +If the host supplied an input requester, the execution context exposes it through `RequestInputAsync(prompt, isPassword, ct)`. Kernels can use this to pause execution until the front-end returns a value. Hosts that do not provide an input requester keep the default unsupported behavior. + ### 6. Execute The kernel's `ExecuteAsync(code, context)` is called. The kernel can: - **Stream outputs** during execution via `context.WriteOutputAsync(output)`, which calls the `AppendOutput` delegate +- **Request interactive input** during execution via `context.RequestInputAsync(...)` - **Return outputs** from the method, which are appended after execution (skipping any already streamed) ### 7. Post-Processing @@ -197,6 +202,20 @@ Outputs are collected in `cell.Outputs` (a mutable `List`). The pipe Error outputs set `IsError = true` and optionally include `ErrorName` (exception type) and `ErrorStackTrace`. +### Live Output Notifications + +When a kernel calls `WriteOutputAsync`, the pipeline appends the output immediately and invokes the optional output-updated callback. `Scaffold` exposes this as `OnCellOutputUpdated`, and interactive hosts can forward it to their UI before the cell finishes executing. VS Code uses this path to send `output/update` and refresh the cell output cache while the `execution/run` request is still pending. + +This mechanism streams outputs that are explicitly written through the execution context. Outputs that are only returned from `ILanguageKernel.ExecuteAsync` are still appended after the kernel method completes. + +## Interactive Input + +`IExecutionContext.RequestInputAsync(prompt, isPassword, ct)` asks the current host to collect a single line of input. It returns the entered value or `null` when the user cancels. The default interface implementation throws `NotSupportedException`, so kernels should treat this as an optional host capability. + +The PowerShell kernel uses this capability through its internal host adapter around PowerShell's `PSHost` / `PSHostUserInterface` APIs. It maps host prompts such as `Read-Host`, credential prompts, and choice prompts onto `RequestInputAsync`. In VS Code, the request is delivered as an `input/request` notification and answered with `input/response`. + +PowerShell host output (`Write-Host`, warnings, errors, verbose/debug output, and similar `PSHostUserInterface` writes) is converted to `text/plain` `CellOutput` values and written through `WriteOutputAsync`. ANSI escape sequences are currently stripped before display so host output stays readable in cell output. Future work may parse supported ANSI SGR sequences into safe rich output instead of discarding them. + ## Execution Result `ExecutionResult` is an immutable record returned by the pipeline: diff --git a/docs/architecture/front-ends.md b/docs/architecture/front-ends.md index e253fde..f21c36e 100644 --- a/docs/architecture/front-ends.md +++ b/docs/architecture/front-ends.md @@ -82,7 +82,7 @@ Engine events are forwarded to the UI through `Action?` events on the service, w ### Project References -Verso.Blazor references the engine (`Verso`), all kernel extension packages (`Verso.FSharp`, `Verso.JavaScript`, `Verso.Python`, `Verso.PowerShell`, `Verso.Ado`, `Verso.Http`), and the shared UI library (`Verso.Blazor.Shared`). +Verso.Blazor references the engine (`Verso`), all kernel extension packages (`Verso.FSharp`, `Verso.JavaScript`, `Verso.Python`, `Verso.PowerShell`, `Verso.Ado`, `Verso.Http`), and the shared UI library (`Verso.Blazor.Shared`). The PowerShell host adapter is internal to `Verso.PowerShell`. ## VS Code Extension @@ -154,6 +154,8 @@ The WASM project runs in the webview sandbox. It references only `Verso.Abstract The WASM app maintains a local state cache populated from the `notebook/opened` notification. Source updates are debounced (250ms) before forwarding to the host to avoid flooding the JSON-RPC channel during fast typing. +Some requests use a detached bridge path instead of awaiting the full JSON-RPC round-trip inside the webview call stack. This allows the webview to keep processing host notifications while a long-running request such as `execution/run` is still pending. + `WebviewNavigationManager` stubs `NavigationManager` with `app:///` as the base URI because the `vscode-webview://` scheme is not parseable by `System.Uri`. ### Message Flow @@ -182,13 +184,40 @@ Cell.razor calls NotebookService.ExecuteCellAsync(cellId) Notifications (execution state changes, variable updates) flow in the reverse direction without a request ID. +### Live Output and Interactive Input + +Kernels can append outputs while a cell is still running. In VS Code, this uses the `output/update` host notification: + +``` +Kernel calls context.WriteOutputAsync(output) + -> ExecutionPipeline appends the output and notifies Scaffold + -> HostSession sends output/update { notebookId, cellId } + -> BlazorBridge forwards the notification to the webview + -> RemoteNotebookService refreshes the local cell output cache + -> Notebook components re-render before execution/run completes +``` + +Kernels can also request a single input value through `IExecutionContext.RequestInputAsync`. VS Code handles this with `input/request` and `input/response`: + +``` +Kernel calls context.RequestInputAsync(prompt, isPassword, ct) + -> NotebookSession creates a pending input request + -> HostSession sends input/request { notebookId, requestId, cellId, prompt, isPassword } + -> BlazorBridge shows a VS Code input box + -> BlazorBridge sends input/response { notebookId, requestId, value, cancelled } + -> NotebookSession resolves the pending request + -> Kernel execution resumes +``` + +`input/response` is handled by the host read loop outside the normal sequential request queue. This is required because the queued `execution/run` request is still active while the kernel is waiting for the user's answer. + ## Verso.Host `Verso.Host` is a console application that serves as the engine host for VS Code. It communicates via line-delimited JSON-RPC on stdin/stdout. ### Startup -`Program.cs` sets console encoding to UTF-8, emits a `host/ready` notification, then enters a read loop on stdin. A shared `stdoutLock` ensures atomic response writes. Incoming messages are queued through a `Channel` and dispatched sequentially by `HostSession`. +`Program.cs` sets console encoding to UTF-8, emits a `host/ready` notification, then enters a read loop on stdin. A shared `stdoutLock` ensures atomic response writes. Incoming messages are queued through a `Channel` and dispatched sequentially by `HostSession`. A small set of re-entrant responses, such as `extension/consentResponse` and `input/response`, are handled directly in the read loop so they can unblock an already-running request. ### HostSession and NotebookSession @@ -213,6 +242,8 @@ Notifications (execution state changes, variable updates) flow in the reverse di Method names are centralized in `Protocol.MethodNames`. The TypeScript `protocol.ts` in the VS Code extension mirrors the same method names. +`NotebookSession` also owns session-scoped callbacks that are not exposed as standalone handler classes. It subscribes to `Scaffold.OnCellOutputUpdated` and sends `output/update`, and it implements the pending request table for `input/request` / `input/response`. + ## CLI (Verso.Cli) The CLI is a .NET global tool with four commands. It references all kernel packages and the Blazor Server project. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 6c8714f..4255ef4 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -36,7 +36,7 @@ The architecture separates into three layers. The engine knows nothing about the | +------------------------------------------------------+ | | | First-Party Extension Packages | | | | Verso.FSharp - Verso.JavaScript - Verso.PowerShell | | -| | Verso.Python - Verso.Ado (SQL) - Verso.Http | | +| | Verso.Python - Verso.Ado - Verso.Http | | | +------------------------------------------------------+ | +-----------------------------------------------------------+ | @@ -89,6 +89,8 @@ The engine is a headless .NET library with no UI dependencies. Its central class The engine is designed for embedding. Any .NET application can reference the Verso NuGet package, create a `Scaffold`, load extensions, and execute notebook cells programmatically. The front-ends are consumers of this API, not part of it. +Execution contexts can still request host services through public abstractions. Kernels use `WriteOutputAsync` for live output and `RequestInputAsync` for a single interactive input value. Hosts that support those operations wire them to their UI; hosts that do not support them can keep the default `NotSupportedException` behavior. + See the [engine](engine.md) and [execution-pipeline](execution-pipeline.md) documents for detailed coverage. ### Front-Ends @@ -99,6 +101,8 @@ Three front-ends consume the engine: **VS Code** runs Blazor WebAssembly in a webview. The WASM app has no reference to the engine. Instead, a `RemoteNotebookService` forwards all operations through a JavaScript bridge to the VS Code extension, which relays them over JSON-RPC (stdin/stdout) to a `Verso.Host` process. The host process holds the `Scaffold` and runs the engine. This separation means the UI runs in the browser's sandbox while the engine has full .NET runtime access. +During long-running execution, VS Code also receives host-pushed notifications for live output and interactive input. `output/update` refreshes cell outputs before the final `execution/run` response returns, and `input/request` asks the extension to collect a value from the user and answer with `input/response`. + **The CLI** (`verso run`, `verso convert`) drives the engine headlessly with no UI. A `HeadlessRunner` creates a `Scaffold`, loads extensions, resolves parameters, and executes cells sequentially. Output is rendered to the terminal or serialized to JSON. This is the entry point for CI/CD pipelines. All three visual front-ends (Blazor Server, VS Code, and `verso serve`) share the same Razor components from the `Verso.Blazor.Shared` class library, so the notebook experience is identical regardless of hosting environment. @@ -135,7 +139,7 @@ Verso (engine: Scaffold, ExtensionHost, Pipeline) ^ |--- Verso.FSharp (F# kernel extension) |--- Verso.JavaScript (JS/TS kernel extension) - |--- Verso.PowerShell (PowerShell kernel extension) + |--- Verso.PowerShell (PowerShell kernel extension with internal PSHost adapter) |--- Verso.Python (Python kernel extension) |--- Verso.Ado (SQL kernel extension) |--- Verso.Http (HTTP kernel extension) diff --git a/docs/extensions/context-reference.md b/docs/extensions/context-reference.md index d781dce..f948a64 100644 --- a/docs/extensions/context-reference.md +++ b/docs/extensions/context-reference.md @@ -61,6 +61,7 @@ Extends `IVersoContext` with execution-specific state. Passed to `ILanguageKerne | `CellId` | `Guid` | Unique identifier of the cell being executed. | | `ExecutionCount` | `int` | Monotonically increasing execution counter for the current cell. | | `DisplayAsync(CellOutput)` | `Task` | Sends a display output that can be updated in place during execution. | +| `RequestInputAsync(string, bool, CancellationToken)` | `Task` | Requests a single input value from the current host. The second argument controls password masking. Returns `null` when the user cancels. Default implementation throws `NotSupportedException` on non-interactive hosts. | ### When Available @@ -72,6 +73,12 @@ Only within `ILanguageKernel.ExecuteAsync`. Not available during completions, di - `DisplayAsync` sends a live-updating display that can be replaced. Use it for progress indicators, streaming output, or interactive displays. - `UpdateOutputAsync` (from `IVersoContext`) replaces the content of a specific output block by ID. Use it for interactive panels that refresh in place (e.g., paginated tables, `ICellInteractionHandler` responses). +### Interactive Input + +`RequestInputAsync` is optional host functionality. Use it when a kernel genuinely needs a value from the user during execution, and handle `NotSupportedException` if the same code can run in headless or non-interactive environments. + +In VS Code, input requests are shown with a native input box. In headless hosts, the default implementation is unsupported unless that host wires in its own input provider. + ### Usage Example ```csharp @@ -87,9 +94,22 @@ public async Task> ExecuteAsync(string code, IExecutio } ``` +```csharp +public async Task> ExecuteAsync(string code, IExecutionContext context) +{ + var name = await context.RequestInputAsync("Name:", cancellationToken: context.CancellationToken); + if (name is null) + { + return new[] { new CellOutput("text/plain", "Input cancelled.") }; + } + + return new[] { new CellOutput("text/plain", $"Hello, {name}") }; +} +``` + ### Test Stub -`StubExecutionContext` from `Verso.Testing.Stubs` tracks both `WrittenOutputs` and `DisplayedOutputs` as `List` for assertion. +`StubExecutionContext` from `Verso.Testing.Stubs` tracks both `WrittenOutputs` and `DisplayedOutputs` as `List` for assertion. Tests can set `InputHandler` to provide values for `RequestInputAsync`. --- diff --git a/src/Verso.Abstractions/Contexts/IExecutionContext.cs b/src/Verso.Abstractions/Contexts/IExecutionContext.cs index d6a3f09..30e3b73 100644 --- a/src/Verso.Abstractions/Contexts/IExecutionContext.cs +++ b/src/Verso.Abstractions/Contexts/IExecutionContext.cs @@ -21,4 +21,19 @@ public interface IExecutionContext : IVersoContext /// The cell output to display. /// A task that completes when the output has been displayed. Task DisplayAsync(CellOutput output); + + /// + /// Requests a single line of user input from the current front-end. + /// + /// Prompt text shown to the user. + /// Whether the input should be masked. + /// Cancellation token for the pending input request. + /// The entered value, or null when the user cancels the prompt. + Task RequestInputAsync( + string prompt, + bool isPassword = false, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException("Interactive input is not supported by this host."); + } } diff --git a/src/Verso.Blazor.Wasm/Services/RemoteNotebookService.cs b/src/Verso.Blazor.Wasm/Services/RemoteNotebookService.cs index 9f14e3a..7d7e6e3 100644 --- a/src/Verso.Blazor.Wasm/Services/RemoteNotebookService.cs +++ b/src/Verso.Blazor.Wasm/Services/RemoteNotebookService.cs @@ -770,7 +770,7 @@ private void HandleNotification(string method, string? paramsJson) _ = HandleExtensionChangedAsync(); break; case "output/update": - HandleOutputUpdate(); + HandleOutputUpdate(paramsJson); break; case "kernel/restarting": HandleKernelRestarting(paramsJson); @@ -838,8 +838,36 @@ private async Task HandleExtensionChangedAsync() OnNotebookChanged?.Invoke(); } - private void HandleOutputUpdate() + private void HandleOutputUpdate(string? paramsJson) { + if (!string.IsNullOrWhiteSpace(paramsJson)) + { + try + { + var notif = JsonSerializer.Deserialize( + paramsJson, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (notif is not null && Guid.TryParse(notif.CellId, out var cellId)) + { + var cell = _cells.FirstOrDefault(c => c.Id == cellId); + if (cell is not null) + { + cell.Outputs.Clear(); + foreach (var output in notif.Outputs ?? Enumerable.Empty()) + cell.Outputs.Add(MapOutputFromDto(output)); + + OnOutputUpdated?.Invoke(); + return; + } + } + } + catch (JsonException) + { + // Fall back to a full refresh below. + } + } + _ = RefreshCellListAsync().ContinueWith(_ => OnOutputUpdated?.Invoke()); } @@ -1220,6 +1248,12 @@ private sealed class ExecutionStateNotification public string State { get; set; } = ""; } + private sealed class OutputUpdateNotification + { + public string CellId { get; set; } = ""; + public List? Outputs { get; set; } + } + private sealed class SaveResponse { public string? Content { get; set; } diff --git a/src/Verso.Blazor.Wasm/Services/VsCodeBridge.cs b/src/Verso.Blazor.Wasm/Services/VsCodeBridge.cs index 8a60e0a..65f0ea2 100644 --- a/src/Verso.Blazor.Wasm/Services/VsCodeBridge.cs +++ b/src/Verso.Blazor.Wasm/Services/VsCodeBridge.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; +using System.Collections.Concurrent; using Microsoft.JSInterop; namespace Verso.Blazor.Wasm.Services; @@ -11,7 +12,9 @@ namespace Verso.Blazor.Wasm.Services; public sealed class VsCodeBridge : IAsyncDisposable { private readonly IJSRuntime _js; + private readonly ConcurrentDictionary> _pendingRequests = new(); private DotNetObjectReference? _selfRef; + private int _nextRequestId; private bool _initialized; private static readonly JsonSerializerOptions s_jsonOptions = new() @@ -57,8 +60,7 @@ public async Task IsVsCodeWebviewAsync() /// public async Task RequestAsync(string method, object? @params = null) { - var paramsJson = @params is not null ? JsonSerializer.Serialize(@params, s_jsonOptions) : null; - var resultJson = await _js.InvokeAsync("vscodeBridge.sendRequest", method, paramsJson); + var resultJson = await RequestRawAsync(method, @params); return JsonSerializer.Deserialize(resultJson, s_jsonOptions)!; } @@ -67,8 +69,27 @@ public async Task RequestAsync(string method, object? @params = null) /// public async Task RequestVoidAsync(string method, object? @params = null) { + await RequestRawAsync(method, @params); + } + + private async Task RequestRawAsync(string method, object? @params) + { + var id = Interlocked.Increment(ref _nextRequestId); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (!_pendingRequests.TryAdd(id, tcs)) + throw new InvalidOperationException($"Duplicate VS Code bridge request id '{id}'."); + var paramsJson = @params is not null ? JsonSerializer.Serialize(@params, s_jsonOptions) : null; - await _js.InvokeAsync("vscodeBridge.sendRequest", method, paramsJson); + try + { + await _js.InvokeVoidAsync("vscodeBridge.sendRequestDetached", id, method, paramsJson); + return await tcs.Task; + } + catch + { + _pendingRequests.TryRemove(id, out _); + throw; + } } /// @@ -81,9 +102,48 @@ public Task OnNotificationFromJs(string method, string? paramsJson) return Task.CompletedTask; } + /// + /// Called from JS when a detached JSON-RPC request receives a response. + /// + [JSInvokable("OnResponse")] + public Task OnResponseFromJs(int id, string? resultJson, string? errorJson) + { + if (!_pendingRequests.TryRemove(id, out var tcs)) + return Task.CompletedTask; + + if (!string.IsNullOrWhiteSpace(errorJson)) + { + try + { + using var doc = JsonDocument.Parse(errorJson); + var root = doc.RootElement; + var message = root.TryGetProperty("message", out var messageEl) + ? messageEl.GetString() + : "JSON-RPC error"; + var code = root.TryGetProperty("code", out var codeEl) && codeEl.TryGetInt32(out var c) + ? c + : 0; + tcs.TrySetException(new InvalidOperationException($"{message} (code {code})")); + } + catch (JsonException) + { + tcs.TrySetException(new InvalidOperationException(errorJson)); + } + } + else + { + tcs.TrySetResult(resultJson ?? "null"); + } + + return Task.CompletedTask; + } + public ValueTask DisposeAsync() { _selfRef?.Dispose(); + foreach (var pending in _pendingRequests.Values) + pending.TrySetCanceled(); + _pendingRequests.Clear(); return ValueTask.CompletedTask; } } diff --git a/src/Verso.Blazor.Wasm/wwwroot/js/vscode-bridge.js b/src/Verso.Blazor.Wasm/wwwroot/js/vscode-bridge.js index b4c6a48..2de336b 100644 --- a/src/Verso.Blazor.Wasm/wwwroot/js/vscode-bridge.js +++ b/src/Verso.Blazor.Wasm/wwwroot/js/vscode-bridge.js @@ -11,6 +11,7 @@ const pending = new Map(); // id → { resolve, reject } let notificationCallback = null; // DotNetObjectReference for notifications const pendingNotifications = []; // queued before handler is registered + const pendingResponses = []; // queued before handler is registered /** * Send a JSON-RPC request to the VS Code extension host. @@ -41,6 +42,29 @@ }); } + /** + * Send a JSON-RPC request and return immediately. The eventual response is + * delivered to .NET through OnResponse. This avoids keeping a long-running + * .NET→JS interop promise open while execution notifications are streaming. + * @param {number} id - JSON-RPC request id allocated by .NET + * @param {string} method - The JSON-RPC method name + * @param {string} paramsJson - Serialized JSON params + */ + function sendRequestDetached(id, method, paramsJson) { + const message = { + type: "jsonrpc-request", + id: id, + method: method, + params: paramsJson ? JSON.parse(paramsJson) : null + }; + + if (vscode) { + vscode.postMessage(message); + } else { + throw new Error("Not running inside a VS Code webview."); + } + } + /** * Register a .NET object reference to receive host notifications. * The object must have an InvokeMethodAsync-compatible method named "OnNotification". @@ -53,6 +77,11 @@ var n = pendingNotifications.shift(); notificationCallback.invokeMethodAsync("OnNotification", n.method, n.params); } + + while (pendingResponses.length > 0) { + var r = pendingResponses.shift(); + notificationCallback.invokeMethodAsync("OnResponse", r.id, r.result, r.error); + } } /** @@ -83,13 +112,22 @@ if (msg.type === "jsonrpc-response") { const entry = pending.get(msg.id); - if (!entry) return; - pending.delete(msg.id); + if (entry) { + pending.delete(msg.id); - if (msg.error) { - entry.reject(new Error(msg.error.message || "JSON-RPC error")); + if (msg.error) { + entry.reject(new Error(msg.error.message || "JSON-RPC error")); + } else { + entry.resolve(JSON.stringify(msg.result)); + } } else { - entry.resolve(JSON.stringify(msg.result)); + const result = msg.error ? null : JSON.stringify(msg.result); + const error = msg.error ? JSON.stringify(msg.error) : null; + if (notificationCallback) { + notificationCallback.invokeMethodAsync("OnResponse", msg.id, result, error); + } else { + pendingResponses.push({ id: msg.id, result: result, error: error }); + } } } else if (msg.type === "editor-settings-changed") { if (window.versoMonaco && typeof window.versoMonaco.updateEditorSettings === "function") { @@ -114,6 +152,7 @@ // Expose to Blazor JS interop window.vscodeBridge = { sendRequest: sendRequest, + sendRequestDetached: sendRequestDetached, registerNotificationHandler: registerNotificationHandler, isVsCodeWebview: isVsCodeWebview, getThemeKind: getThemeKind diff --git a/src/Verso.Blazor/Components/NotebookInputDialog.razor b/src/Verso.Blazor/Components/NotebookInputDialog.razor new file mode 100644 index 0000000..8c38fca --- /dev/null +++ b/src/Verso.Blazor/Components/NotebookInputDialog.razor @@ -0,0 +1,77 @@ +@using Verso.Blazor.Services + +@if (IsVisible && Request is not null) +{ +
+
+
+ PowerShell Input + +
+
+ + +
+ +
+
+} + +@code { + private string _value = string.Empty; + private ServerInputRequest? _lastRequest; + + [Parameter] public bool IsVisible { get; set; } + [Parameter] public ServerInputRequest? Request { get; set; } + [Parameter] public EventCallback OnSubmit { get; set; } + [Parameter] public EventCallback OnCancel { get; set; } + + private string PromptText => + string.IsNullOrWhiteSpace(Request?.Prompt) ? "Input" : Request.Prompt; + + private string InputType => Request?.IsPassword == true ? "password" : "text"; + + protected override void OnParametersSet() + { + if (Request is null) + { + _lastRequest = null; + _value = string.Empty; + return; + } + + if (_lastRequest != Request) + { + _lastRequest = Request; + _value = string.Empty; + } + } + + private async Task HandleSubmit() => await OnSubmit.InvokeAsync(_value); + + private async Task HandleCancel() => await OnCancel.InvokeAsync(); + + private async Task HandleKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") + await HandleSubmit(); + else if (e.Key == "Escape") + await HandleCancel(); + } +} diff --git a/src/Verso.Blazor/Components/Pages/NotebookPage.razor b/src/Verso.Blazor/Components/Pages/NotebookPage.razor index 72b7d4d..58d192d 100644 --- a/src/Verso.Blazor/Components/Pages/NotebookPage.razor +++ b/src/Verso.Blazor/Components/Pages/NotebookPage.razor @@ -62,6 +62,14 @@ OnConsentResult="HandleExtensionConsentResult" /> } + @if (_showInputDialog && _pendingInputRequest is not null) + { + + } + @if (_error is not null) {
@@ -303,6 +311,9 @@ private ElementReference _saveDialogInput; private bool _showExtensionConsent; private IReadOnlyList? _pendingConsentExtensions; + private bool _showInputDialog; + private ServerInputRequest? _pendingInputRequest; + private bool _executionInProgress; private Timer? _autoSaveTimer; private bool _userPrefsApplied; private bool _restoringPrefs; @@ -335,7 +346,10 @@ Service.OnOutputUpdated += HandleOutputUpdated; if (Service is ServerNotebookService serverService) + { serverService.OnExtensionConsentRequested += HandleExtensionConsentRequested; + serverService.OnInputRequested += HandleInputRequested; + } } private void HandleCellExecutedRefresh() @@ -623,6 +637,37 @@ serverService.ResolveConsentResult(approved); } + private void HandleInputRequested() + { + _ = InvokeAsync(() => + { + if (Service is ServerNotebookService serverService) + { + _pendingInputRequest = serverService.PendingInputRequest; + _showInputDialog = _pendingInputRequest is not null; + StateHasChanged(); + } + }); + } + + private void HandleInputSubmitted(string value) + { + _showInputDialog = false; + _pendingInputRequest = null; + + if (Service is ServerNotebookService serverService) + serverService.ResolveInputResult(value, cancelled: false); + } + + private void HandleInputCancelled() + { + _showInputDialog = false; + _pendingInputRequest = null; + + if (Service is ServerNotebookService serverService) + serverService.ResolveInputResult(null, cancelled: true); + } + private async void HandleOutputUpdated() { await InvokeAsync(StateHasChanged); @@ -894,33 +939,58 @@ _selectedCellId = cell.Id; } - private async Task HandleRunCell(Guid cellId) + private Task HandleRunCell(Guid cellId) + => StartExecutionAsync(async () => await Service.ExecuteCellAsync(cellId)); + + private async Task HandleCancelCell(Guid cellId) { - // _executingCellId and cell metadata are managed by HandleCellExecutingEvent / - // HandleCellExecutedEvent (driven by Scaffold.OnCellExecuting/OnCellExecuted). try { - await Service.ExecuteCellAsync(cellId); - } - catch (Exception ex) when (ex is OperationCanceledException || ex.Message.Contains("operation was canceled", StringComparison.OrdinalIgnoreCase)) - { - // User-initiated cancellation is not an error; the cell's status badge already shows it. + await Service.CancelCellAsync(cellId); } catch (Exception ex) { - _error = $"Execution error: {ex.Message}"; + _error = $"Cancel error: {ex.Message}"; } } - private async Task HandleCancelCell(Guid cellId) + private Task StartExecutionAsync(Func executeAsync) { + if (_executionInProgress) + return Task.CompletedTask; + + _executionInProgress = true; + _ = Task.Run(() => RunExecutionAsync(executeAsync)); + return Task.CompletedTask; + } + + private async Task RunExecutionAsync(Func executeAsync) + { + // _executingCellId and cell metadata are managed by HandleCellExecutingEvent / + // HandleCellExecutedEvent (driven by Scaffold.OnCellExecuting/OnCellExecuted). try { - await Service.CancelCellAsync(cellId); + await executeAsync(); + } + catch (Exception ex) when (ex is OperationCanceledException || ex.Message.Contains("operation was canceled", StringComparison.OrdinalIgnoreCase)) + { + // User-initiated cancellation is not an error; the cell's status badge already shows it. } catch (Exception ex) { - _error = $"Cancel error: {ex.Message}"; + await InvokeAsync(() => + { + _error = $"Execution error: {ex.Message}"; + StateHasChanged(); + }); + } + finally + { + await InvokeAsync(() => + { + _executionInProgress = false; + StateHasChanged(); + }); } } @@ -1018,25 +1088,8 @@ } } - private async Task HandleRunAll() - { - // Per-cell UI updates (_executingCellId + StateHasChanged) are driven by - // HandleCellExecutingEvent / HandleCellExecutedEvent. Cell metadata - // (ExecutionCount, LastElapsed, LastStatus) is stamped inside Scaffold - // before OnCellExecuted fires, so the UI sees fresh values per cell. - try - { - await Service.ExecuteAllAsync(); - } - catch (Exception ex) when (ex is OperationCanceledException || ex.Message.Contains("operation was canceled", StringComparison.OrdinalIgnoreCase)) - { - // User-initiated cancellation is not an error. - } - catch (Exception ex) - { - _error = $"Execution error: {ex.Message}"; - } - } + private Task HandleRunAll() + => StartExecutionAsync(async () => await Service.ExecuteAllAsync()); private async Task SelectNextCell(Guid currentId, bool addIfLast = false) { @@ -1210,6 +1263,9 @@ Service.OnOutputUpdated -= HandleOutputUpdated; if (Service is ServerNotebookService serverService) + { serverService.OnExtensionConsentRequested -= HandleExtensionConsentRequested; + serverService.OnInputRequested -= HandleInputRequested; + } } } diff --git a/src/Verso.Blazor/Services/ServerInputRequest.cs b/src/Verso.Blazor/Services/ServerInputRequest.cs new file mode 100644 index 0000000..9189f19 --- /dev/null +++ b/src/Verso.Blazor/Services/ServerInputRequest.cs @@ -0,0 +1,6 @@ +namespace Verso.Blazor.Services; + +public sealed record ServerInputRequest( + Guid CellId, + string Prompt, + bool IsPassword); diff --git a/src/Verso.Blazor/Services/ServerNotebookService.cs b/src/Verso.Blazor/Services/ServerNotebookService.cs index fc23877..f17add6 100644 --- a/src/Verso.Blazor/Services/ServerNotebookService.cs +++ b/src/Verso.Blazor/Services/ServerNotebookService.cs @@ -24,6 +24,9 @@ public sealed class ServerNotebookService : INotebookService, IAsyncDisposable private CancellationTokenSource? _executionCts; private readonly IJSRuntime _jsRuntime; private readonly NotebookServiceOptions _options; + private readonly object _inputLock = new(); + private TaskCompletionSource? _inputTcs; + private ServerInputRequest? _pendingInputRequest; // Monaco is eagerly loaded at page load (before any notebook opens), // so by the time cells render, define.amd is already removed and // output scripts cannot interfere with the AMD loader. @@ -36,9 +39,22 @@ public sealed class ServerNotebookService : INotebookService, IAsyncDisposable /// Raised when extensions need user consent. The UI should show the consent dialog. public event Action? OnExtensionConsentRequested; + /// Raised when kernel execution needs a single input value from the UI. + public event Action? OnInputRequested; + /// The extensions awaiting consent, if any. public IReadOnlyList? PendingConsentExtensions => _pendingConsentExtensions; + /// The pending execution input request, if any. + public ServerInputRequest? PendingInputRequest + { + get + { + lock (_inputLock) + return _pendingInputRequest; + } + } + /// Called by the UI to resolve the pending consent request. public void ResolveConsentResult(bool approved) { @@ -46,6 +62,20 @@ public void ResolveConsentResult(bool approved) _pendingConsentExtensions = null; } + /// Called by the UI to resolve the pending execution input request. + public void ResolveInputResult(string? value, bool cancelled) + { + TaskCompletionSource? tcs; + lock (_inputLock) + { + tcs = _inputTcs; + _inputTcs = null; + _pendingInputRequest = null; + } + + tcs?.TrySetResult(cancelled ? null : value ?? string.Empty); + } + public ServerNotebookService(IJSRuntime jsRuntime, NotebookServiceOptions? options = null) { _jsRuntime = jsRuntime; @@ -970,6 +1000,8 @@ private void SubscribeToEngineEvents() { _scaffold.OnCellExecuting += HandleScaffoldCellExecuting; _scaffold.OnCellExecuted += HandleScaffoldCellExecuted; + _scaffold.OnCellOutputUpdated += HandleScaffoldCellOutputUpdated; + _scaffold.InputRequester = RequestInputFromUIAsync; } if (_scaffold?.Variables is VariableStore vs) @@ -988,6 +1020,8 @@ private void UnsubscribeFromEngineEvents() { _scaffold.OnCellExecuting -= HandleScaffoldCellExecuting; _scaffold.OnCellExecuted -= HandleScaffoldCellExecuted; + _scaffold.OnCellOutputUpdated -= HandleScaffoldCellOutputUpdated; + _scaffold.InputRequester = null; } if (_scaffold?.Variables is VariableStore vs) @@ -1015,6 +1049,57 @@ private void HandleScaffoldCellExecuted(Guid cellId) OnCellExecuted?.Invoke(); } + private void HandleScaffoldCellOutputUpdated(Guid cellId) + => OnOutputUpdated?.Invoke(); + + private async Task RequestInputFromUIAsync( + Guid cellId, + string prompt, + bool isPassword, + CancellationToken cancellationToken) + { + var tcs = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + lock (_inputLock) + { + if (_inputTcs is not null) + throw new InvalidOperationException("Another input request is already pending."); + + _inputTcs = tcs; + _pendingInputRequest = new ServerInputRequest(cellId, prompt, isPassword); + } + + using var registration = cancellationToken.Register( + static state => ((TaskCompletionSource)state!).TrySetResult(null), + tcs); + + OnInputRequested?.Invoke(); + + try + { + return await tcs.Task.ConfigureAwait(false); + } + finally + { + if (ClearPendingInputRequest(tcs)) + OnInputRequested?.Invoke(); + } + } + + private bool ClearPendingInputRequest(TaskCompletionSource tcs) + { + lock (_inputLock) + { + if (!ReferenceEquals(_inputTcs, tcs)) + return false; + + _inputTcs = null; + _pendingInputRequest = null; + return true; + } + } + private async Task RequestConsentFromUIAsync( IReadOnlyList extensions, CancellationToken cancellationToken) @@ -1073,6 +1158,8 @@ private void WarmUpKernelsInBackground() private async Task DisposeCurrentAsync() { + _executionCts?.Cancel(); + ResolveInputResult(null, cancelled: true); UnsubscribeFromEngineEvents(); if (_scaffold is not null) { diff --git a/src/Verso.Host/HostSession.cs b/src/Verso.Host/HostSession.cs index 6c1932a..616aaee 100644 --- a/src/Verso.Host/HostSession.cs +++ b/src/Verso.Host/HostSession.cs @@ -16,7 +16,9 @@ public sealed class NotebookSession : IAsyncDisposable private CancellationTokenSource? _executionCts; private readonly Action _sendNotification; private readonly ConcurrentDictionary> _pendingConsents = new(); + private readonly ConcurrentDictionary> _pendingInputs = new(); private int _consentCounter; + private int _inputCounter; public NotebookSession( string notebookId, @@ -34,6 +36,8 @@ public NotebookSession( // extension/layout/theme lists. ExtensionHost.OnExtensionLoaded += HandleExtensionLoaded; ExtensionHost.OnExtensionStatusChanged += HandleExtensionStatusChanged; + Scaffold.OnCellOutputUpdated += HandleCellOutputUpdated; + Scaffold.InputRequester = RequestInputAsync; } private void HandleExtensionLoaded(Abstractions.IExtension extension) @@ -46,6 +50,17 @@ private void HandleExtensionStatusChanged(string extensionId, Abstractions.Exten SendNotification(Protocol.MethodNames.ExtensionChanged, null); } + private void HandleCellOutputUpdated(Guid cellId) + { + var cell = Scaffold.GetCell(cellId); + SendNotification(Protocol.MethodNames.OutputUpdate, new + { + cellId = cellId.ToString(), + outputs = cell?.Outputs.Select(Handlers.NotebookHandler.MapOutput).ToList() + ?? new List() + }); + } + public CancellationToken GetExecutionToken() { _executionCts?.Dispose(); @@ -100,16 +115,66 @@ public void ResolveConsent(string requestId, bool approved) tcs.TrySetResult(approved); } + /// + /// Sends an interactive input request notification to the client and awaits the response. + /// + public async Task RequestInputAsync( + Guid cellId, + string prompt, + bool isPassword, + CancellationToken cancellationToken) + { + var requestId = $"input-{Interlocked.Increment(ref _inputCounter)}"; + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pendingInputs[requestId] = tcs; + + using var registration = cancellationToken.Register(() => + { + if (_pendingInputs.TryRemove(requestId, out var pending)) + pending.TrySetCanceled(cancellationToken); + }); + + SendNotification(Protocol.MethodNames.InputRequest, new + { + requestId, + cellId = cellId.ToString(), + prompt, + isPassword + }); + + return await tcs.Task.ConfigureAwait(false); + } + + /// + /// Resolves a pending input request from the client response. + /// + public void ResolveInput(string requestId, string? value, bool cancelled) + { + if (!_pendingInputs.TryRemove(requestId, out var tcs)) + return; + + if (cancelled) + tcs.TrySetCanceled(); + else + tcs.TrySetResult(value ?? string.Empty); + } + public async ValueTask DisposeAsync() { ExtensionHost.OnExtensionLoaded -= HandleExtensionLoaded; ExtensionHost.OnExtensionStatusChanged -= HandleExtensionStatusChanged; + Scaffold.OnCellOutputUpdated -= HandleCellOutputUpdated; + Scaffold.InputRequester = null; // Cancel any pending consent requests foreach (var tcs in _pendingConsents.Values) tcs.TrySetResult(false); _pendingConsents.Clear(); + foreach (var tcs in _pendingInputs.Values) + tcs.TrySetCanceled(); + _pendingInputs.Clear(); + _executionCts?.Dispose(); await Scaffold.DisposeAsync(); await ExtensionHost.DisposeAsync(); @@ -247,6 +312,7 @@ public async Task DispatchAsync(object id, string method, JsonElement? @ MethodNames.ExtensionList => ExtensionHandler.HandleList(ns), MethodNames.ExtensionEnable => await ExtensionHandler.HandleEnableAsync(ns, @params), MethodNames.ExtensionDisable => await ExtensionHandler.HandleDisableAsync(ns, @params), + MethodNames.InputResponse => HandleInputResponse(ns, @params), MethodNames.SettingsGetDefinitions => SettingsHandler.HandleGetDefinitions(ns), MethodNames.SettingsGet => SettingsHandler.HandleGet(ns, @params), MethodNames.SettingsUpdate => await SettingsHandler.HandleUpdateAsync(ns, @params), @@ -276,6 +342,23 @@ public async Task DispatchAsync(object id, string method, JsonElement? @ return null; } + private static object? HandleInputResponse(NotebookSession ns, JsonElement? @params) + { + var requestId = @params?.GetProperty("requestId").GetString(); + var cancelled = @params?.TryGetProperty("cancelled", out var cancelledEl) == true && + cancelledEl.GetBoolean(); + string? value = null; + if (@params?.TryGetProperty("value", out var valueEl) == true && + valueEl.ValueKind != JsonValueKind.Null) + { + value = valueEl.GetString(); + } + + if (requestId is not null) + ns.ResolveInput(requestId, value, cancelled); + return null; + } + private object? HandleShutdown() { _ = Task.Run(async () => diff --git a/src/Verso.Host/Program.cs b/src/Verso.Host/Program.cs index fb12ec1..f20a80b 100644 --- a/src/Verso.Host/Program.cs +++ b/src/Verso.Host/Program.cs @@ -22,9 +22,9 @@ var requests = Channel.CreateUnbounded<(object id, string method, JsonElement? @params)>(); // Background task: continuously read stdin. -// Consent responses are resolved immediately (they just complete a TCS) to prevent -// deadlocks when a handler is blocked waiting for consent approval. All other -// requests are forwarded to the main loop via the channel. +// Consent and input responses are resolved immediately (they just complete a TCS) +// to prevent deadlocks when a handler is blocked waiting for user approval/input. +// All other requests are forwarded to the main loop via the channel. _ = Task.Run(async () => { try @@ -41,7 +41,8 @@ if (id is null || method is null) continue; - if (method == MethodNames.ExtensionConsentResponse) + if (method == MethodNames.ExtensionConsentResponse || + method == MethodNames.InputResponse) { // Handle inline — resolves a TCS, no heavy work. var response = await session.DispatchAsync(id, method, @params); diff --git a/src/Verso.Host/Protocol/MethodNames.cs b/src/Verso.Host/Protocol/MethodNames.cs index b458997..14a8bbc 100644 --- a/src/Verso.Host/Protocol/MethodNames.cs +++ b/src/Verso.Host/Protocol/MethodNames.cs @@ -45,6 +45,10 @@ public static class MethodNames public const string OutputClearAll = "output/clearAll"; public const string OutputUpdate = "output/update"; + // Interactive input + public const string InputRequest = "input/request"; + public const string InputResponse = "input/response"; + // Cell interaction public const string CellInteract = "cell/interact"; diff --git a/src/Verso.PowerShell/Kernel/Host/PowerShellHostCallbacks.cs b/src/Verso.PowerShell/Kernel/Host/PowerShellHostCallbacks.cs new file mode 100644 index 0000000..8be4e88 --- /dev/null +++ b/src/Verso.PowerShell/Kernel/Host/PowerShellHostCallbacks.cs @@ -0,0 +1,9 @@ +namespace Verso.PowerShell.Kernel.Host; + +internal delegate Task PowerShellHostOutputCallback(PowerShellHostOutput output); + +internal delegate PowerShellHostOutputCallback? PowerShellHostOutputCallbackProvider(); + +internal delegate Task PowerShellHostInputCallback(PowerShellHostInputRequest request); + +internal delegate PowerShellHostInputCallback? PowerShellHostInputCallbackProvider(); diff --git a/src/Verso.PowerShell/Kernel/Host/PowerShellHostInputRequest.cs b/src/Verso.PowerShell/Kernel/Host/PowerShellHostInputRequest.cs new file mode 100644 index 0000000..01b0036 --- /dev/null +++ b/src/Verso.PowerShell/Kernel/Host/PowerShellHostInputRequest.cs @@ -0,0 +1,5 @@ +namespace Verso.PowerShell.Kernel.Host; + +internal sealed record PowerShellHostInputRequest( + string Prompt, + bool IsPassword = false); diff --git a/src/Verso.PowerShell/Kernel/Host/PowerShellHostOutput.cs b/src/Verso.PowerShell/Kernel/Host/PowerShellHostOutput.cs new file mode 100644 index 0000000..b84d77f --- /dev/null +++ b/src/Verso.PowerShell/Kernel/Host/PowerShellHostOutput.cs @@ -0,0 +1,7 @@ +namespace Verso.PowerShell.Kernel.Host; + +internal sealed record PowerShellHostOutput( + string MimeType, + string Content, + bool IsError = false, + string? ErrorName = null); diff --git a/src/Verso.PowerShell/Kernel/Host/VersoPowerShellHost.cs b/src/Verso.PowerShell/Kernel/Host/VersoPowerShellHost.cs new file mode 100644 index 0000000..28b3b40 --- /dev/null +++ b/src/Verso.PowerShell/Kernel/Host/VersoPowerShellHost.cs @@ -0,0 +1,54 @@ +using System.Globalization; +using System.Management.Automation; +using System.Management.Automation.Host; + +namespace Verso.PowerShell.Kernel.Host; + +internal sealed class VersoPowerShellHost : PSHost +{ + private readonly VersoPowerShellHostUserInterface _ui; + + public VersoPowerShellHost( + PowerShellHostOutputCallbackProvider outputCallbackProvider, + PowerShellHostInputCallbackProvider inputCallbackProvider) + { + ArgumentNullException.ThrowIfNull(outputCallbackProvider); + ArgumentNullException.ThrowIfNull(inputCallbackProvider); + + InstanceId = Guid.NewGuid(); + _ui = new VersoPowerShellHostUserInterface(outputCallbackProvider, inputCallbackProvider); + PrivateData = PSObject.AsPSObject(new object()); + } + + public override string Name => "Verso PowerShell Host"; + + public override Version Version { get; } = new(1, 0, 0); + + public override Guid InstanceId { get; } + + public override PSHostUserInterface UI => _ui; + + public override PSObject PrivateData { get; } + + public override CultureInfo CurrentCulture => CultureInfo.CurrentCulture; + + public override CultureInfo CurrentUICulture => CultureInfo.CurrentUICulture; + + public override void EnterNestedPrompt() => + throw new PSNotSupportedException("Nested PowerShell prompts are not supported by Verso."); + + public override void ExitNestedPrompt() => + throw new PSNotSupportedException("Nested PowerShell prompts are not supported by Verso."); + + public override void NotifyBeginApplication() + { + } + + public override void NotifyEndApplication() + { + } + + public override void SetShouldExit(int exitCode) + { + } +} diff --git a/src/Verso.PowerShell/Kernel/Host/VersoPowerShellHostUserInterface.cs b/src/Verso.PowerShell/Kernel/Host/VersoPowerShellHostUserInterface.cs new file mode 100644 index 0000000..77986f4 --- /dev/null +++ b/src/Verso.PowerShell/Kernel/Host/VersoPowerShellHostUserInterface.cs @@ -0,0 +1,356 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using System.Management.Automation; +using System.Management.Automation.Host; +using System.Security; +using System.Text; +using System.Text.RegularExpressions; + +namespace Verso.PowerShell.Kernel.Host; + +internal sealed partial class VersoPowerShellHostUserInterface : PSHostUserInterface +{ + private const string PlainText = "text/plain"; + private const string InteractiveInputMessage = "Interactive PowerShell input is not supported by Verso yet."; + + private readonly PowerShellHostOutputCallbackProvider _outputCallbackProvider; + private readonly PowerShellHostInputCallbackProvider _inputCallbackProvider; + private readonly VersoPowerShellRawUserInterface _rawUI = new(); + private readonly object _promptLock = new(); + + public VersoPowerShellHostUserInterface( + PowerShellHostOutputCallbackProvider outputCallbackProvider, + PowerShellHostInputCallbackProvider inputCallbackProvider) + { + _outputCallbackProvider = outputCallbackProvider ?? throw new ArgumentNullException(nameof(outputCallbackProvider)); + _inputCallbackProvider = inputCallbackProvider ?? throw new ArgumentNullException(nameof(inputCallbackProvider)); + } + + public override PSHostRawUserInterface RawUI => _rawUI; + + public override bool SupportsVirtualTerminal => false; + + public override string ReadLine() => ReadInput(string.Empty, isPassword: false); + + public override SecureString ReadLineAsSecureString() + { + var value = ReadInput(string.Empty, isPassword: true); + var secure = new SecureString(); + foreach (var ch in value) + secure.AppendChar(ch); + secure.MakeReadOnly(); + return secure; + } + + public override Dictionary Prompt( + string caption, + string message, + Collection descriptions) + { + ArgumentNullException.ThrowIfNull(descriptions); + + lock (_promptLock) + { + if (!string.IsNullOrEmpty(caption)) + WriteLine(caption); + if (!string.IsNullOrEmpty(message)) + WriteLine(message); + + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var description in descriptions) + { + if (description is null) + continue; + + var fieldType = ResolveFieldType(description); + object? value; + + if (fieldType == typeof(SecureString)) + { + value = ReadSecureString($"{description.Name}: "); + } + else if (fieldType == typeof(PSCredential)) + { + WriteLine($"{description.Name}:"); + value = PromptForCredential( + caption: string.Empty, + message: string.Empty, + userName: string.Empty, + targetName: string.Empty); + } + else + { + value = ReadAndConvert(description.Name, fieldType); + } + + result[description.Name] = PSObject.AsPSObject(value); + } + + return result; + } + } + + public override PSCredential PromptForCredential( + string caption, + string message, + string userName, + string targetName) => + PromptForCredential( + caption, + message, + userName, + targetName, + PSCredentialTypes.Default, + PSCredentialUIOptions.Default); + + public override PSCredential PromptForCredential( + string caption, + string message, + string userName, + string targetName, + PSCredentialTypes allowedCredentialTypes, + PSCredentialUIOptions options) + { + lock (_promptLock) + { + if (!string.IsNullOrEmpty(caption)) + WriteLine(caption); + if (!string.IsNullOrEmpty(message)) + WriteLine(message); + + while (string.IsNullOrEmpty(userName)) + userName = ReadInput("User: ", isPassword: false); + + var password = ReadSecureString($"Password for user {userName}: "); + return new PSCredential(userName, password); + } + } + + public override int PromptForChoice( + string caption, + string message, + Collection choices, + int defaultChoice) + { + ArgumentNullException.ThrowIfNull(choices); + if (choices.Count == 0) + throw new ArgumentException("At least one choice is required.", nameof(choices)); + if (defaultChoice < -1 || defaultChoice >= choices.Count) + throw new ArgumentOutOfRangeException(nameof(defaultChoice)); + + lock (_promptLock) + { + if (!string.IsNullOrEmpty(caption)) + WriteLine(caption); + if (!string.IsNullOrEmpty(message)) + WriteLine(message); + + var labels = BuildChoiceLabels(choices); + while (true) + { + WriteLine(BuildChoicePrompt(labels, defaultChoice)); + var response = ReadInput("Select: ", isPassword: false).Trim(); + + if (response.Length == 0 && defaultChoice >= 0) + return defaultChoice; + + if (response == "?") + { + for (var i = 0; i < choices.Count; i++) + WriteLine($"{labels[i].Key} - {choices[i].HelpMessage}"); + continue; + } + + for (var i = 0; i < labels.Count; i++) + { + if (string.Equals(response, labels[i].Key, StringComparison.OrdinalIgnoreCase) || + string.Equals(response, labels[i].Label, StringComparison.OrdinalIgnoreCase)) + { + return i; + } + } + } + } + } + + public override void Write(string value) + { + Emit(value); + } + + public override void Write(ConsoleColor foregroundColor, ConsoleColor backgroundColor, string value) + { + Emit(value); + } + + public override void WriteLine() + { + Emit(Environment.NewLine); + } + + public override void WriteLine(string value) + { + Emit(value); + } + + public override void WriteLine(ConsoleColor foregroundColor, ConsoleColor backgroundColor, string value) + { + Emit(value); + } + + public override void WriteErrorLine(string value) + { + Emit(value, isError: true, errorName: "PSError"); + } + + public override void WriteDebugLine(string message) + { + Emit($"DEBUG: {message}"); + } + + public override void WriteProgress(long sourceId, ProgressRecord record) + { + // Progress records are intentionally suppressed for now. Streaming every + // update would create noisy notebook output and needs a dedicated UI shape. + } + + public override void WriteVerboseLine(string message) + { + Emit($"VERBOSE: {message}"); + } + + public override void WriteWarningLine(string message) + { + Emit($"WARNING: {message}"); + } + + public override void WriteInformation(InformationRecord record) + { + // Write-Host reaches the host UI through Write/WriteLine. Emitting + // InformationRecord as well would duplicate the same visible output. + } + + private void Emit(string? content, bool isError = false, string? errorName = null) + { + if (string.IsNullOrEmpty(content)) + return; + + content = StripAnsiEscapeSequences(content); + if (string.IsNullOrEmpty(content)) + return; + + var callback = _outputCallbackProvider(); + if (callback is null) + return; + + var output = new PowerShellHostOutput(PlainText, content, isError, errorName); + callback(output).GetAwaiter().GetResult(); + } + + private string ReadInput(string prompt, bool isPassword) + { + var callback = _inputCallbackProvider(); + if (callback is null) + throw CreateInteractiveInputException(); + + var result = callback(new PowerShellHostInputRequest(prompt, isPassword)) + .GetAwaiter() + .GetResult(); + + if (result is null) + throw new OperationCanceledException("PowerShell input was cancelled."); + + return result; + } + + private SecureString ReadSecureString(string prompt) + { + var value = ReadInput(prompt, isPassword: true); + var secure = new SecureString(); + foreach (var ch in value) + secure.AppendChar(ch); + secure.MakeReadOnly(); + return secure; + } + + private static Type ResolveFieldType(FieldDescription description) + { + if (LanguagePrimitives.TryConvertTo(description.ParameterAssemblyFullName, out Type type) || + LanguagePrimitives.TryConvertTo(description.ParameterTypeFullName, out type)) + { + return type; + } + + return typeof(string); + } + + private object? ReadAndConvert(string fieldName, Type fieldType) + { + while (true) + { + var raw = ReadInput($"{fieldName}: ", isPassword: false); + try + { + return LanguagePrimitives.ConvertTo(raw, fieldType, CultureInfo.InvariantCulture); + } + catch (PSInvalidCastException ex) + { + WriteLine(ex.InnerException?.Message ?? ex.Message); + } + catch (Exception ex) + { + WriteLine(ex.Message); + } + } + } + + private static List<(string Key, string Label)> BuildChoiceLabels(Collection choices) + { + var result = new List<(string Key, string Label)>(); + foreach (var choice in choices) + { + var label = choice.Label; + var amp = label.IndexOf('&'); + string key; + if (amp >= 0 && amp + 1 < label.Length) + { + key = char.ToUpperInvariant(label[amp + 1]).ToString(); + label = label.Remove(amp, 1).Trim(); + } + else + { + key = label.Length > 0 ? char.ToUpperInvariant(label[0]).ToString() : string.Empty; + } + + result.Add((key, label)); + } + + return result; + } + + private static string BuildChoicePrompt(IReadOnlyList<(string Key, string Label)> choices, int defaultChoice) + { + var builder = new StringBuilder(); + for (var i = 0; i < choices.Count; i++) + { + if (i > 0) + builder.Append(' '); + builder.Append('[').Append(choices[i].Key).Append("] ").Append(choices[i].Label); + } + + builder.Append(" [?] Help"); + if (defaultChoice >= 0) + builder.Append(" (default is '").Append(choices[defaultChoice].Key).Append("')"); + + return builder.ToString(); + } + + private static string StripAnsiEscapeSequences(string value) => + AnsiEscapeSequenceRegex().Replace(value, string.Empty); + + [GeneratedRegex(@"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")] + private static partial Regex AnsiEscapeSequenceRegex(); + + private static PSNotSupportedException CreateInteractiveInputException() => + new(InteractiveInputMessage); +} diff --git a/src/Verso.PowerShell/Kernel/Host/VersoPowerShellRawUserInterface.cs b/src/Verso.PowerShell/Kernel/Host/VersoPowerShellRawUserInterface.cs new file mode 100644 index 0000000..0c4e108 --- /dev/null +++ b/src/Verso.PowerShell/Kernel/Host/VersoPowerShellRawUserInterface.cs @@ -0,0 +1,100 @@ +using System.Management.Automation; +using System.Management.Automation.Host; + +namespace Verso.PowerShell.Kernel.Host; + +internal sealed class VersoPowerShellRawUserInterface : PSHostRawUserInterface +{ + private ConsoleColor _backgroundColor = ConsoleColor.Black; + private Size _bufferSize = new(120, 3000); + private Coordinates _cursorPosition; + private int _cursorSize = 25; + private ConsoleColor _foregroundColor = ConsoleColor.Gray; + private Coordinates _windowPosition; + private Size _windowSize = new(120, 40); + private string _windowTitle = "Verso"; + + public override ConsoleColor BackgroundColor + { + get => _backgroundColor; + set => _backgroundColor = value; + } + + public override Size BufferSize + { + get => _bufferSize; + set => _bufferSize = value; + } + + public override Coordinates CursorPosition + { + get => _cursorPosition; + set => _cursorPosition = value; + } + + public override int CursorSize + { + get => _cursorSize; + set => _cursorSize = value; + } + + public override ConsoleColor ForegroundColor + { + get => _foregroundColor; + set => _foregroundColor = value; + } + + public override bool KeyAvailable => false; + + public override Size MaxPhysicalWindowSize => new(240, 80); + + public override Size MaxWindowSize => new(240, 80); + + public override Coordinates WindowPosition + { + get => _windowPosition; + set => _windowPosition = value; + } + + public override Size WindowSize + { + get => _windowSize; + set => _windowSize = value; + } + + public override string WindowTitle + { + get => _windowTitle; + set => _windowTitle = value; + } + + public override void FlushInputBuffer() + { + } + + public override BufferCell[,] GetBufferContents(Rectangle rectangle) + { + var width = Math.Max(0, rectangle.Right - rectangle.Left + 1); + var height = Math.Max(0, rectangle.Bottom - rectangle.Top + 1); + return new BufferCell[height, width]; + } + + public override KeyInfo ReadKey(ReadKeyOptions options) => + throw new PSNotSupportedException("Reading raw keyboard input is not supported by Verso."); + + public override void ScrollBufferContents( + Rectangle source, + Coordinates destination, + Rectangle clip, + BufferCell fill) + { + } + + public override void SetBufferContents(Coordinates origin, BufferCell[,] contents) + { + } + + public override void SetBufferContents(Rectangle rectangle, BufferCell fill) + { + } +} diff --git a/src/Verso.PowerShell/Kernel/PowerShellKernel.cs b/src/Verso.PowerShell/Kernel/PowerShellKernel.cs index f0745c5..b7a70ef 100644 --- a/src/Verso.PowerShell/Kernel/PowerShellKernel.cs +++ b/src/Verso.PowerShell/Kernel/PowerShellKernel.cs @@ -1,4 +1,5 @@ using Verso.Abstractions; +using Verso.PowerShell.Kernel.Host; namespace Verso.PowerShell.Kernel; @@ -72,9 +73,34 @@ public async Task> ExecuteAsync(string code, IExecutio _variableBridge!.InjectFromStore(context.Variables); } - var result = _runspaceManager!.Invoke(code, context.CancellationToken); var outputs = new List(); + Task AppendHostOutput(PowerShellHostOutput output) + { + var cellOutput = new CellOutput( + output.MimeType, + output.Content, + output.IsError, + output.ErrorName); + + outputs.Add(cellOutput); + return context.WriteOutputAsync(cellOutput); + } + + Task RequestHostInput(PowerShellHostInputRequest request) + { + return context.RequestInputAsync( + request.Prompt, + request.IsPassword, + context.CancellationToken); + } + + var result = _runspaceManager!.Invoke( + code, + context.CancellationToken, + AppendHostOutput, + RequestHostInput); + // Output stream (objects) if (result.OutputLines.Count > 0) { @@ -83,10 +109,13 @@ public async Task> ExecuteAsync(string code, IExecutio outputs.Add(new CellOutput(result.OutputMimeType, text)); } - // Information stream (Write-Host) + // Information stream (Write-Information) if (result.InformationLines.Count > 0) { - var text = string.Join(Environment.NewLine, result.InformationLines); + var informationLines = result.InformationLines + .Where(line => !HasMatchingPlainTextOutput(outputs, line)) + .ToList(); + var text = string.Join(Environment.NewLine, informationLines); if (!string.IsNullOrEmpty(text)) outputs.Add(new CellOutput("text/plain", text)); } @@ -120,6 +149,18 @@ public async Task> ExecuteAsync(string code, IExecutio } } + private static bool HasMatchingPlainTextOutput(IEnumerable outputs, string content) => + outputs.Any(output => + !output.IsError + && string.Equals(output.MimeType, "text/plain", StringComparison.OrdinalIgnoreCase) + && string.Equals( + NormalizePlainText(output.Content), + NormalizePlainText(content), + StringComparison.Ordinal)); + + private static string NormalizePlainText(string value) => + value.Replace("\r\n", "\n", StringComparison.Ordinal).TrimEnd('\r', '\n'); + public async Task> GetCompletionsAsync(string code, int cursorPosition) { ThrowIfDisposed(); diff --git a/src/Verso.PowerShell/Kernel/RunspaceManager.cs b/src/Verso.PowerShell/Kernel/RunspaceManager.cs index 18c3341..76b3f0e 100644 --- a/src/Verso.PowerShell/Kernel/RunspaceManager.cs +++ b/src/Verso.PowerShell/Kernel/RunspaceManager.cs @@ -5,6 +5,7 @@ using System.Net; using System.Text; using Verso.Abstractions; +using Verso.PowerShell.Kernel.Host; namespace Verso.PowerShell.Kernel; @@ -19,6 +20,8 @@ internal sealed record InvokeResult( internal sealed class RunspaceManager : IDisposable { private Runspace? _runspace; + private PowerShellHostOutputCallback? _currentHostOutput; + private PowerShellHostInputCallback? _currentHostInput; private bool _disposed; public void Initialize() @@ -33,11 +36,18 @@ public void Initialize() iss.ExecutionPolicy = Microsoft.PowerShell.ExecutionPolicy.RemoteSigned; } - _runspace = RunspaceFactory.CreateRunspace(iss); + var host = new VersoPowerShellHost( + () => _currentHostOutput, + () => _currentHostInput); + _runspace = RunspaceFactory.CreateRunspace(host, iss); _runspace.Open(); } - public InvokeResult Invoke(string code, CancellationToken ct) + public InvokeResult Invoke( + string code, + CancellationToken ct, + PowerShellHostOutputCallback? hostOutput = null, + PowerShellHostInputCallback? hostInput = null) { ThrowIfDisposed(); var runspace = _runspace ?? throw new InvalidOperationException("RunspaceManager not initialized."); @@ -59,6 +69,8 @@ public InvokeResult Invoke(string code, CancellationToken ct) var informationLines = new List(); Exception? exception = null; + _currentHostOutput = hostOutput; + _currentHostInput = hostInput; try { Collection results = ps.Invoke(); @@ -131,6 +143,9 @@ public InvokeResult Invoke(string code, CancellationToken ct) foreach (var info in ps.Streams.Information) { + if (IsPowerShellHostInformation(info)) + continue; + var msg = info.MessageData?.ToString(); if (!string.IsNullOrEmpty(msg)) informationLines.Add(msg); @@ -146,10 +161,18 @@ public InvokeResult Invoke(string code, CancellationToken ct) exception = ex; errorLines.Add(ex.Message); } + finally + { + _currentHostOutput = null; + _currentHostInput = null; + } return new InvokeResult(outputLines, outputMimeType, errorLines, warningLines, informationLines, exception); } + private static bool IsPowerShellHostInformation(InformationRecord record) => + record.Tags.Any(tag => string.Equals(tag, "PSHOST", StringComparison.OrdinalIgnoreCase)); + public void InjectDisplayFunction() { ThrowIfDisposed(); diff --git a/src/Verso.Testing/Stubs/StubExecutionContext.cs b/src/Verso.Testing/Stubs/StubExecutionContext.cs index 186b584..a39d048 100644 --- a/src/Verso.Testing/Stubs/StubExecutionContext.cs +++ b/src/Verso.Testing/Stubs/StubExecutionContext.cs @@ -19,6 +19,7 @@ public sealed class StubExecutionContext : IExecutionContext public Guid CellId { get; set; } = Guid.NewGuid(); public int ExecutionCount { get; set; } = 1; + public Func>? InputHandler { get; set; } public List WrittenOutputs { get; } = new(); public List DisplayedOutputs { get; } = new(); @@ -35,6 +36,17 @@ public Task DisplayAsync(CellOutput output) return Task.CompletedTask; } + public Task RequestInputAsync( + string prompt, + bool isPassword = false, + CancellationToken cancellationToken = default) + { + if (InputHandler is null) + throw new NotSupportedException("Interactive input is not supported by this host."); + + return InputHandler(prompt, isPassword, cancellationToken); + } + public List<(string OutputBlockId, CellOutput Output)> UpdatedOutputs { get; } = new(); public Task UpdateOutputAsync(string outputBlockId, CellOutput output) diff --git a/src/Verso/Contexts/ExecutionContext.cs b/src/Verso/Contexts/ExecutionContext.cs index c45ffe3..15177ef 100644 --- a/src/Verso/Contexts/ExecutionContext.cs +++ b/src/Verso/Contexts/ExecutionContext.cs @@ -9,6 +9,7 @@ namespace Verso.Contexts; public sealed class ExecutionContext : VersoContext, IExecutionContext { private readonly Func _display; + private readonly Func>? _requestInput; public ExecutionContext( Guid cellId, @@ -21,12 +22,14 @@ public ExecutionContext( INotebookMetadata notebookMetadata, INotebookOperations notebook, Func writeOutput, - Func display) + Func display, + Func>? requestInput = null) : base(variables, cancellationToken, theme, layoutCapabilities, extensionHost, notebookMetadata, notebook, writeOutput) { CellId = cellId; ExecutionCount = executionCount; _display = display ?? throw new ArgumentNullException(nameof(display)); + _requestInput = requestInput; } /// @@ -41,4 +44,16 @@ public Task DisplayAsync(CellOutput output) ArgumentNullException.ThrowIfNull(output); return _display(output); } + + /// + public Task RequestInputAsync( + string prompt, + bool isPassword = false, + CancellationToken cancellationToken = default) + { + if (_requestInput is null) + throw new NotSupportedException("Interactive input is not supported by this host."); + + return _requestInput(prompt, isPassword, cancellationToken); + } } diff --git a/src/Verso/Execution/ExecutionPipeline.cs b/src/Verso/Execution/ExecutionPipeline.cs index a4d4337..e7eeb15 100644 --- a/src/Verso/Execution/ExecutionPipeline.cs +++ b/src/Verso/Execution/ExecutionPipeline.cs @@ -26,6 +26,8 @@ internal sealed class ExecutionPipeline private readonly Func _resolveLanguageId; private readonly Func _getExecutionCount; private readonly Func _resolveMagicCommand; + private readonly Action? _notifyOutputUpdated; + private readonly Func>? _requestInput; public ExecutionPipeline( IVariableStore variables, @@ -38,7 +40,9 @@ public ExecutionPipeline( Func ensureInitialized, Func resolveLanguageId, Func getExecutionCount, - Func? resolveMagicCommand = null) + Func? resolveMagicCommand = null, + Action? notifyOutputUpdated = null, + Func>? requestInput = null) { _variables = variables; _theme = theme; @@ -51,6 +55,8 @@ public ExecutionPipeline( _resolveLanguageId = resolveLanguageId; _getExecutionCount = getExecutionCount; _resolveMagicCommand = resolveMagicCommand ?? (_ => null); + _notifyOutputUpdated = notifyOutputUpdated; + _requestInput = requestInput; } public async Task ExecuteAsync(CellModel cell, CancellationToken ct) @@ -153,6 +159,7 @@ Task AppendOutput(CellOutput output) cell.Outputs.Add(output); streamedOutputs.Add(output); } + _notifyOutputUpdated?.Invoke(cell.Id); return Task.CompletedTask; } @@ -232,7 +239,11 @@ Task AppendOutput(CellOutput output) _notebookMetadata, _notebook, writeOutput: AppendOutput, - display: AppendOutput); + display: AppendOutput, + requestInput: (prompt, isPassword, inputCt) => + _requestInput is null + ? throw new NotSupportedException("Interactive input is not supported by this host.") + : _requestInput(cell.Id, prompt, isPassword, inputCt == default ? ct : inputCt)); ct.ThrowIfCancellationRequested(); diff --git a/src/Verso/Scaffold.cs b/src/Verso/Scaffold.cs index 760e1c3..2125cd3 100644 --- a/src/Verso/Scaffold.cs +++ b/src/Verso/Scaffold.cs @@ -365,6 +365,18 @@ private async Task ResetAllKernelsAsync() ///
public event Action? OnCellExecuted; + /// + /// Raised when a cell receives output during execution before the cell has + /// completed. Remote front-ends use this to refresh running cell output. + /// + public event Action? OnCellOutputUpdated; + + /// + /// Optional front-end hook used by kernels that need a single line of user + /// input during execution, such as PowerShell Read-Host. + /// + public Func>? InputRequester { get; set; } + public async Task ExecuteCellAsync(Guid cellId, CancellationToken ct = default) { // Ensure parameter defaults are in the variable store before any cell runs @@ -688,6 +700,8 @@ private ExecutionPipeline BuildPipeline() EnsureInitialized, ResolveLanguageId, GetExecutionCount, - ResolveMagicCommand); + ResolveMagicCommand, + id => OnCellOutputUpdated?.Invoke(id), + InputRequester); } } diff --git a/tests/Verso.Blazor.Shared.Tests/Fakes/FakeNotebookService.cs b/tests/Verso.Blazor.Shared.Tests/Fakes/FakeNotebookService.cs index 8bdbad8..abf795c 100644 --- a/tests/Verso.Blazor.Shared.Tests/Fakes/FakeNotebookService.cs +++ b/tests/Verso.Blazor.Shared.Tests/Fakes/FakeNotebookService.cs @@ -104,6 +104,8 @@ public sealed class FakeNotebookService : INotebookService public Dictionary CellContainers { get; set; } = new(); public Dictionary CollapseInputMap { get; set; } = new(); public string? InteractionResponse { get; set; } + public Func>? ExecuteCellAsyncHandler { get; set; } + public Func>>? ExecuteAllAsyncHandler { get; set; } // ── File operations ──────────────────────────────────────────────── @@ -153,17 +155,27 @@ public Task InsertCellAsync(int index, string type = "code", string? public Task ClearAllOutputsAsync() => Task.CompletedTask; + public Task SetCellInputCollapsedAsync(Guid cellId, bool collapsed) => Task.CompletedTask; + + public Task SetCellOutputVisibilityAsync(Guid cellId, string visibility) => Task.CompletedTask; + // ── Execution ────────────────────────────────────────────────────── public Task ExecuteCellAsync(Guid cellId) { ExecutedCellIds.Add(cellId); + if (ExecuteCellAsyncHandler is not null) + return ExecuteCellAsyncHandler(cellId); + return Task.FromResult(new ExecutionResultDto(cellId, "ok", 1, TimeSpan.FromMilliseconds(42))); } public Task> ExecuteAllAsync() { ExecuteAllCallCount++; + if (ExecuteAllAsyncHandler is not null) + return ExecuteAllAsyncHandler(); + return Task.FromResult>(new List()); } diff --git a/tests/Verso.Blazor.Shared.Tests/NotebookInputDialogTests.cs b/tests/Verso.Blazor.Shared.Tests/NotebookInputDialogTests.cs new file mode 100644 index 0000000..aded5f9 --- /dev/null +++ b/tests/Verso.Blazor.Shared.Tests/NotebookInputDialogTests.cs @@ -0,0 +1,53 @@ +using Verso.Blazor.Components; +using Verso.Blazor.Services; + +namespace Verso.Blazor.Shared.Tests; + +[TestClass] +public sealed class NotebookInputDialogTests : BunitTestContext +{ + [TestMethod] + public void SubmitButton_InvokesSubmitWithCurrentValue() + { + string? submitted = null; + var request = new ServerInputRequest(Guid.NewGuid(), "Name:", IsPassword: false); + + var cut = RenderComponent(parameters => parameters + .Add(p => p.IsVisible, true) + .Add(p => p.Request, request) + .Add(p => p.OnSubmit, value => submitted = value)); + + cut.Find("input").Input("Ada"); + cut.Find("button.verso-modal-btn--primary").Click(); + + Assert.AreEqual("Ada", submitted); + } + + [TestMethod] + public void CancelButton_InvokesCancel() + { + var cancelled = false; + var request = new ServerInputRequest(Guid.NewGuid(), "Name:", IsPassword: false); + + var cut = RenderComponent(parameters => parameters + .Add(p => p.IsVisible, true) + .Add(p => p.Request, request) + .Add(p => p.OnCancel, () => cancelled = true)); + + cut.Find("button.verso-modal-btn--secondary").Click(); + + Assert.IsTrue(cancelled); + } + + [TestMethod] + public void PasswordRequest_RendersPasswordInput() + { + var request = new ServerInputRequest(Guid.NewGuid(), "Secret:", IsPassword: true); + + var cut = RenderComponent(parameters => parameters + .Add(p => p.IsVisible, true) + .Add(p => p.Request, request)); + + Assert.AreEqual("password", cut.Find("input").GetAttribute("type")); + } +} diff --git a/tests/Verso.Blazor.Shared.Tests/NotebookPageExecutionTests.cs b/tests/Verso.Blazor.Shared.Tests/NotebookPageExecutionTests.cs new file mode 100644 index 0000000..80e7686 --- /dev/null +++ b/tests/Verso.Blazor.Shared.Tests/NotebookPageExecutionTests.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Components.Web; +using Verso.Blazor.Components.Pages; + +namespace Verso.Blazor.Shared.Tests; + +[TestClass] +public sealed class NotebookPageExecutionTests : BunitTestContext +{ + [TestMethod] + public async Task RunButton_ReturnsBeforeExecutionCompletes() + { + TestContext!.JSInterop.Mode = JSRuntimeMode.Loose; + + var cell = new CellModel + { + Type = "code", + Language = "powershell", + Source = "Read-Host 'Name'" + }; + var executionStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseExecution = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var service = new FakeNotebookService + { + IsLoaded = true, + Cells = new List { cell }, + RegisteredLanguages = new List + { + new("powershell", "PowerShell") + }, + ExecuteCellAsyncHandler = _ => + { + executionStarted.TrySetResult(); + return releaseExecution.Task; + } + }; + TestContext.Services.AddSingleton(service); + + var cut = RenderComponent(); + var clickTask = cut.Find("button.verso-cell-btn--run").ClickAsync(new MouseEventArgs()); + + var completed = await Task.WhenAny(clickTask, Task.Delay(TimeSpan.FromSeconds(1))); + Assert.AreSame(clickTask, completed, "Run click should return control before cell execution completes."); + + await clickTask; + await WaitForAsync(executionStarted.Task, "Expected background execution to start."); + Assert.IsFalse(releaseExecution.Task.IsCompleted, "Test setup should still be holding execution open."); + + releaseExecution.SetResult(new ExecutionResultDto(cell.Id, "Success", 1, TimeSpan.Zero)); + } + + private static async Task WaitForAsync(Task task, string failureMessage) + { + var completed = await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(5))); + Assert.AreSame(task, completed, failureMessage); + await task; + } +} diff --git a/tests/Verso.Blazor.Shared.Tests/ServerNotebookServiceTests.cs b/tests/Verso.Blazor.Shared.Tests/ServerNotebookServiceTests.cs new file mode 100644 index 0000000..21aeccf --- /dev/null +++ b/tests/Verso.Blazor.Shared.Tests/ServerNotebookServiceTests.cs @@ -0,0 +1,185 @@ +using Microsoft.JSInterop; +using Verso.Blazor.Services; + +namespace Verso.Blazor.Shared.Tests; + +[TestClass] +public sealed class ServerNotebookServiceTests +{ + [TestMethod] + public async Task ExecuteCell_ForwardsLiveOutputUpdates() + { + await using var service = await CreateServiceAsync(); + var cell = await AddPowerShellCellAsync( + service, + "Write-Host 'before'\nStart-Sleep -Seconds 2\nWrite-Host 'after'"); + var outputUpdated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + service.OnOutputUpdated += () => outputUpdated.TrySetResult(); + + var execution = service.ExecuteCellAsync(cell.Id); + + await WaitForAsync(outputUpdated.Task, "Expected live output update while the cell was running."); + Assert.IsFalse(execution.IsCompleted, "Execution should still be running after the first live output update."); + + var result = await WaitForAsync(execution, "Expected execution to complete."); + Assert.AreEqual("Success", result.Status); + Assert.IsTrue(cell.Outputs.Any(o => o.Content.Contains("before")), "Expected streamed host output in cell outputs."); + } + + [TestMethod] + public async Task ExecuteCell_ReadHost_UsesServerInputRequester() + { + await using var service = await CreateServiceAsync(); + var cell = await AddPowerShellCellAsync( + service, + "$name = Read-Host 'Name'\nWrite-Host \"hello $name\""); + var inputRequested = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + service.OnInputRequested += () => + { + var request = service.PendingInputRequest; + if (request is null) + return; + + inputRequested.TrySetResult(request); + service.ResolveInputResult("Ada", cancelled: false); + }; + + var result = await WaitForAsync( + service.ExecuteCellAsync(cell.Id), + "Expected Read-Host execution to complete."); + var request = await WaitForAsync(inputRequested.Task, "Expected server input request."); + + Assert.AreEqual(cell.Id, request.CellId); + Assert.IsFalse(request.IsPassword); + Assert.IsTrue(request.Prompt.Contains("Name"), $"Expected Name prompt, got: {request.Prompt}"); + Assert.AreEqual("Success", result.Status); + Assert.IsTrue( + cell.Outputs.Any(o => o.Content.Contains("hello Ada")), + $"Expected supplied input in output, got: {string.Join(" | ", cell.Outputs.Select(o => o.Content))}"); + } + + [TestMethod] + public async Task ExecuteCell_ReadHostCancellation_CompletesAsCancelled() + { + await using var service = await CreateServiceAsync(); + var cell = await AddPowerShellCellAsync( + service, + "$name = Read-Host 'Name'\nWrite-Host \"hello $name\""); + var inputRequested = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + service.OnInputRequested += () => + { + var request = service.PendingInputRequest; + if (request is null) + return; + + inputRequested.TrySetResult(request); + service.ResolveInputResult(null, cancelled: true); + }; + + var result = await WaitForAsync( + service.ExecuteCellAsync(cell.Id), + "Expected cancelled Read-Host execution to complete."); + + await WaitForAsync(inputRequested.Task, "Expected server input request."); + Assert.IsTrue( + string.Equals(result.Status, "Cancelled", StringComparison.OrdinalIgnoreCase) + || cell.Outputs.Any(o => o.IsError), + $"Expected cancelled or error output shape, got status {result.Status} and outputs: {string.Join(" | ", cell.Outputs.Select(o => o.Content))}"); + } + + [TestMethod] + public async Task ExecuteCell_ReadHostAsSecureString_PropagatesPasswordFlag() + { + await using var service = await CreateServiceAsync(); + var cell = await AddPowerShellCellAsync( + service, + "$secret = Read-Host 'Secret' -AsSecureString\nWrite-Host 'done'"); + var inputRequested = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + service.OnInputRequested += () => + { + var request = service.PendingInputRequest; + if (request is null) + return; + + inputRequested.TrySetResult(request); + service.ResolveInputResult("s3cr3t", cancelled: false); + }; + + var result = await WaitForAsync( + service.ExecuteCellAsync(cell.Id), + "Expected secure Read-Host execution to complete."); + var request = await WaitForAsync(inputRequested.Task, "Expected server input request."); + + Assert.IsTrue(request.IsPassword, "Expected secure Read-Host to request password input."); + Assert.AreEqual("Success", result.Status); + Assert.IsTrue(cell.Outputs.Any(o => o.Content.Contains("done")), "Expected cell to continue after password input."); + } + + [TestMethod] + public async Task ExecuteCell_WriteInformation_ReturnsInformationOutput() + { + await using var service = await CreateServiceAsync(); + var cell = await AddPowerShellCellAsync( + service, + "Write-Information 'info' -InformationAction Continue"); + + var result = await WaitForAsync( + service.ExecuteCellAsync(cell.Id), + "Expected Write-Information execution to complete."); + + Assert.AreEqual("Success", result.Status); + Assert.IsTrue( + cell.Outputs.Any(o => o.Content.Contains("info")), + $"Expected information output, got: {string.Join(" | ", cell.Outputs.Select(o => o.Content))}"); + } + + private static async Task CreateServiceAsync() + { + var service = new ServerNotebookService(new ThrowingJSRuntime()); + await service.NewNotebookAsync(); + Assert.IsTrue( + service.RegisteredLanguages.Any(l => string.Equals(l.Id, "powershell", StringComparison.OrdinalIgnoreCase)), + "Expected PowerShell kernel to be registered."); + return service; + } + + private static async Task AddPowerShellCellAsync(ServerNotebookService service, string source) + { + var cell = await service.AddCellAsync("code", "powershell"); + await service.UpdateCellSourceAsync(cell.Id, source); + return cell; + } + + private static async Task WaitForAsync(Task task, string failureMessage) + { + var completed = await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(10))); + Assert.AreSame(task, completed, failureMessage); + await task; + } + + private static async Task WaitForAsync(Task task, string failureMessage) + { + var completed = await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(10))); + Assert.AreSame(task, completed, failureMessage); + return await task; + } + + private sealed class ThrowingJSRuntime : IJSRuntime + { + public ValueTask InvokeAsync(string identifier, object?[]? args) + => throw new InvalidOperationException($"Unexpected JS interop call: {identifier}"); + + public ValueTask InvokeAsync( + string identifier, + CancellationToken cancellationToken, + object?[]? args) + => throw new InvalidOperationException($"Unexpected JS interop call: {identifier}"); + } +} diff --git a/tests/Verso.Blazor.Shared.Tests/Verso.Blazor.Shared.Tests.csproj b/tests/Verso.Blazor.Shared.Tests/Verso.Blazor.Shared.Tests.csproj index 22a83d7..2b8b82b 100644 --- a/tests/Verso.Blazor.Shared.Tests/Verso.Blazor.Shared.Tests.csproj +++ b/tests/Verso.Blazor.Shared.Tests/Verso.Blazor.Shared.Tests.csproj @@ -19,8 +19,10 @@ + + diff --git a/tests/Verso.PowerShell.Tests/Kernel/ExecutionTests.cs b/tests/Verso.PowerShell.Tests/Kernel/ExecutionTests.cs index 3fcc996..7bbd5dc 100644 --- a/tests/Verso.PowerShell.Tests/Kernel/ExecutionTests.cs +++ b/tests/Verso.PowerShell.Tests/Kernel/ExecutionTests.cs @@ -48,6 +48,87 @@ public async Task WriteHost_CapturesInformationStream() var outputs = await _kernel.ExecuteAsync("Write-Host 'hello world'", _context); var allText = string.Join(" ", outputs.Select(o => o.Content)); Assert.IsTrue(allText.Contains("hello world"), $"Expected 'hello world', got: {allText}"); + Assert.AreEqual(1, outputs.Count(o => o.Content.Contains("hello world"))); + } + + [TestMethod] + public async Task WriteInformation_WithContinueAction_ReturnsInformationOutput() + { + var outputs = await _kernel.ExecuteAsync( + "Write-Information 'info message' -InformationAction Continue", + _context); + + var allText = string.Join(" ", outputs.Select(o => o.Content)); + Assert.IsTrue(allText.Contains("info message"), $"Expected 'info message', got: {allText}"); + Assert.AreEqual( + 1, + outputs.Count(o => o.Content.Contains("info message")), + $"Expected 'info message' once, got: {allText}"); + } + + [TestMethod] + public async Task WriteHost_StreamsBeforeCommandCompletes() + { + var execution = Task.Run(() => _kernel.ExecuteAsync( + "Write-Host 'before'\nStart-Sleep -Seconds 2\nWrite-Host 'after'", + _context)); + + var deadline = DateTimeOffset.UtcNow.AddSeconds(1); + while (DateTimeOffset.UtcNow < deadline && + !_context.WrittenOutputs.Any(o => o.Content.Contains("before"))) + { + await Task.Delay(25); + } + + Assert.IsTrue( + _context.WrittenOutputs.Any(o => o.Content.Contains("before")), + "Expected Write-Host output to be streamed before execution completed."); + Assert.IsFalse(execution.IsCompleted, "Execution should still be running after the first streamed output."); + + var outputs = await execution; + Assert.IsTrue(outputs.Any(o => o.Content.Contains("before"))); + Assert.IsTrue(outputs.Any(o => o.Content.Contains("after"))); + } + + [TestMethod] + public async Task WriteHost_StripsAnsiEscapeSequences() + { + var outputs = await _kernel.ExecuteAsync( + "Write-Host \"$([char]27)[93mcolored$([char]27)[0m\"", + _context); + + var allText = string.Join(" ", outputs.Select(o => o.Content)); + Assert.IsTrue(allText.Contains("colored"), $"Expected colored text, got: {allText}"); + Assert.IsFalse(allText.Contains("\u001b["), $"Did not expect ANSI escape sequences, got: {allText}"); + } + + [TestMethod] + public async Task ReadHost_UsesExecutionContextInput() + { + _context.InputHandler = (prompt, isPassword, ct) => + Task.FromResult("typed value"); + + var outputs = await _kernel.ExecuteAsync( + "$value = Read-Host 'enter value'\nWrite-Host \"value=$value\"", + _context); + + Assert.IsFalse(outputs.Any(o => o.IsError), "Did not expect an error output"); + var allText = string.Join(" ", outputs.Select(o => o.Content)); + Assert.IsTrue( + allText.Contains("value=typed value"), + $"Expected provided input in output, got: {allText}"); + } + + [TestMethod] + public async Task ReadHost_ReturnsUnsupportedInteractiveInputErrorWithoutInputHandler() + { + var outputs = await _kernel.ExecuteAsync("Read-Host 'enter value'", _context); + + Assert.IsTrue(outputs.Any(o => o.IsError), "Expected an error output"); + var allText = string.Join(" ", outputs.Select(o => o.Content)); + Assert.IsTrue( + allText.Contains("Interactive input is not supported by this host."), + $"Expected unsupported interactive input message, got: {allText}"); } [TestMethod] diff --git a/vscode/src/blazor/blazorBridge.ts b/vscode/src/blazor/blazorBridge.ts index 197e046..13e9f63 100644 --- a/vscode/src/blazor/blazorBridge.ts +++ b/vscode/src/blazor/blazorBridge.ts @@ -16,6 +16,7 @@ import { log } from "../log"; * * Some host notifications are handled by the bridge instead of forwarded: * - "file/download" — shows a save dialog and writes the file + * - "input/request" — asks VS Code for user input and replies to the host */ export class BlazorBridge implements vscode.Disposable { private readonly disposables: vscode.Disposable[] = []; @@ -23,6 +24,7 @@ export class BlazorBridge implements vscode.Disposable { "cell/executionState", "settings/changed", "variable/changed", + "output/update", "extension/consentRequest", "extension/changed", "layout/missing", @@ -116,6 +118,15 @@ export class BlazorBridge implements vscode.Disposable { const p = params as { kernelId?: string } | undefined; this.triggerRestart(p?.kernelId); }); + + this.host.onNotification("input/request", (params) => { + this.handleInputRequest(params).catch((err) => { + log.error(`input/request error: ${err instanceof Error ? err.message : String(err)}`); + vscode.window.showErrorMessage( + `Input request failed: ${err instanceof Error ? err.message : String(err)}` + ); + }); + }); } /** @@ -363,6 +374,44 @@ export class BlazorBridge implements vscode.Disposable { vscode.window.showInformationMessage(`Exported to ${uri.fsPath}`); } + /** + * Handle "input/request" notification — prompt through VS Code and reply + * directly to the host. The reply bypasses the host's sequential execution + * queue so a running execution can continue. + */ + private async handleInputRequest(params: unknown): Promise { + const p = params as + | { + notebookId?: string; + requestId?: string; + prompt?: string; + isPassword?: boolean; + } + | undefined; + + if (!p?.requestId) { + return; + } + + const notebookId = p.notebookId ?? this.notebookId; + if (!notebookId) { + throw new Error("No notebookId available for input response."); + } + + const value = await vscode.window.showInputBox({ + prompt: p.prompt || "PowerShell input", + password: !!p.isPassword, + ignoreFocusOut: true, + }); + + await this.host.sendRequest("input/response", { + notebookId, + requestId: p.requestId, + value: value ?? null, + cancelled: value === undefined, + }); + } + /** * Build file filter map from content type and file name. */ diff --git a/vscode/src/blazor/blazorEditorProvider.ts b/vscode/src/blazor/blazorEditorProvider.ts index af4498e..b34e15b 100644 --- a/vscode/src/blazor/blazorEditorProvider.ts +++ b/vscode/src/blazor/blazorEditorProvider.ts @@ -123,7 +123,7 @@ export class BlazorEditorProvider webview.options = { enableScripts: true, - localResourceRoots: [this.getWasmRoot()], + localResourceRoots: [this.context.extensionUri, this.getWasmRoot()], }; // Set the webview HTML loading the WASM app @@ -504,6 +504,9 @@ export class BlazorEditorProvider // Core WASM framework files const frameworkJs = toUri("_framework/blazor.webassembly.js"); + const frameworkBase = webview.asWebviewUri( + vscode.Uri.joinPath(wasmRoot, "_framework") + ).toString(); // Shared component static files const appCss = toUri("_content/Verso.Blazor.Shared/app.css"); @@ -679,7 +682,7 @@ export class BlazorEditorProvider