private/output/Show-StreamingOutput.ps1

function Show-StreamingOutput {
    <#
    .SYNOPSIS
        Streaming output window for async runs: live console, errors, warnings, results tabs.
    #>

    # Too big. Wants splitting into smaller functions.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$Executor,
        [string]$Title = 'Running...',
        [int]$Width = 900,
        [int]$Height = 600,
        [System.Windows.Window]$ParentWindow,
        [scriptblock]$Action,
        [hashtable]$Parameters,
        [hashtable[]]$ResultActions,
        [switch]$SingleSelect,
        [string[]]$LinkedVariables,
        [string[]]$LinkedFunctions,
        [string[]]$LinkedModules,
        [System.Collections.IDictionary]$LinkedVariableValues,
        [System.Collections.IDictionary]$LinkedFunctionDefinitions,
        [string[]]$Capture,
        [switch]$HideUntilContent,
        [switch]$NoWait,
        [switch]$ScrollToTop
    )

    # Debug output goes to console if the parent window was created with -Debug
    $debugEnabled = $false
    $currentSession = [PsUi.SessionManager]::Current
    if ($currentSession) { $debugEnabled = $currentSession.DebugMode }

    $writeDebug = {
        param([string]$Message)
        if ($debugEnabled) { [Console]::WriteLine("[DEBUG] $Message") }
    }

    & $writeDebug "Show-StreamingOutput started (Title='$Title', Width=$Width, Height=$Height)"
    & $writeDebug " HideUntilContent=$HideUntilContent, ResultActions=$($ResultActions.Count)"

    if (!$Executor -or !$Executor.PSObject.Methods['ExecuteAsync']) {
        & $writeDebug "Invalid executor (null or missing ExecuteAsync), returning"
        return
    }
    & $writeDebug "Executor validated (Type=$($Executor.GetType().Name))"

    $hydrationParams = @{
        LinkedVariableValues      = $LinkedVariableValues
        LinkedFunctionDefinitions = $LinkedFunctionDefinitions
        LinkedVariables           = $LinkedVariables
        LinkedFunctions           = $LinkedFunctions
        LinkedModules             = $LinkedModules
        DebugEnabled              = $debugEnabled
    }

    $hydrationResult = Register-VariableHydration @hydrationParams
    $varValues       = $hydrationResult.Variables
    $funcDefs        = $hydrationResult.Functions
    $capturedModules = $hydrationResult.Modules
    
    & $writeDebug "Hydration complete - Vars: $($varValues.Count), Funcs: $($funcDefs.Count), Modules: $($capturedModules.Count)"

    if ($debugEnabled -and $varValues.Count -gt 0) {
        $varNames = ($varValues.Keys | Select-Object -First 10) -join ', '
        & $writeDebug " Variables: $varNames$(if ($varValues.Count -gt 10) { '...' })"
    }

    if ($debugEnabled -and $funcDefs.Count -gt 0) {
        $funcNames = ($funcDefs.Keys | Select-Object -First 10) -join ', '
        & $writeDebug " Functions: $funcNames$(if ($funcDefs.Count -gt 10) { '...' })"
    }

    if ($debugEnabled -and $capturedModules.Count -gt 0) {
        & $writeDebug " Modules: $($capturedModules -join ', ')"
    }

    $colors = Get-ThemeColors

    if (!$colors) {
        $colors = @{
            WindowBg         = '#FFFFFF'
            WindowFg         = '#1A1A1A'
            ControlBg        = '#F3F3F3'
            ControlFg        = '#1A1A1A'
            Accent           = '#0078D4'
            Border           = '#D1D1D1'
            HeaderBackground = '#0078D4'
            HeaderForeground = '#FFFFFF'
        }
    }

    $customLogo = if ($currentSession) { $currentSession.CustomLogo } else { $null }
    $window = New-OutputWindow -Title $Title -Width $Width -Height $Height -ParentWindow $ParentWindow -Colors $colors -CustomLogo $customLogo

    $windowRefs  = $window.Tag
    $contentArea = $windowRefs.ContentArea

    # HideUntilContent keeps the window invisible until something actually arrives - good for quick async actions where a brief flash of "nothing yet" looks like a bug.
    $showWindowOnData = $HideUntilContent

    # Take ownership of dialog parenting so child Show-Ui* dialogs centre on this window.
    if ($currentSession) { $currentSession.ActiveDialogParent = $window }

    # Catch-all UnhandledException handler. Log and swallow so a stray throw on the UI thread doesn't tear the whole app down.
    $window.Dispatcher.add_UnhandledException({
        param($sender, $eventArgs)
        $ex = $eventArgs.Exception

        # SuspendStoppingPipeline NREs in UI-thread handlers - no parent pipeline to suspend.
        # Harmless (caught internally, execution proceeds) but it spams the console three or four times per event. Drop it here.
        $stack = ''

        if ($ex.InnerException -and $ex.InnerException.StackTrace) { $stack = $ex.InnerException.StackTrace }
        elseif ($ex.StackTrace) { $stack = $ex.StackTrace }

        if ($ex -is [System.NullReferenceException] -and $stack.Contains('SuspendStoppingPipeline')) {
            $eventArgs.Handled = $true
            return
        }

        if ($ex.InnerException -is [System.NullReferenceException] -and $stack.Contains('SuspendStoppingPipeline')) {
            $eventArgs.Handled = $true
            return
        }

        [Console]::WriteLine("[PsUi] Dispatcher exception: $($ex.Message)")
        if ($ex.InnerException) {
            [Console]::WriteLine("[PsUi] Inner exception: $($ex.InnerException.Message)")
            [Console]::WriteLine("[PsUi] Inner stack: $($ex.InnerException.StackTrace)")
        }
        else { [Console]::WriteLine("[PsUi] Stack: $($ex.StackTrace)") }

        $eventArgs.Handled = $true

    }.GetNewClosure())

    # Esc cancels the running async action - confirms first so a stray keystroke doesn't kill work.
    $executorRef = $Executor
    $window.add_PreviewKeyDown({
        param($sender, $eventArgs)
        if ($eventArgs.Key -eq [System.Windows.Input.Key]::Escape) {
            if ($executorRef -and $executorRef.IsRunning) {
                # Unpin temporarily so the confirm dialog doesn't open behind the window. Deferred via DispatcherTimer so the Topmost change settles before the modal grabs focus (Start-Sleep would freeze the UI).
                $wasPinned = $window.Topmost
                if ($wasPinned) { $window.Topmost = $false }

                $capturedWindow   = $window
                $capturedExec     = $executorRef
                $capturedWasPin   = $wasPinned
                $unpinTimer          = [System.Windows.Threading.DispatcherTimer]::new()
                $unpinTimer.Interval = [TimeSpan]::FromMilliseconds(50)
                $unpinTimer.Add_Tick({
                    $this.Stop()
                    $confirm = Show-UiConfirmDialog -Title "Cancel Operation" -Message "Are you sure you want to cancel the running task?"
                    if ($capturedWasPin) { $capturedWindow.Topmost = $true }

                    if ($confirm -and $capturedExec.IsRunning) {
                        [PsUi.KeyCaptureDialog]::CloseCurrentDialog()
                        $capturedExec.Cancel()
                    }
                }.GetNewClosure())
                $unpinTimer.Start()

                $eventArgs.Handled = $true
            }
        }
    }.GetNewClosure())

    $mainPanel = [System.Windows.Controls.DockPanel]::new()
    $contentArea.Child = $mainPanel

    # Status header sits below the title bar - holds the spinner/check icon and the script title.
    $statusHeader = [System.Windows.Controls.Border]@{
        Background = ConvertTo-UiBrush $colors.HeaderBackground
        Padding    = [System.Windows.Thickness]::new(16, 8, 16, 8)
        Tag        = 'HeaderBorder'
    }
    [System.Windows.Controls.DockPanel]::SetDock($statusHeader, 'Top')

    $statusPanel = [System.Windows.Controls.StackPanel]@{ Orientation = 'Horizontal' }

    $statusIndicatorResult = New-StatusIndicator -Colors $colors
    $statusIndicator = $statusIndicatorResult.Container
    $statusSpinner   = $statusIndicatorResult.Spinner
    $statusSuccess   = $statusIndicatorResult.Success
    $statusWarning   = $statusIndicatorResult.Warning
    [void]$statusPanel.Children.Add($statusIndicator)

    $headerTitle = [System.Windows.Controls.TextBlock]@{
        FontSize          = 16
        FontWeight        = 'SemiBold'
        Foreground        = ConvertTo-UiBrush $colors.HeaderForeground
        VerticalAlignment = 'Center'
        Text              = $Title
        Tag               = 'HeaderText'
    }
    [void]$statusPanel.Children.Add($headerTitle)

    # Subtitle shows whatever the script set on $host.UI.RawUI.WindowTitle, hidden until used.
    $headerSubtitle = [System.Windows.Controls.TextBlock]@{
        FontSize          = 12
        FontStyle         = 'Italic'
        Foreground        = ConvertTo-UiBrush $colors.SecondaryText
        VerticalAlignment = 'Center'
        Margin            = [System.Windows.Thickness]::new(8, 0, 0, 0)
        Visibility        = 'Collapsed'
        Tag               = 'HeaderSubtitle'
    }
    [void]$statusPanel.Children.Add($headerSubtitle)

    $statusHeader.Child = $statusPanel
    [void]$mainPanel.Children.Add($statusHeader)

    $progressPanelResult = New-ProgressPanel -Colors $colors
    $progressPanel      = $progressPanelResult.Panel
    $progressActivities = $progressPanelResult.Activities
    $defaultProgressUI  = $progressPanelResult.DefaultUI
    $progressBar        = $progressPanelResult.ProgressBar
    $createProgressUI   = $progressPanelResult.CreateProgressUI
    [System.Windows.Controls.DockPanel]::SetDock($progressPanel, 'Bottom')

    [void]$mainPanel.Children.Add($progressPanel)

    $tabControl = [System.Windows.Controls.TabControl]@{
        Margin          = [System.Windows.Thickness]::new(12)
        Background      = [System.Windows.Media.Brushes]::Transparent
        BorderBrush     = ConvertTo-UiBrush $colors.Border
        BorderThickness = [System.Windows.Thickness]::new(1)
    }

    # Spinner overlay covers the empty tab area until the first record lands.
    $loadingPanel = [System.Windows.Controls.Grid]@{
        Background = ConvertTo-UiBrush $colors.WindowBg
        Margin     = [System.Windows.Thickness]::new(12)
    }
    $loadingStack = [System.Windows.Controls.StackPanel]@{
        HorizontalAlignment = 'Center'
        VerticalAlignment   = 'Center'
    }
    $loadingSpinner = New-UiLoadingSpinner -Size 32 -Color $colors.Accent
    $loadingSpinner.Margin = [System.Windows.Thickness]::new(0, 0, 0, 12)
    [void]$loadingStack.Children.Add($loadingSpinner)
    $loadingLabel = [System.Windows.Controls.TextBlock]@{
        Text                = 'Running...'
        FontSize            = 14
        Foreground          = ConvertTo-UiBrush $colors.SecondaryText
        HorizontalAlignment = 'Center'
    }
    [void]$loadingStack.Children.Add($loadingLabel)
    [void]$loadingPanel.Children.Add($loadingStack)

    # Stack the tab control and the spinner overlay in a single cell so they overlap.
    $contentGrid = [System.Windows.Controls.Grid]::new()
    [void]$contentGrid.Children.Add($tabControl)
    [void]$contentGrid.Children.Add($loadingPanel)
    [System.Windows.Controls.Panel]::SetZIndex($loadingPanel, 100)

    [void]$mainPanel.Children.Add($contentGrid)

    $consoleResult                = New-ConsoleTabFull -Colors $colors
    $consoleTab                   = $consoleResult.Tab
    $consoleTextBox               = $consoleResult.TextBox
    $consoleParagraph             = $consoleResult.Paragraph
    $autoScrollCheckbox           = $consoleResult.AutoScrollCheckbox
    $pinToTopCheckbox             = $consoleResult.PinToTopCheckbox
    $consoleColorMap              = $consoleResult.ConsoleColorMap
    $rawColorMap                  = $consoleResult.RawColorMap
    $appendConsoleText            = $consoleResult.AppendConsoleText
    $appendState                  = $consoleResult.AppendState
    $consoleFindState             = $consoleResult.FindState
    $highlightRunMatches          = $consoleResult.HighlightRunMatches

    # Pin checkbox flips the window's Topmost - useful when running against a list of items in another window and the output needs to stay visible.
    $pinToTopCheckbox.Add_Checked({ $window.Topmost = $true }.GetNewClosure())
    $pinToTopCheckbox.Add_Unchecked({ $window.Topmost = $false }.GetNewClosure())

    [void]$tabControl.Items.Add($consoleTab)

    # Errors tab is built on first error - building upfront slows window show on every action.
    $errorsTabState = @{
        Built       = $false
        Tab         = $null
        Container   = $null
        DataGrid    = $null
        List        = $null
        TotalErrors = 0
    }

    $ensureErrorsTab = {
        if ($errorsTabState.Built) { return }
        $errorsTabState.Built = $true

        $result                   = New-ErrorsTab -Colors $colors
        $errorsTabState.Tab       = $result.Tab
        $errorsTabState.Container = $result.Container
        $errorsTabState.DataGrid  = $result.DataGrid
        $errorsTabState.List      = $result.List

        $detailsParams = @{
            Colors       = $colors
            Container    = $result.Container
            DataGrid     = $result.DataGrid
            ErrorsList   = $result.List
            CopyButton   = $result.CopyButton
            ExportButton = $result.ExportButton
        }
        Add-ErrorDetailsPanel @detailsParams

        [void]$tabControl.Items.Add($result.Tab)
    }.GetNewClosure()

    # Warnings tab same deal - lazy built on the first warning record.
    $warningsTabState = @{
        Built     = $false
        Tab       = $null
        TextBox   = $null
        Paragraph = $null
    }

    $ensureWarningsTab = {
        if ($warningsTabState.Built) { return }
        $warningsTabState.Built = $true

        $result                       = New-WarningsTabFull -Colors $colors
        $warningsTabState.Tab         = $result.Tab
        $warningsTabState.TextBox     = $result.TextBox
        $warningsTabState.Paragraph   = $result.Paragraph

        [void]$tabControl.Items.Add($result.Tab)
    }.GetNewClosure()

    # outputData is the flat list for the single type case. outputDataByType buckets results by type name when the script returns multiple types - drives the tab per type Results view.
    $outputData = [System.Collections.Generic.List[object]]::new()
    $outputDataByType = [ordered]@{}
    $warningCount = @{ Value = 0 }
    $state = @{
        IsCancelled          = $false
        WindowRevealed       = $false
        LoadingHidden        = $false
        DebugEnabled         = $debugEnabled
        IsAutoScrolling      = $false
        HostQueueTimer       = $null
        ConsoleFindState     = $consoleFindState
        HighlightRunMatches  = $highlightRunMatches
        ConsoleTextBox       = $consoleTextBox
        AutoScrollCheckbox   = $autoScrollCheckbox
    }

    # Drops the spinner overlay once content arrives. One shot via $state.LoadingHidden.
    $hideLoading = {
        if (!$state.LoadingHidden) {
            $state.LoadingHidden = $true
            $loadingPanel.Visibility = 'Collapsed'
        }
    }.GetNewClosure()

    # In HideUntilContent mode the window starts invisible - reveal it on the first record.
    $revealWindow = {
        if (!$state.WindowRevealed -and $showWindowOnData) {
            $state.WindowRevealed = $true

            # Route to the UI thread - AsyncExecutor events come from a background runspace.
            # Titlebar theming attaches via the Loaded event registered earlier.
            $window.Dispatcher.Invoke([Action]{
                $window.Show()
                $window.Activate()
            })

            Write-Verbose "Window revealed due to content"
        }
    }.GetNewClosure()

    # Unread badges per tab. Cleared on tab activation.
    $tabNotifications = @{
        Console  = @{
            TotalCount  = 0
            UnreadCount = 0
        }
        Errors   = @{
            TotalCount  = 0
            UnreadCount = 0
        }
        Warnings = @{
            TotalCount  = 0
            UnreadCount = 0
        }
    }

    # Tab switch clears that tab's unread badge - same UX as Slack/Discord channels.
    $tabControl.Add_SelectionChanged({
        param($sender, $eventArgs)
        $selectedTab = $sender.SelectedItem

        if ($selectedTab -eq $consoleTab) {
            $tabNotifications.Console.UnreadCount = 0
            $consoleTab.Header = "Console"
        }
        elseif ($errorsTabState.Tab -and $selectedTab -eq $errorsTabState.Tab) {
            $tabNotifications.Errors.UnreadCount = 0
            # TotalErrors, not List.Count - the New-ErrorsTab filter rebuilds List, so its Count reads as "matching the filter" midstream.
            $eCount = [int]$errorsTabState.TotalErrors
            $errorsTabState.Tab.Header = if ($eCount -gt 0) { "Errors ($eCount)" } else { "Errors" }
        }
        elseif ($warningsTabState.Tab -and $selectedTab -eq $warningsTabState.Tab) {
            $tabNotifications.Warnings.UnreadCount = 0
            $warningsTabState.Tab.Header = if ($warningCount.Value -gt 0) { "Warnings ($($warningCount.Value))" } else { "Warnings" }
        }
    }.GetNewClosure())

    # .Count, not truthiness - Match() returns an empty but truthy collection when the member is missing.
    if ($Executor.PSObject.Properties.Match('UiDispatcher').Count -gt 0) {
        $Executor.UiDispatcher = $window.Dispatcher
    }

    # Batched AsyncExecutor handlers - one invoke per record floods the UI thread under heavy output.
    $Executor.add_OnHostBatch({
        param([System.Collections.Generic.List[PsUi.HostOutputRecord]]$records)
        if ($state.IsCancelled) { return }
        if ($null -eq $records -or $records.Count -eq 0) { return }

        if ($HideUntilContent) { & $revealWindow }

        & $hideLoading
        if ($consoleTab.Visibility -ne 'Visible') {
            $consoleTab.Visibility   = 'Visible'
            $tabControl.SelectedItem = $consoleTab
        }

        # Skip per record scroll - one scroll at the end of the batch is enough.
        $lastIndex = $records.Count - 1
        for ($i = 0; $i -lt $records.Count; $i++) {
            $record     = $records[$i]
            $skipScroll = $i -lt $lastIndex
            [void](Add-OutputLine -Record $record -AppendFunc $appendConsoleText -ColorMap $consoleColorMap -RawColorMap $rawColorMap -State $appendState -SkipScroll:$skipScroll)
        }

        if ($tabControl.SelectedItem -ne $consoleTab) {
            $tabNotifications.Console.UnreadCount += $records.Count
            $consoleTab.Header = "Console (+$($tabNotifications.Console.UnreadCount))"
        }
    }.GetNewClosure())

    # Queue + poll instead of per event UI invokes - log parsers can emit thousands of lines a second and per event routing pegs the UI thread before output catches up. Pipeline mode does the same for large result sets, not just Write-Host floods.
    $Executor.UseQueueMode = $true
    $Executor.UsePipelineQueueMode = $true

    # 50ms tick - looks live without flooding the UI thread.
    $state.HostQueueTimer = [System.Windows.Threading.DispatcherTimer]::new()
    $state.HostQueueTimer.Interval = [TimeSpan]::FromMilliseconds(50)

    $state.HostQueueTimer.Add_Tick({
        if ($state.IsCancelled) { return }

        # 100 at a time keeps each tick fast enough that the UI never blocks visibly.
        $pipelineItems = $Executor.DrainPipelineQueue(100)
        $hadPipeline   = $null -ne $pipelineItems -and $pipelineItems.Count -gt 0
        if ($hadPipeline) {
            foreach ($item in $pipelineItems) {
                if ($null -eq $item) { continue }

                # ErrorRecords go to the Errors tab, not the Results grid.
                if ($item -is [System.Management.Automation.ErrorRecord]) {
                    & $ensureErrorsTab
                    $displayRecord = New-ErrorDisplayRecord -ErrorRecord $item
                    [void]$errorsTabState.List.Add($displayRecord)
                    $errorsTabState.TotalErrors = [int]$errorsTabState.TotalErrors + 1

                    if ($errorsTabState.Tab.Visibility -eq 'Collapsed') {
                        $errorsTabState.Tab.Visibility = 'Visible'
                    }

                    if ($tabControl.SelectedItem -ne $errorsTabState.Tab) {
                        $tabNotifications.Errors.UnreadCount++
                        $errorsTabState.Tab.Header = "Errors ($([int]$errorsTabState.TotalErrors)) +$($tabNotifications.Errors.UnreadCount)"
                    }
                    else {
                        $errorsTabState.Tab.Header = "Errors ($([int]$errorsTabState.TotalErrors))"
                    }
                    continue
                }

                # Bucket by type name. Multi type returns get a tab per bucket downstream.
                $displayName = Get-CleanTypeName -Item $item
                if (!$outputDataByType.Contains($displayName)) {
                    $outputDataByType[$displayName] = [System.Collections.Generic.List[object]]::new()
                }
                [void]$outputDataByType[$displayName].Add($item)
                [void]$outputData.Add([psobject]$item)
            }
        }

        $records = $Executor.DrainHostQueue(100)
        $hadHost = $null -ne $records -and $records.Count -gt 0

        # Both queues empty and the run is done; nothing left to poll.
        if (!$hadPipeline -and !$hadHost -and $state.ExecutorDone) {
            $state.HostQueueTimer.Stop()
            return
        }
        if (!$hadHost) { return }

        if ($HideUntilContent) { & $revealWindow }
        & $hideLoading
        if ($consoleTab.Visibility -ne 'Visible') {
            $consoleTab.Visibility   = 'Visible'
            $tabControl.SelectedItem = $consoleTab
        }

        foreach ($record in $records) {
            [void](Add-OutputLine -Record $record -AppendFunc $appendConsoleText -ColorMap $consoleColorMap -RawColorMap $rawColorMap -State $appendState -SkipScroll)
        }

        # One scroll at the end of the batch, not per record.
        if ($autoScrollCheckbox.IsChecked) {
            $state.IsAutoScrolling = $true
            $consoleTextBox.ScrollToEnd()
            $state.IsAutoScrolling = $false
        }

        if ($tabControl.SelectedItem -ne $consoleTab) {
            $tabNotifications.Console.UnreadCount += $records.Count
            $consoleTab.Header = "Console (+$($tabNotifications.Console.UnreadCount))"
        }
    }.GetNewClosure())

    $state.HostQueueTimer.Start()

    # Fallback per message handler for the rare case where queue mode isn't available.
    $Executor.add_OnHost({
        param($record)
        if ($state.DebugEnabled) { [Console]::WriteLine("DEBUG: OnHost fired - record type: " + $record.GetType().Name) }

        if ($state.IsCancelled) { return }

        $message   = $null
        $fgColor   = $null
        $bgColor   = $null
        $noNewLine = $false
        if ($record -is [PsUi.HostOutputRecord]) {
            $message   = $record.Message
            $fgColor   = $record.ForegroundColor
            $bgColor   = $record.BackgroundColor
            $noNewLine = $record.NoNewLine
        }
        else {
            $message = "$record"
        }

        if ($state.DebugEnabled) { [Console]::WriteLine("DEBUG: OnHost message: " + $message) }

        # Empty message = "just add a newline". IsNullOrWhiteSpace would eat valid space only output.
        if ([string]::IsNullOrEmpty($message)) {
            if (!$noNewLine) {
                & $appendConsoleText "`n" $null $null -NoNewLine -State $appendState
            }
            return
        }

        if ($HideUntilContent) { & $revealWindow }
        & $hideLoading
        if ($consoleTab.Visibility -ne 'Visible') {
            $consoleTab.Visibility = 'Visible'
            $tabControl.SelectedItem = $consoleTab
        }

        $cleanOutput = $message -replace '\x1b\[[0-9;]*m', ''
        $fgBrush = $null
        if ($null -ne $fgColor -and $consoleColorMap.ContainsKey($fgColor)) {
            $fgBrush = $consoleColorMap[$fgColor]
        }
        $bgBrush = $null
        if ($null -ne $bgColor -and $consoleColorMap.ContainsKey($bgColor)) {
            $bgBrush = $consoleColorMap[$bgColor]
        }

        # Direct append. The queue mode handler above usually beats this one to the records.
        if ($noNewLine) { & $appendConsoleText $cleanOutput $fgBrush $bgBrush -NoNewLine -State $appendState  }
        else {  & $appendConsoleText $cleanOutput $fgBrush $bgBrush -State $appendState }
    }.GetNewClosure())

    $Executor.add_OnError({
        param($errorRecord)
        try {
            if ($state.IsCancelled) { return }
            if ($null -eq $errorRecord) { return }

            if ($state.DebugEnabled) {
                $errMsg = if ($errorRecord.Message) { $errorRecord.Message } else { $errorRecord.ToString() }
                $errType = if ($errorRecord.Exception) { $errorRecord.Exception.GetType().Name } else { 'Unknown' }
                [Console]::WriteLine("[DEBUG] OnError: $errType - $errMsg")
                if ($errorRecord.ScriptStackTrace) { [Console]::WriteLine("[DEBUG] StackTrace: $($errorRecord.ScriptStackTrace -replace "`n", " -> ")")  }
            }

            if ($HideUntilContent) { & $revealWindow }

            & $hideLoading

            & $ensureErrorsTab

            if ($errorsTabState.Tab.Visibility -eq 'Collapsed') {
                $errorsTabState.Tab.Visibility = 'Visible'

                # Errors tab grabs focus only when nothing else is showing yet, otherwise the script's output should stay focused
                if ($consoleTab.Visibility -eq 'Collapsed') { $tabControl.SelectedItem = $errorsTabState.Tab }
            }

            $displayRecord = New-ErrorDisplayRecord -ErrorRecord $errorRecord
            [void]$errorsTabState.List.Add($displayRecord)
            $errorsTabState.TotalErrors = [int]$errorsTabState.TotalErrors + 1

            # Echo to the console tab too - red text so it stands out from regular output.
            $displayMessage = if ($errorRecord.Message) { $errorRecord.Message } else { $errorRecord.ToString() }
            & $appendConsoleText "[ERROR] $displayMessage" ([System.Windows.Media.Brushes]::IndianRed) $null -State $appendState

            if ($tabControl.SelectedItem -ne $errorsTabState.Tab) {
                $tabNotifications.Errors.UnreadCount++
                $errorsTabState.Tab.Header = "Errors ($([int]$errorsTabState.TotalErrors)) +$($tabNotifications.Errors.UnreadCount)"
            }
            else { $errorsTabState.Tab.Header = "Errors ($([int]$errorsTabState.TotalErrors))" }

            if ($tabControl.SelectedItem -ne $consoleTab) {
                $tabNotifications.Console.UnreadCount++
                $consoleTab.Header = "Console (+$($tabNotifications.Console.UnreadCount))"
            }
        }
        catch {
            # Eat any failure in the error handler itself - the handler crashing the window is worse than losing the diagnostic.
            [Console]::Error.WriteLine("[PsUi] OnError handler failed: $($_.Exception.Message)")
        }
    }.GetNewClosure())

    $Executor.add_OnWarning({
        param($warningMessage)
        if ($state.IsCancelled) { return }
        if ([string]::IsNullOrWhiteSpace($warningMessage)) { return }

        if ($HideUntilContent) { & $revealWindow }

        & $hideLoading

        & $ensureWarningsTab

        if ($warningsTabState.Tab.Visibility -eq 'Collapsed') {
            $warningsTabState.Tab.Visibility = 'Visible'
            # Same focus rule as the errors tab - only steal focus if nothing else is showing.
            $errorsTabVisible = $errorsTabState.Tab -and $errorsTabState.Tab.Visibility -ne 'Collapsed'
            if ($consoleTab.Visibility -eq 'Collapsed' -and !$errorsTabVisible) {
                $tabControl.SelectedItem = $warningsTabState.Tab
            }
        }

        # Warnings also stream to Console below, so the tab needs to be visible regardless.
        if ($consoleTab.Visibility -ne 'Visible') { $consoleTab.Visibility = 'Visible' }

        $warningCount.Value++
        $warningRun = [System.Windows.Documents.Run]::new("$warningMessage`n")
        [void]$warningsTabState.Paragraph.Inlines.Add($warningRun)
        $warningsTabState.TextBox.ScrollToEnd()
        & $appendConsoleText "[WARNING] $warningMessage" ([System.Windows.Media.Brushes]::DarkGoldenrod) $null -State $appendState

        if ($tabControl.SelectedItem -ne $warningsTabState.Tab) {
            $tabNotifications.Warnings.UnreadCount++
            $warningsTabState.Tab.Header = "Warnings ($($warningCount.Value)) +$($tabNotifications.Warnings.UnreadCount)"
        }
        else { $warningsTabState.Tab.Header = "Warnings ($($warningCount.Value))" }

        if ($tabControl.SelectedItem -ne $consoleTab) {
            $tabNotifications.Console.UnreadCount++
            $consoleTab.Header = "Console (+$($tabNotifications.Console.UnreadCount))"
        }
    }.GetNewClosure())

    $Executor.add_OnVerbose({
        param($verboseMessage)
        if ($state.IsCancelled) { return }
        if ([string]::IsNullOrWhiteSpace($verboseMessage)) { return }
        & $appendConsoleText "[VERBOSE] $verboseMessage" ([System.Windows.Media.Brushes]::Gray) $null -State $appendState
    }.GetNewClosure())

    # OnDebug goes straight to the real console, not the UI. For diagnosing the async machinery itself, where touching the UI would just hide the bug.
    $Executor.add_OnDebug({
        param($debugMessage)
        if ($state.IsCancelled) { return }
        if ([string]::IsNullOrWhiteSpace($debugMessage)) { return }
        if ($state.DebugEnabled) { [Console]::WriteLine("[DEBUG] $debugMessage") }
    }.GetNewClosure())

    $Executor.add_OnProgress({
        param($progressRecord)
        if ($state.IsCancelled) { return }
        if ($null -eq $progressRecord) { return }

        $actId    = $progressRecord.ActivityId
        $parentId = $progressRecord.ParentActivityId
        $isChild  = ($parentId -ge 0)

        # Completed records tear down the bar except ActivityId 0, which is permanent.
        if ($progressRecord.RecordType -eq [System.Management.Automation.ProgressRecordType]::Completed) {
            if ($progressActivities.ContainsKey($actId)) {
                if ($actId -ne 0) {
                    $ui = $progressActivities[$actId]
                    [void]$progressPanel.Children.Remove($ui.Container)
                    $progressActivities.Remove($actId)
                }
                else { $progressActivities[0].Active = $false }

                # Hide once no real activity remains. Count non default bars, not total, since the permanent Id 0 bar stays in the dictionary after it completes - but a mid run Id 0 has to hold the panel open too, or a secondary activity finishing first collapses it out from under the running default bar.
                $activeCount  = @($progressActivities.Keys | Where-Object { $_ -ne 0 }).Count
                $idZeroActive = $progressActivities.ContainsKey(0) -and $progressActivities[0].Active
                if ($activeCount -eq 0 -and !$idZeroActive) {
                    $progressPanel.Visibility = 'Collapsed'
                }
            }
            return
        }

        if (!$progressActivities.ContainsKey($actId)) {
            if ($actId -eq 0) {
                # ActivityId 0 reuses the prebuilt default bar instead of creating a new one.
                $progressActivities[0] = $defaultProgressUI
                [void]$progressPanel.Children.Add($defaultProgressUI.Container)
            }
            else {
                $newUI = & $createProgressUI $actId $isChild
                $progressActivities[$actId] = $newUI

                # Slot child activities under their parent so the visual hierarchy mirrors Write-Progress.
                if ($isChild -and $progressActivities.ContainsKey($parentId)) {
                    $parentIdx = $progressPanel.Children.IndexOf($progressActivities[$parentId].Container)
                    $progressPanel.Children.Insert($parentIdx + 1, $newUI.Container)
                }
                else {
                    [void]$progressPanel.Children.Add($newUI.Container)
                }
            }
        }

        $progressPanel.Visibility = 'Visible'

        if ($actId -eq 0) { $progressActivities[0].Active = $true }

        $ui = $progressActivities[$actId]

        if ($progressRecord.PercentComplete -ge 0) {
            $ui.Bar.IsIndeterminate = $false
            $ui.Bar.Value           = $progressRecord.PercentComplete
        }
        else { $ui.Bar.IsIndeterminate = $true }

        $statusParts = [System.Collections.Generic.List[string]]::new()

        if (![string]::IsNullOrWhiteSpace($progressRecord.Activity)) { $statusParts.Add($progressRecord.Activity) }

        if (![string]::IsNullOrWhiteSpace($progressRecord.StatusDescription)) { $statusParts.Add($progressRecord.StatusDescription) }

        if (![string]::IsNullOrWhiteSpace($progressRecord.CurrentOperation)) { $statusParts.Add("($($progressRecord.CurrentOperation))") }

        if ($progressRecord.SecondsRemaining -gt 0) {
            $remaining = [TimeSpan]::FromSeconds($progressRecord.SecondsRemaining)
            if ($remaining.TotalHours -ge 1) {
                $statusParts.Add("[{0:h\:mm\:ss} remaining]" -f $remaining)
            }
            else { $statusParts.Add("[{0:m\:ss} remaining]" -f $remaining) }
        }

        $ui.Label.Text = $statusParts -join " - "

        # Top level activities also drive the window title so it's readable from the taskbar.
        if (!$isChild) { $headerTitle.Text = "$Title - $($progressRecord.StatusDescription)" }
    }.GetNewClosure())

    $Executor.add_OnPipelineOutput({
        param($pipelineObject)
        if ($state.IsCancelled) { return }
        if ($null -eq $pipelineObject) { return }

        if ($outputData.Count -eq 0) {
            if ($state.DebugEnabled) { [Console]::WriteLine("[DEBUG] First pipeline output: $($pipelineObject.GetType().FullName)") }
        }
        elseif ($state.DebugEnabled -and ($outputData.Count % 100) -eq 0) {
            [Console]::WriteLine("[DEBUG] Pipeline output count: $($outputData.Count)")
        }

        # Same bucketing as the queue mode path - tab per type Results view.
        $displayName = Get-CleanTypeName -Item $pipelineObject
        if (!$outputDataByType.Contains($displayName)) {
            if ($state.DebugEnabled) { [Console]::WriteLine("[DEBUG] New result type bucket: $displayName") }
            $outputDataByType[$displayName] = [System.Collections.Generic.List[object]]::new()
        }
        [void]$outputDataByType[$displayName].Add($pipelineObject)
        [void]$outputData.Add([psobject]$pipelineObject)
    }.GetNewClosure())

    # Hooks the blocking input host APIs (Read-Host / Get-Credential / $host.UI.Prompt) to themed dialogs so a UI app doesn't hang on console input that never arrives.
    $inputParams = @{
        Executor        = $Executor
        Window          = $window
        ClearHostAction = { $consoleParagraph.Inlines.Clear() }.GetNewClosure()
        DebugEnabled    = $debugEnabled
    }
    Add-InputProviders @inputParams

    # The completion handler context. Everything Invoke-OnCompleteHandler needs to swap the window from "running" to "done" lives in here so the callback closure stays small.
    $onCompleteContext = @{
        Executor            = $Executor
        State               = $state
        OutputData          = $outputData
        OutputDataByType    = $outputDataByType
        ConsoleColorMap     = $consoleColorMap
        RawColorMap         = $rawColorMap
        AppendConsoleText   = $appendConsoleText
        AppendState         = $appendState
        ConsoleParagraph    = $consoleParagraph
        ConsoleTextBox      = $consoleTextBox
        ConsoleTab          = $consoleTab
        AutoScrollCheckbox  = $autoScrollCheckbox
        ErrorsTabState      = $errorsTabState
        EnsureErrorsTab     = $ensureErrorsTab
        WarningsTabState    = $warningsTabState
        EnsureWarningsTab   = $ensureWarningsTab
        WarningCount        = $warningCount
        TabControl          = $tabControl
        TabNotifications    = $tabNotifications
        Window              = $window
        HideLoading         = $hideLoading
        LoadingPanel        = $loadingPanel
        LoadingSpinner      = $loadingSpinner
        LoadingStack        = $loadingStack
        LoadingLabel        = $loadingLabel
        ProgressBar         = $progressBar
        ProgressPanel       = $progressPanel
        HeaderTitle         = $headerTitle
        Title               = $Title
        HideUntilContent    = $HideUntilContent
        StatusSpinner       = $statusSpinner
        StatusSuccess       = $statusSuccess
        StatusWarning       = $statusWarning
        StatusIndicator     = $statusIndicator
        Colors              = $colors
        ResultActions       = $ResultActions
        SingleSelect        = $SingleSelect
        VarValues           = $varValues
        FuncDefs            = $funcDefs
        CapturedModules     = $capturedModules
        DefaultProgressUI   = $defaultProgressUI
        ParentWindow        = $ParentWindow
        ScrollToTop         = $ScrollToTop
    }
    & $writeDebug "OnComplete context prepared, hooking events..."

    # Script can set window title via $host.UI.RawUI.WindowTitle
    $Executor.add_OnWindowTitle({
        param($title)
        if ($state.IsCancelled) { return }
        if ([string]::IsNullOrWhiteSpace($title)) {
            $headerSubtitle.Visibility = 'Collapsed'
        }
        else {
            $headerSubtitle.Text       = "- $title"
            $headerSubtitle.Visibility = 'Visible'
        }
    }.GetNewClosure())

    $Executor.add_OnComplete({
        & $writeDebug "OnComplete handler fired"
        $state.ExecutorDone = $true
        Invoke-OnCompleteHandler -Context $onCompleteContext
    }.GetNewClosure())

    # Cancellation flips the spinner to a warning icon and drops a coloured message into the console so it's clear something stopped the script - not just that it returned nothing.
    $Executor.add_OnCancelled({
        & $writeDebug "OnCancelled handler fired"
        $state.ExecutorDone = $true

        $statusSpinner.Visibility = 'Collapsed'
        $statusWarning.Visibility = 'Visible'
        $headerTitle.Text         = "$Title - Cancelled"

        $progressPanel.Visibility = 'Collapsed'

        $cancelPara = [System.Windows.Documents.Paragraph]::new()
        $cancelRun  = [System.Windows.Documents.Run]::new("Operation cancelled by user")
        $cancelRun.Foreground = [System.Windows.Media.Brushes]::Orange
        [void]$cancelPara.Inlines.Add($cancelRun)
        [void]$consoleTextBox.Document.Blocks.Add($cancelPara)
        $consoleTextBox.ScrollToEnd()
    }.GetNewClosure())

    $capturedSession = $currentSession

    $window.Add_Closing({
        param($sender, $eventArgs)

        # If a script is still running, confirm before closing - and keep any open Read-Host dialog alive so Esc inside that dialog still cancels normally.
        if ($Executor.IsRunning) {
            # Drop Topmost first so the confirm dialog doesn't open behind this window.
            $wasPinned = $window.Topmost
            if ($wasPinned) { $window.Topmost = $false }

            $confirm = Show-UiConfirmDialog -Title "Cancel Operation" -Message "A task is still running. Cancel and close?"
            if (!$confirm) {
                if ($wasPinned) { $window.Topmost = $true }
                $eventArgs.Cancel = $true
                [PsUi.KeyCaptureDialog]::BringToFront()
                return
            }
        }

        # Past the confirm gate - tear down any open Read-Host / Get-Credential dialog so the script's blocking input call returns and the runspace can wind down.
        [PsUi.KeyCaptureDialog]::CancelInputSession()
        [PsUi.KeyCaptureDialog]::CloseCurrentDialog()

        & $writeDebug "Window closing..."

        # Skip the cancel call on a finished script - otherwise the cancelled UI overlays on top of a successful completion, which reads like a fake error.
        if ($Executor.IsRunning) {
            $state.IsCancelled = $true
            $Executor.Cancel()
        }

        # Hand dialog parenting back to whoever had it. Otherwise the next dialog tries to centre on a closed window and ends up off screen.
        if ($capturedSession -and $capturedSession.ActiveDialogParent -eq $window) {  $capturedSession.ActiveDialogParent = $null   }

        # DispatcherTimer keeps a strong reference to its tick handler - leak the window without an explicit Stop() and the closure pins everything it captured.
        if ($state.HostQueueTimer -and $state.HostQueueTimer.IsEnabled) {  $state.HostQueueTimer.Stop()  }

        # Drop the UiDispatcher reference - otherwise late OnHost events from the runspace try to route back to a dead window and throw.
        if ($Executor.PSObject.Properties.Match('UiDispatcher').Count -gt 0) { $Executor.UiDispatcher = $null  }

        if ($Executor.PSObject.Methods.Match('Dispose').Count -gt 0) {
            try { $Executor.Dispose() } catch { Write-Debug "Suppressed dispose error: $_" }
        }
    }.GetNewClosure())

    # HideUntilContent path: kick off execution now, only show the window if data shows up.
    if ($showWindowOnData) {
        & $writeDebug "HideUntilContent mode - starting execution without showing window"

        if ($Capture) {  $Executor.CaptureVariables = [string[]]$Capture }

        # Don't wait for window load - in this mode the window won't load until data shows up.
        if ($Action) {
            & $writeDebug "Starting ExecuteAsync - Action: $($Action.ToString().Length) chars"
            try {
                $Executor.ExecuteAsync($Action, $Parameters, $varValues, $funcDefs, $capturedModules, $debugEnabled)
                & $writeDebug "ExecuteAsync called successfully"
            }
            catch {
                & $writeDebug "ExecuteAsync FAILED: $($_.Exception.Message)"
                Write-Verbose "Failed to start async execution: $_"
            }
        }

        # Keep dispatching messages until the script's done AND the window's been closed (if it was ever shown). Exit out early on cancellation (usually the window closing itself)
        while (!$state.IsCancelled -and ($Executor.IsRunning -or ($state.WindowRevealed -and $window.IsVisible))) {
            [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action]{ } )
            Start-Sleep -Milliseconds 10
        }

        # Final check. Scripts that finish faster than the 50ms tick leave output sitting in the queues. Peek the counts only, the OnComplete drain owns the queues, and draining them here threw the pipeline items away (a fast object only action revealed an empty window with no Results tab).
        if (!$state.WindowRevealed) {
            $hasOutput = ($Executor.HostQueueCount -gt 0) -or ($Executor.PipelineQueueCount -gt 0)

            if ($hasOutput) {
                $state.WindowRevealed = $true

                & $hideLoading
                $consoleTab.Visibility = 'Visible'
                $tabControl.SelectedItem = $consoleTab

                # Header flip is cosmetic, so skip it if the template's already down
                try {
                    $statusSpinner.Visibility = 'Collapsed'
                    $statusSuccess.Visibility = 'Visible'
                    $headerTitle.Text         = "$Title - Complete"
                }
                catch { }

                $window.Opacity = 1
                $window.Show()
                $window.Activate()

                try {
                    $currentColors = Get-ThemeColors
                    $headerBg      = [System.Windows.Media.ColorConverter]::ConvertFromString($currentColors.HeaderBackground)
                    $headerFg      = [System.Windows.Media.ColorConverter]::ConvertFromString($currentColors.HeaderForeground)
                    [PsUi.WindowManager]::SetTitleBarColor($window, $headerBg, $headerFg)
                }
                catch { Write-Debug "Title bar theming failed: $_" }

                # Block until the window closes - same dispatch loop as the main one, just slower tick.
                while (!$state.IsCancelled -and $window.IsVisible) {
                    [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke(
                        [System.Windows.Threading.DispatcherPriority]::Background,
                        [Action]{ }
                    )
                    Start-Sleep -Milliseconds 50
                }
            }
        }

        # Never revealed window still holds its runspace + UI thread. Close it explicitly so they release.
        if (!$state.WindowRevealed) {
            & $writeDebug "No output produced - closing hidden window to prevent leak"
            # Close can race a window that's already torn down - nothing left to do then.
            try { $window.Close() } catch { }
        }

        & $writeDebug "HideUntilContent execution complete"
    }
    else {
        # Standard path: show the window first, kick off execution once it's loaded.
        $window.Opacity = 0

        # CaptureVariables has to be set before the Add_Loaded closure captures the AsyncExecutor.
        if ($Capture) { $Executor.CaptureVariables = [string[]]$Capture }

        $window.Add_Loaded({
            Start-UIFadeIn -Window $window

            # Title bar colors come from the theme. Windows API call, separate from the control brushes.
            try {
                $currentColors = Get-ThemeColors
                $headerBg      = [System.Windows.Media.ColorConverter]::ConvertFromString($currentColors.HeaderBackground)
                $headerFg      = [System.Windows.Media.ColorConverter]::ConvertFromString($currentColors.HeaderForeground)
                [PsUi.WindowManager]::SetTitleBarColor($window, $headerBg, $headerFg)
            }
            catch { Write-Verbose "Failed to set title bar colors: $_" }

            if ($Action) {
                & $writeDebug "Starting ExecuteAsync - Action: $($Action.ToString().Length) chars"
                try {
                    $Executor.ExecuteAsync($Action, $Parameters, $varValues, $funcDefs, $capturedModules, $debugEnabled)
                    & $writeDebug "ExecuteAsync called successfully"
                }
                catch {
                    & $writeDebug "ExecuteAsync FAILED: $($_.Exception.Message)"
                    Write-Verbose "Failed to start async execution: $_"
                }
            }
            else { & $writeDebug "No Action provided" }
        }.GetNewClosure())

        & $writeDebug "Calling ShowDialog..."
        try {
            if ($NoWait) {
                # NoWait skips ShowDialog so the parent runspace keeps going. The calling script hooks the returned window's Closed event for any cleanup that needs to wait it out.
                $window.Show()
                return $window
            }
            else { [void]$window.ShowDialog() }
        }
        catch { Write-Warning "Output window error: $($_.Exception.Message)" }
    }
}