Terminal/SRxTerminalBoxControl.psm1

<#
    SRxTerminalBoxControl.psm1 (Terminal subsystem — Iteration 9)
    ------------------------------------------------------------------------
    Self-contained WinForms UserControl hosting a WebView2 (xterm.js) surface
    wired to a ConPTY-backed PowerShell child process (SRxConPty.psm1).

    MECHANICS (same convention as SRxPropertyBoxControl / SRxFilterBoxControl):
      - Host uses this by COMPOSITION + DYNAMIC DISPATCH (untyped field on the
        Explorer form). No 'using module'.
      - WinForms/Drawing must be loaded BEFORE this module is imported (same
        requirement as the other *Box controls).
      - Create via factory New-SRxTerminalBoxControl.
      - Import the logic modules (SRxConPty.psm1) INSIDE this module (scope
        isolation), exactly like SRxFilterBoxControl imports SRxFilterBox.psm1.
    ------------------------------------------------------------------------
    GOTCHA #1: PowerShell classes are compiled at PARSE TIME
    ------------------------------------------------------------------------
    ANY `[Namespace.TypeName]` bracket-literal written ANYWHERE inside the
    `class SRxTerminalBoxControl { ... }` body must already be resolvable the
    instant this module is Import-Module'd — even if buried in a method that
    is never called. Since WebView2's assemblies are only Add-Type'd lazily
    (the first time the panel opens - see Import-SRxWebView2Assemblies), any
    `[Microsoft.Web.WebView2.*]` bracket-literal anywhere in this class would
    make Import-Module fail immediately with "Unable to find type [...]".
    FIX: never write such a literal in this class. Use New-Object -TypeName
    '<string>' to construct instances, and resolve static
    types/methods/enums via reflection helpers (Get-SRxWebView2Type below)
    instead of bracket-literals.
    ------------------------------------------------------------------------
    GOTCHA #2: CoreWebView2EnvironmentOptions has no New-Object-friendly ctor
    ------------------------------------------------------------------------
    `New-Object -TypeName '...CoreWebView2EnvironmentOptions'` fails on some
    SDK builds ("A constructor was not found"). CreateAsync's 3rd parameter
    is optional/nullable per Microsoft's own docs/samples - we just pass
    $null (use-default-options) instead of constructing this object at all.
    ------------------------------------------------------------------------
    GOTCHA #3: [type]::GetType('Name, SimpleAssembly') can't see LoadFrom
               context assemblies
    ------------------------------------------------------------------------
    `Add-Type -Path <dll>` loads the assembly into .NET's "LoadFrom" binding
    context. `[type]::GetType('TypeName, SimpleAssemblyName')` internally
    calls `Assembly.Load(SimpleAssemblyName)`, which only searches the
    "Load" context - it does NOT see LoadFrom-context assemblies, even
    though they ARE fully loaded in the AppDomain.
    FIX: Get-SRxWebView2Type enumerates [AppDomain]::CurrentDomain.GetAssemblies()
    directly and calls .GetType(fullName) on the matching Assembly object.
    ------------------------------------------------------------------------
    GOTCHA #4: PowerShell scriptblock-as-delegate invoked from a background
               .NET thread can silently hang/fail
    ------------------------------------------------------------------------
    A PowerShell scriptblock cast to a .NET delegate (e.g. `[Action]{...}`)
    executes via ScriptBlock.Invoke() internally, which requires
    [Runspace]::DefaultRunspace to be set on the CALLING thread. Threads with
    no PowerShell runspace attached (a raw ThreadPool worker thread, or a
    plain background System.Threading.Thread) cannot reliably invoke such a
    delegate - the failure can be silent rather than a visible exception.
    FIX: never subscribe a PowerShell scriptblock directly to an event or
    Task continuation that fires on a background .NET thread. Instead, use
    a System.Windows.Forms.Timer that POLLS for completion/new data.
    Timer.Tick ALWAYS fires on the UI thread's own message loop.
      - CoreWebView2Environment.CreateAsync's Task is polled via
        _startupTimer (checks $task.IsCompleted).
      - ConPTY output/exit is polled via _ioTimer, draining
        $this._conPty.TryDequeueOutput(...) and checking $this._conPty.HasExited.
    ------------------------------------------------------------------------
    GOTCHA #5: CoreWebView2 (unlike the WinForms WebView2 Control and unlike
               System.Windows.Forms.Timer) has NO .Tag property at all
    ------------------------------------------------------------------------
    `$this._webView` (the WinForms CONTROL) inherits System.Windows.Forms.
    Control and has .Tag. `$this._startupTimer`/`$this._ioTimer` also have
    their own .Tag (Timer defines it directly). But `$this._webView.CoreWebView2`
    is a plain COM-wrapper class with NO .Tag property - setting it throws
    "The property 'Tag' cannot be found on this object". FIX: capture
    $control directly via a `.GetNewClosure()` scriptblock instead of
    round-tripping through a (non-existent) .Tag property on $core.
    ------------------------------------------------------------------------
    Header construction (close button / title) DELIBERATELY MIRRORS
    SRxPropertyBoxControl's own ToolStrip-based header exactly (confirmed
    against that module's real source): a System.Windows.Forms.ToolStrip
    (auto-sizing height, ImageScalingSize=(20,20) controls the RENDERED icon
    size) hosting a ToolStripLabel (title) + ToolStripButton (close, 24x24,
    DisplayStyle=Image, ImageScaling=SizeToFit) - NOT a plain Panel+Button.
    ------------------------------------------------------------------------
    ------------------------------------------------------------------------
    ITERATION 7 — RunCommand(): programmatic input from the Explorer host
    ------------------------------------------------------------------------
    Added to support Explorer's "Enable Terminal" redirect (onBeginCommand()):
    lets the HOST type a command into the terminal's already-running
    interactive shell.ps1 session, exactly as if the user had typed it. Since
    the ConPTY session may not exist yet the very first time this is called
    (WebView2/environment creation is still asynchronously in flight via
    _startupTimer, and the ConPTY session itself is only created once the
    xterm.js page signals 'ready' - see onWebMessage), pending text is queued
    in $_pendingCommands (a plain List[string], only ever touched from the UI
    thread - both RunCommand() and the flush point in startConPty() run on
    the UI thread, so no additional synchronization is needed) and flushed
    as soon as $this._conPty is assigned.
    ------------------------------------------------------------------------
    ITERATION 8 — RestartSession(): relaunch the ConPTY session in place,
                  WITHOUT tearing down WebView2/xterm.js
    ------------------------------------------------------------------------
    Added to support Explorer's "Enable Debug" toggle: when that value
    changes, any ALREADY-RUNNING Terminal session was started with the OLD
    -EnableDebug (and -RulesLibPath) flags baked into its StartupCommandLine
    - since each child powershell.exe process gets its own fresh
    $global:SRxEnv, there is no way for an already-running session to pick
    up the new value on its own. RestartSession() lets the host say "kill
    the current shell.ps1 process and start a fresh one using whatever
    StartupCommandLine is CURRENTLY set" - the host is expected to update
    .StartupCommandLine (via a freshly-computed value reflecting the new
    EnableDebug/RulesLibPath state) immediately BEFORE calling this method.
    Deliberately does NOT touch $this._webView, $this._startupTimer, or
    CoreWebView2 at all - WebView2/xterm.js stay fully alive and connected;
    only the ConPTY session + its I/O pump timer are torn down and recreated.
    This is both cheaper (no WebView2Environment re-creation, no re-navigate)
    and gives a smoother visual result than a full Shutdown()+Activate()
    cycle would.
    ------------------------------------------------------------------------
    ITERATION 9 — RestartSession() BUGFIX: race with Activate()'s own
                  first-time async startup chain caused a "black screen"
                  Terminal (confirmed via live testing)
    ------------------------------------------------------------------------
    CONFIRMED BUG: RestartSession()'s guard "if (-not $this._started) {
    return }" does NOT protect against the case where Activate() has JUST
    been called for the FIRST TIME and is still asynchronously in flight
    (WebView2 environment creation / CoreWebView2 init / Navigate() / the
    xterm.js page's own load+ready handshake can easily take several hundred
    ms to a few seconds) - because $this._started is set to $true as the
    VERY FIRST LINE of Activate(), long before any of that async work
    actually completes.

    If Explorer's "Enable Debug" toggle handler happens to call
    RestartSession() DURING that window (e.g. the async Rules Library
    refresh finishes around the same time the user opens the Terminal panel
    for the first time), RestartSession() saw $this._conPty as $null
    (Activate() hadn't reached startConPty() yet) and then called
    $this.startConPty(...) ITSELF, unconditionally, at its own tail -
    creating a REAL ConPTY-backed process EARLY, before WebView2/CoreWebView2
    even existed yet.

    pumpConPtyIo()'s own I/O timer then started polling immediately
    (startIoPump() runs from inside that premature startConPty() call) -
    and since TryDequeueOutput() removes each chunk from the queue
    REGARDLESS of whether "$this._webView -and $this._webView.CoreWebView2"
    is true, every early output chunk the shell prints on startup (its
    banner + first prompt) got silently DISCARDED, because CoreWebView2
    wasn't ready yet at that moment. By the time WebView2/xterm.js finally
    finished loading and sent their OWN {t:'ready'} signal, onWebMessage's
    call to startConPty() just no-op'd ("if ($this._conPty) { return }" -
    a session already existed from the premature call) - so the now-ready
    terminal UI never received ANY of that already-printed, already-
    discarded output. The process stayed alive and the app stayed
    responsive (hence a real PID, hence no crash) - it was just idly
    waiting for input into a UI that had never actually seen its own
    startup banner: a live process, a silent/black terminal.

    THE FIX: RestartSession() now only force-starts a NEW ConPTY session if
    one ALREADY EXISTED at the moment it was called. If none existed yet,
    that means Activate()'s own first-time async chain is still in flight -
    the correct behavior is to leave the just-updated StartupCommandLine in
    place and let that NATURAL onWebMessage('ready') path create the
    session itself, once xterm.js is genuinely ready to receive output -
    exactly the sequencing that already worked correctly before Iterations
    4/5 introduced Rules-Library-triggered RestartSession() calls that can
    now race against a control's very first activation.
    ------------------------------------------------------------------------
#>


Import-Module (Join-Path $PSScriptRoot 'SRxConPty.psm1') -DisableNameChecking -Force -ErrorAction SilentlyContinue

#region ---- WebView2 SDK bootstrap (lazy, tolerant of missing deployment) --
function Get-SRxLoadedAssembly {
    <#
        .SYNOPSIS
        Finds an already-loaded assembly by its simple name, by enumerating
        the current AppDomain directly (works regardless of Load/LoadFrom
        binding context - see GOTCHA #3 above).
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$SimpleName)
    return [System.AppDomain]::CurrentDomain.GetAssemblies() |
        Where-Object { $_.GetName().Name -eq $SimpleName } |
        Select-Object -First 1
}

function Import-SRxWebView2Assemblies {
    <#
        .SYNOPSIS
        Loads the Microsoft.Web.WebView2 Core/WinForms assemblies from
        <ModuleRoot>\Lib\WebView2 (idempotent). Returns $true on success.
        .NOTES
        The native WebView2Loader.dll (matching process bitness) must live in
        the SAME folder as these managed dlls, or somewhere on the process's
        native DLL search path. If the DLLs were downloaded via a browser,
        Windows Mark-of-the-Web will block loading them (HRESULT 0x80131515)
        until you run Unblock-File on all three.
    #>

    [CmdletBinding()]
    param(
        [string]$LibPath = (Join-Path $PSScriptRoot 'Lib\WebView2')
    )
    if (Get-SRxLoadedAssembly 'Microsoft.Web.WebView2.WinForms') {
        return $true
    }
    try {
        $core = Join-Path $LibPath 'Microsoft.Web.WebView2.Core.dll'
        $winf = Join-Path $LibPath 'Microsoft.Web.WebView2.WinForms.dll'
        if (-not (Test-Path $core) -or -not (Test-Path $winf)) {
            Write-Host "Import-SRxWebView2Assemblies - SDK assemblies not found under '$LibPath'."
            return $false
        }
        Add-Type -Path $core -ErrorAction Stop
        Add-Type -Path $winf -ErrorAction Stop
        return $true
    }
    catch {
        Write-Host "Import-SRxWebView2Assemblies - $($_.Exception.Message)"
        return $false
    }
}

function Get-SRxWebView2Type {
    <#
        .SYNOPSIS
        Resolves a Microsoft.Web.WebView2.* type by its short name. Never
        via a bracket-literal — safe to call from inside the
        SRxTerminalBoxControl CLASS body (see GOTCHA #1).
        .PARAMETER ShortName
        e.g. 'Core.CoreWebView2Environment' or 'Core.CoreWebView2HostResourceAccessKind'
        or 'WinForms.WebView2'
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$ShortName)
    $assemblySimpleName = if ($ShortName -like 'WinForms.*') { 'Microsoft.Web.WebView2.WinForms' } else { 'Microsoft.Web.WebView2.Core' }
    $fullTypeName = "Microsoft.Web.WebView2.$ShortName"

    $asm = Get-SRxLoadedAssembly $assemblySimpleName
    if ($asm) {
        $t = $asm.GetType($fullTypeName)
        if ($t) { return $t }
    }
    try { return [type]::GetType("$fullTypeName, $assemblySimpleName") } catch { return $null }
}
#endregion

class SRxTerminalBoxControl : System.Windows.Forms.UserControl {
    #--------------------------------------
    # public configuration surface (mirrors SRxPropertyBoxControl conventions)
    #--------------------------------------
    [string]$SourcePath = ""
    [hashtable]$Icons = @{}
    [System.Management.Automation.ScriptBlock]$OnStatus = $null   # param($text)
    [System.Management.Automation.ScriptBlock]$OnClose  = $null   # param() -- host unchecks its toggle button
    [string]$StartupCommandLine = "powershell.exe -NoLogo -NoExit"

    #--------------------------------------
    # internal state (kept UNTYPED on purpose — see GOTCHA #1 above: none of
    # these may ever be declared as [Microsoft.Web.WebView2.*] or
    # [SRx.Terminal.ConPtySession], or Import-Module fails immediately)
    #--------------------------------------
    hidden $_headerPanel   = $null   # System.Windows.Forms.ToolStrip
    hidden $_titleLabel    = $null   # System.Windows.Forms.ToolStripLabel
    hidden $_closeButton   = $null   # System.Windows.Forms.ToolStripButton
    hidden $_placeholder   = $null   # plain Label shown when WebView2/ConPTY unavailable
    hidden $_webView       = $null   # Microsoft.Web.WebView2.WinForms.WebView2 (dynamic)
    hidden $_conPty        = $null   # SRx.Terminal.ConPtySession (dynamic)
    hidden $_startupTimer  = $null   # System.Windows.Forms.Timer - polls CreateAsync's Task (GOTCHA #4)
    hidden $_ioTimer       = $null   # System.Windows.Forms.Timer - polls ConPTY output queue + exit (GOTCHA #4)
    hidden $_pendingCommands = $null # System.Collections.Generic.List[string] - queued RunCommand() text (Iteration 7)
    hidden [bool]$_started       = $false
    hidden [bool]$_webViewReady   = $false
    hidden [int]$_lastCols  = 80
    hidden [int]$_lastRows  = 24

    SRxTerminalBoxControl() : base() {
        $this._pendingCommands = New-Object 'System.Collections.Generic.List[string]'
        $this.buildLayout()
    }

    #--------------------------------------
    # Header construction DELIBERATELY MIRRORS SRxPropertyBoxControl's own
    # ToolStrip-based header exactly (see module header comment above for
    # the full rationale / confirmed property values).
    #--------------------------------------
    hidden [void] buildLayout() {
        $this.Dock = [System.Windows.Forms.DockStyle]::Fill

        $this._headerPanel = [System.Windows.Forms.ToolStrip]::new()
        $this._headerPanel.Dock = [System.Windows.Forms.DockStyle]::Top
        $this._headerPanel.GripStyle = [System.Windows.Forms.ToolStripGripStyle]::Hidden
        $this._headerPanel.BackColor = [System.Drawing.SystemColors]::ControlLight
        $this._headerPanel.ImageScalingSize = [System.Drawing.Size]::new(20, 20)
        # Cosmetic fix (supersedes the original "Height intentionally NOT
        # set" design note above): match the fixed 35px header-bar height
        # used by the left-hand tree panel's own top bar (Explorer.ps1's
        # _toolStripTree, hosted in a TableLayoutPanel row with
        # RowStyles.Item(0).Height=35). Left auto-sized, this header
        # rendered visibly shorter than that reference bar, once compared
        # side-by-side - Property Box's _title header gets this same fix.
        $this._headerPanel.AutoSize = $false
        $this._headerPanel.Height = 35

        $this._titleLabel = [System.Windows.Forms.ToolStripLabel]::new('Terminal')

        $this._closeButton = [System.Windows.Forms.ToolStripButton]::new()
        $this._closeButton.Alignment = [System.Windows.Forms.ToolStripItemAlignment]::Right
        $this._closeButton.DisplayStyle = [System.Windows.Forms.ToolStripItemDisplayStyle]::Image
        #$this._closeButton.AutoSize = $false
        # Cosmetic fix: widened from 24 to 29 to match the button sizing
        # convention used on the left-hand tree panel's own top bar
        # (Explorer.ps1's _buttonProperties/_buttonTerminal, both 29x24).
        $this._closeButton.Size = [System.Drawing.Size]::new(29, 24)
        $this._closeButton.ImageScaling = [System.Windows.Forms.ToolStripItemImageScaling]::SizeToFit
        $this._closeButton.AutoToolTip = $false
        $this._closeButton.Tag = $this
        $this._closeButton.add_Click({
            param($source, $e)
            $_this = $source.Tag
            if ($_this.OnClose) { $_this.OnClose.Invoke() }
        })

        [void]$this._headerPanel.Items.Add($this._titleLabel)
        [void]$this._headerPanel.Items.Add($this._closeButton)

        $this._placeholder = [System.Windows.Forms.Label]::new()
        $this._placeholder.Dock = [System.Windows.Forms.DockStyle]::Fill
        $this._placeholder.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter
        $this._placeholder.Text = "Terminal is starting..."
        $this._placeholder.Visible = $false

        [void]$this.Controls.Add($this._placeholder)
        [void]$this.Controls.Add($this._headerPanel)
    }

    #--------------------------------------
    # icons (Close 'X' image) - just assign the raw bitmap; the ToolStrip's
    # ImageScalingSize (set in buildLayout above) handles scaling the
    # rendered icon automatically, regardless of the source bitmap's native
    # resolution (e.g. the shared 30x30 icons8-multiply-30.png also used
    # elsewhere in Explorer.ps1).
    #--------------------------------------
    [void] ApplyIcons() {
        try {
            if ($this.Icons.ContainsKey('Close') -and $this.Icons['Close']) {
                $this._closeButton.Image = $this.Icons['Close']
            }
        } catch { Write-Host "SRxTerminalBoxControl.ApplyIcons - $($_.Exception.Message)" }
    }

    hidden [void] status([string]$text) {
        try { if ($this.OnStatus) { $this.OnStatus.Invoke($text) } } catch { }
    }

    #--------------------------------------
    # Activate() - call this each time the host makes the panel visible.
    # Lazily boots WebView2 + the ConPTY session on first activation; on
    # subsequent activations it just re-focuses the existing session.
    # Idempotent - safe to call repeatedly (RunCommand() below relies on this).
    #--------------------------------------
    [void] Activate() {
        if ($this._started) {
            try { if ($this._webView) { $this._webView.Focus() } } catch { }
            return
        }
        $this._started = $true
        $this.status("Starting terminal...")

        if (-not (Test-SRxConPtySupported)) {
            $this.showPlaceholder("Terminal unavailable - this Windows build does not support ConPTY (needs 1809+).")
            $this.status("Terminal unavailable (ConPTY unsupported).")
            return
        }
        if (-not (Import-SRxWebView2Assemblies)) {
            $this.showPlaceholder("Terminal unavailable - WebView2 SDK assemblies are not deployed. See Terminal\Lib\WebView2.")
            $this.status("Terminal unavailable (WebView2 SDK missing).")
            return
        }

        try {
            # 'Microsoft.Web.WebView2.WinForms.WebView2' below is a plain
            # STRING argument to New-Object, not a [bracket-literal] cast -
            # only resolved at runtime. Safe inside the class body (GOTCHA #1).
            $this._webView = New-Object -TypeName 'Microsoft.Web.WebView2.WinForms.WebView2'
            $this._webView.Dock = [System.Windows.Forms.DockStyle]::Fill
            [void]$this.Controls.Add($this._webView)
            $this._webView.BringToFront()

            $userDataFolder = Join-Path $(if ($this.SourcePath) { $this.SourcePath } else { $env:TEMP }) 'WebView2UserData'
            if (-not (Test-Path $userDataFolder)) { New-Item -ItemType Directory -Path $userDataFolder -Force | Out-Null }

            $envType = Get-SRxWebView2Type 'Core.CoreWebView2Environment'
            if (-not $envType) {
                $this.showPlaceholder("Terminal unavailable - CoreWebView2Environment type not found after loading WebView2 SDK.")
                return
            }
            # 3rd arg is $null (GOTCHA #2): CoreWebView2EnvironmentOptions'
            # constructor isn't reliably New-Object'able, and this param is
            # optional/nullable per Microsoft's own docs - $null = defaults.
            $task = $envType::CreateAsync($null, $userDataFolder, $null)

            # ---- poll for completion via a UI-thread Timer (GOTCHA #4) ----
            $this._startupTimer = New-Object System.Windows.Forms.Timer
            $this._startupTimer.Interval = 50
            $state = [pscustomobject]@{ Control = $this; Task = $task }
            $this._startupTimer.Tag = $state
            $this._startupTimer.add_Tick({
                param($source, $e)
                $ctx = $source.Tag
                $t = $ctx.Task
                if (-not $t.IsCompleted) { return }
                $source.Stop()
                $source.Dispose()
                $ctx.Control.clearStartupTimer()
                if ($t.IsFaulted -or -not $t.Result) {
                    $detail = ""
                    try { if ($t.Exception) { $detail = " ($($t.Exception.GetBaseException().Message))" } } catch { }
                    $ctx.Control.showPlaceholder("Terminal unavailable - WebView2 runtime not found or failed to initialize$detail. Install the Evergreen WebView2 Runtime.")
                    return
                }
                $ctx.Control.onEnvironmentReady($t.Result)
            })
            $this._startupTimer.Start()
        }
        catch {
            $this.showPlaceholder("Terminal failed to start: $($_.Exception.Message)")
            $this.status("Terminal start failed: $($_.Exception.Message)")
        }
    }

    hidden [void] clearStartupTimer() {
        $this._startupTimer = $null
    }

    hidden [void] onEnvironmentReady($environment) {
        try {
            $this._webView.add_CoreWebView2InitializationCompleted({
                param($source, $e)
                $ctrl = $source.Tag
                if (-not $e.IsSuccess) {
                    $ctrl.showPlaceholder("Terminal unavailable - CoreWebView2 initialization failed: $($e.InitializationException.Message)")
                    return
                }
                $ctrl.wireCoreWebView2()
            })
            $this._webView.Tag = $this
            $this._webView.EnsureCoreWebView2Async($environment) | Out-Null
        }
        catch { $this.showPlaceholder("Terminal failed to initialize: $($_.Exception.Message)") }
    }

    hidden [void] wireCoreWebView2() {
        try {
            $core = $this._webView.CoreWebView2
            $wwwPath = Join-Path $PSScriptRoot 'www'

            $kindType = Get-SRxWebView2Type 'Core.CoreWebView2HostResourceAccessKind'
            $allowKind = if ($kindType) { [Enum]::Parse($kindType, 'Allow') } else { 1 }  # 1 == Allow, documented enum value, last-resort fallback

            $core.SetVirtualHostNameToFolderMapping('srx-terminal.local', $wwwPath, $allowKind)

            # $core (CoreWebView2) has NO .Tag property (GOTCHA #5) - capture
            # $control directly via .GetNewClosure() instead.
            $control = $this
            $onWebMessage = {
                param($source, $e)
                try { $control.onWebMessage($e.TryGetWebMessageAsString()) } catch { Write-Host "onWebMessage - $($_.Exception.Message)" }
            }.GetNewClosure()
            $core.add_WebMessageReceived($onWebMessage)

            $this._webViewReady = $true
            $core.Navigate('https://srx-terminal.local/terminal.html')
            $this.status("Terminal ready.")
        }
        catch { $this.showPlaceholder("Terminal failed to wire CoreWebView2: $($_.Exception.Message)") }
    }

    #--------------------------------------
    # JS -> Host messages: {"t":"ready"} | {"t":"in","d":"..."} | {"t":"resize","cols":N,"rows":N}
    #--------------------------------------
    hidden [void] onWebMessage([string]$json) {
        if ([string]::IsNullOrEmpty($json)) { return }
        $msg = $null
        try { $msg = $json | ConvertFrom-Json -ErrorAction Stop } catch { return }
        switch ([string]$msg.t) {
            'ready' {
                $this.startConPty($this._lastCols, $this._lastRows)
            }
            'in' {
                if ($this._conPty -and $msg.d) { $this._conPty.Write([string]$msg.d) }
            }
            'resize' {
                $cols = [int]$msg.cols; $rows = [int]$msg.rows
                if ($cols -gt 0 -and $rows -gt 0) {
                    $this._lastCols = $cols; $this._lastRows = $rows
                    if ($this._conPty) { $this._conPty.Resize($cols, $rows) }
                }
            }
            default { }
        }
    }

    hidden [void] startConPty([int]$cols, [int]$rows) {
        if ($this._conPty) { return }
        try {
            $workDir = if ($this.SourcePath) { $this.SourcePath } else { $null }
            $this._conPty = Start-SRxConPtySession -CommandLine $this.StartupCommandLine -WorkingDirectory $workDir -Columns $cols -Rows $rows
            if (-not $this._conPty) {
                $this.status("Terminal: failed to start ConPTY session.")
                return
            }
            $this.status("Terminal session started (PID $($this._conPty.ProcessId)).")
            $this.startIoPump()

            #begin Terminal Host - Iteration 7
            # Flush any RunCommand() calls that arrived before the ConPTY
            # session actually existed (e.g. the very first Activate(), while
            # WebView2/environment creation was still in flight). Runs on the
            # UI thread (this method is only ever reached via the _ioTimer/
            # onWebMessage 'ready' path, both UI-thread), same thread
            # RunCommand() itself runs on - no extra synchronization needed.
            if ($this._pendingCommands -and $this._pendingCommands.Count -gt 0) {
                foreach ($pending in $this._pendingCommands) {
                    $this._conPty.Write($pending + "`r")
                }
                $this._pendingCommands.Clear()
            }
            #end Terminal Host - Iteration 7
        }
        catch {
            $this.status("Terminal: ConPTY start failed: $($_.Exception.Message)")
        }
    }

    #begin Terminal Host - Iteration 7
    #--------------------------------------
    # RunCommand() - lets the HOST (Explorer.onBeginCommand(), when "Enable
    # Terminal" is checked) type a command into this control's already-
    # running interactive session, exactly as if the user had typed it
    # themselves + pressed Enter. See module header comment for the queueing
    # rationale (ConPTY may not exist yet on the very first call).
    #--------------------------------------
    [void] RunCommand([string]$text) {
        if ([string]::IsNullOrEmpty($text)) { return }
        $this.Activate()   # idempotent: boots WebView2/ConPTY on first call; just re-focuses otherwise
        if ($this._conPty) {
            $this._conPty.Write($text + "`r")
        }
        else {
            # ConPTY not started yet - queue for startConPty() to flush once
            # the session object is actually assigned (see startConPty() above).
            if (-not $this._pendingCommands) { $this._pendingCommands = New-Object 'System.Collections.Generic.List[string]' }
            $this._pendingCommands.Add($text)
        }
    }
    #end Terminal Host - Iteration 7

    #begin RulesLibrary - Iteration 10 (Terminal-only Lib-update pipe support)
    #--------------------------------------
    # GetProcessId() - exposes the currently-running ConPTY session's actual
    # process ID to the host (Explorer.ps1's pushLibToTerminal()), which uses
    # it to compute the name of the fixed, per-process pipe
    # ("pipe-Terminal-Lib-<PID>") that this Terminal-hosted shell.ps1 process
    # itself listens on (see shell.ps1's own Terminal-only pipe listener).
    # Returns $null/0 if no session is running yet (Terminal never opened, or
    # WebView2's own async startup chain is still in flight) - the host is
    # expected to treat that as "nothing to push to", not an error.
    #--------------------------------------
    [int] GetProcessId() {
        if ($this._conPty) { return $this._conPty.ProcessId }
        return 0
    }
    #end RulesLibrary - Iteration 10

    #begin Terminal Host - Iteration 9 (bugfix - see module header ITERATION 9)
    #--------------------------------------
    # RestartSession() - relaunches JUST the ConPTY session (kills the old
    # shell.ps1 process, starts a fresh one) using whatever
    # $this.StartupCommandLine is set to AT THE MOMENT THIS IS CALLED -
    # BUT ONLY IF A SESSION ALREADY EXISTED. See module header (ITERATION 9)
    # for the full race-condition rationale: if no session existed yet, this
    # means Activate()'s own first-time async startup chain (WebView2/
    # CoreWebView2/xterm.js) is still in flight, and forcing an early
    # startConPty() here would race against it, causing the shell's initial
    # output to be silently discarded before the UI is ready to receive it
    # (the "black screen" bug). In that case we simply leave the freshly-
    # updated StartupCommandLine in place - the natural onWebMessage('ready')
    # path will create the session itself once xterm.js is genuinely ready.
    #
    # WebView2/xterm.js themselves are NEVER touched here either way - only
    # the ConPTY session + its I/O pump timer are ever torn down/recreated.
    #
    # The HOST is expected to update .StartupCommandLine to reflect the new
    # desired state (e.g. a freshly-computed -EnableDebug/-RulesLibPath
    # argument string) IMMEDIATELY BEFORE calling this method.
    #--------------------------------------
    [void] RestartSession() {
        if (-not $this._started) {
            # Terminal was never activated at all - nothing running to
            # restart. The eventual first Activate() call will use whatever
            # StartupCommandLine is set at THAT time, which already
            # reflects the caller's latest update - nothing further to do.
            return
        }

        # Capture BEFORE tearing anything down: did a REAL session already
        # exist? This is the key fix - see ITERATION 9 above.
        $hadExistingSession = ($null -ne $this._conPty)

        # Stop the I/O pump timer FIRST (it references the OLD $this._conPty
        # instance via pumpConPtyIo() - stop polling before that instance is
        # torn down, to avoid a Tick firing mid-teardown).
        try { if ($this._ioTimer) { $this._ioTimer.Stop(); $this._ioTimer.Dispose() } } catch { }
        $this._ioTimer = $null

        # Gracefully stop the OLD ConPTY session (same graceful-then-kill
        # pattern/timeout as Shutdown() uses) - if there wasn't one yet
        # (e.g. WebView2 still initializing when this was called), this is
        # simply a no-op.
        try { if ($this._conPty) { $this._conPty.Stop(1500) } } catch { }
        $this._conPty = $null

        # Any text queued via RunCommand() for the OLD session no longer
        # applies to a brand-new process/session - drop it rather than
        # silently replaying stale input into the new shell.
        try { if ($this._pendingCommands) { $this._pendingCommands.Clear() } } catch { }

        if (-not $hadExistingSession) {
            # No real session existed yet - Activate()'s own first-time
            # async chain is still in flight (or WebView2 failed to start
            # at all). Do NOT force-start a ConPTY session here - see
            # ITERATION 9 header comment for why that would race against
            # the natural startup sequence and silently drop the shell's
            # initial output. StartupCommandLine has already been updated
            # by the caller, so whenever the natural 'ready' handshake
            # does complete, it will use the CURRENT, correct value.
            $this.status("Terminal settings updated - will apply once the session starts.")
            return
        }

        $this.status("Restarting terminal session...")

        # Best-effort: ask xterm.js to clear its on-screen buffer so the new
        # session starts with a clean slate instead of appearing to continue
        # below the old session's final output. This message type ('clear')
        # is a NEW addition - if the currently-deployed terminal.html predates
        # it, the message is simply ignored client-side (this codebase's
        # terminal.html JS uses an if/else-if chain with no fallback branch,
        # so an unrecognized "t" value is a harmless no-op) - functionally
        # safe either way, this is a pure visual nicety.
        try {
            if ($this._webView -and $this._webView.CoreWebView2) {
                $payload = @{ t = 'clear' } | ConvertTo-Json -Compress
                $this._webView.CoreWebView2.PostWebMessageAsJson($payload)
            }
        } catch { }

        # A real session existed before - genuinely restart it now, using
        # whatever StartupCommandLine is CURRENTLY set (the caller is
        # expected to have already updated it) and the last known terminal
        # size. startConPty() also (re)starts the I/O pump timer internally.
        $this.startConPty($this._lastCols, $this._lastRows)
    }
    #end Terminal Host - Iteration 9

    hidden [void] pumpConPtyIo() {
        if (-not $this._conPty) { return }
        try {
            $chunk = $null
            $budget = 64  # drain at most N chunks per tick so the UI stays responsive under heavy output
            while ($budget -gt 0 -and $this._conPty.TryDequeueOutput([ref]$chunk)) {
                $budget--
                if ($chunk -and $this._webView -and $this._webView.CoreWebView2) {
                    # JSON int array so JS rebuilds an exact Uint8Array (no re-encoding of control bytes)
                    $ints = New-Object 'System.Collections.Generic.List[int]'
                    foreach ($b in $chunk) { [void]$ints.Add([int]$b) }
                    $payload = @{ t = 'out'; d = $ints } | ConvertTo-Json -Compress -Depth 4
                    $this._webView.CoreWebView2.PostWebMessageAsJson($payload)
                }
            }
            if ($this._conPty.HasExited) {
                $code = $this._conPty.LastExitCode
                $this.status("Terminal session ended (exit code $code).")
                try {
                    if ($this._webView -and $this._webView.CoreWebView2) {
                        $payload = @{ t = 'exit'; code = $code } | ConvertTo-Json -Compress
                        $this._webView.CoreWebView2.PostWebMessageAsJson($payload)
                    }
                } catch { }
                $this._conPty = $null
                if ($this._ioTimer) { $this._ioTimer.Stop(); $this._ioTimer.Dispose(); $this._ioTimer = $null }
            }
        }
        catch { Write-Host "pumpConPtyIo - $($_.Exception.Message)" }
    }

    #--------------------------------------
    # I/O pump: polls the ConPTY session's thread-safe output queue and exit
    # flag from a UI-thread Timer (GOTCHA #4).
    #--------------------------------------
    hidden [void] startIoPump() {
        if ($this._ioTimer) { return }
        $this._ioTimer = New-Object System.Windows.Forms.Timer
        $this._ioTimer.Interval = 30
        $this._ioTimer.Tag = $this
        $this._ioTimer.add_Tick({
            param($source, $e)
            $ctrl = $source.Tag
            $ctrl.pumpConPtyIo()
        })
        $this._ioTimer.Start()
    }

    hidden [void] showPlaceholder([string]$text) {
        try {
            if ($this._webView) { $this._webView.Visible = $false }
            $this._placeholder.Text = $text
            $this._placeholder.Visible = $true
            $this._placeholder.BringToFront()
        } catch { }
    }

    #--------------------------------------
    # Shutdown() - call from the host's FormClosed cleanup (mirrors
    # $this._propBox.Shutdown()) so no orphan powershell.exe or Timer survives.
    #--------------------------------------
    [void] Shutdown() {
        try { if ($this._startupTimer) { $this._startupTimer.Stop(); $this._startupTimer.Dispose() } } catch { }
        $this._startupTimer = $null
        try { if ($this._ioTimer) { $this._ioTimer.Stop(); $this._ioTimer.Dispose() } } catch { }
        $this._ioTimer = $null
        try { if ($this._conPty) { $this._conPty.Stop(1500) } } catch { }
        $this._conPty = $null
        try { if ($this._webView) { $this._webView.Dispose() } } catch { }
        $this._webView = $null
    }
}

function New-SRxTerminalBoxControl {
    [OutputType([object])]
    param()
    return [SRxTerminalBoxControl]::new()
}

Export-ModuleMember -Function New-SRxTerminalBoxControl, Import-SRxWebView2Assemblies, Get-SRxWebView2Type, Get-SRxLoadedAssembly