public/controls/buttons/New-UiButton.ps1

function New-UiButton {
    <#
    .SYNOPSIS
        Creates a styled button with async action support.
    .DESCRIPTION
        Creates a themed WPF button that executes actions asynchronously by default,
        with output streamed to the output window and result actions on what comes
        back. Can be used standalone or as part of other layouts (toolbars, forms, etc.).
 
        Use -Action to provide an inline scriptblock, or -File to run an external
        script file. These parameters are mutually exclusive.
 
        Yeah, there's a lot of parameters here. Splitting them into separate cmdlets
        would mean more boilerplate for every button. They group logically:
        appearance (Text/Icon/Width), execution (Action/NoAsync/NoWait), variable
        capture (LinkedVariables/Capture), and result handling (ResultActions). You
        configure all of these together when defining a button, not separately.
    .PARAMETER Text
        The button label text.
    .PARAMETER Action
        The scriptblock to execute when clicked. Mutually exclusive with -File.
    .PARAMETER File
        Path to a script file to execute when clicked. Supports .ps1, .bat, .cmd,
        .vbs, and .exe files. The file must exist at button creation time.
        Mutually exclusive with -Action.
    .PARAMETER ArgumentList
        Hashtable of arguments to pass to the script file. For .ps1 files, these
        are splatted as parameters. For other file types, values are passed as
        command-line arguments.
    .PARAMETER Icon
        Optional icon name shown before the text. Use Show-UiGlyphBrowser to browse names.
    .PARAMETER Accent
        Use accent color styling for the button.
    .PARAMETER Width
        Button width in pixels. Defaults to auto-sizing.
    .PARAMETER Height
        Button height in pixels. Defaults to 28.
    .PARAMETER NoAsync
        Execute synchronously on the UI thread (blocks UI). Auto-set when the action
        spawns a window so it renders on the host's UI thread; pass -NoAsync:$false
        to override. Errors the action writes, a command's stderr included, show
        in one dialog when it finishes. After ten, the dialog counts the rest instead of
        listing them.
    .PARAMETER NoWait
        Execute async with output window, but don't block the parent window.
        Other buttons remain clickable while this action runs. The clicked button
        is still disabled to prevent duplicate execution of the same action.
    .PARAMETER NoOutput
        Execute async but don't show output window.
    .PARAMETER NoInteractive
        Use fast pooled execution. A prompt inside the action does not fail, it just
        returns nothing useful: an empty string from Read-Host, an empty SecureString
        from Read-Host -AsSecureString, no credential object from Get-Credential, and
        the default answer from a choice prompt. Save it for actions that only move
        data around.
    .PARAMETER HideEmptyOutput
        Show output window only when there's actual content.
    .PARAMETER ScrollToTop
        Scrolls console output to the top on completion instead of the bottom. Made for
        help text, which nobody reads bottom up.
    .PARAMETER ResultActions
        Actions offered against selected rows in the output window's results grid, shown as
        an Actions dropdown. Pass a { New-UiResultAction ... } definition block, an array of
        New-UiResultAction output, or the legacy hashtable array. Each entry carries Text and
        Action (required), plus optional Icon, Confirm (asks before the run; a format string
        where {0} is the selection count), and ObjectType (limit the action to result tabs of
        matching type). In the action, $_ is the selected row (the whole array on
        multi-select) and $Selected is always the full array.
    .PARAMETER SingleSelect
        If specified, ResultActions work with single selection.
    .PARAMETER LinkedVariables
        Variable names to capture from your script's scope.
    .PARAMETER LinkedFunctions
        Function names to capture from your script's scope.
    .PARAMETER LinkedModules
        Module paths to import in the async runspace.
    .PARAMETER Capture
        Variable names to capture from the runspace after execution completes.
        Captured variables are stored in the session and available to subsequent
        button actions. With New-UiWindow -ExportOnClose they come back to the
        calling script after the window closes.
    .PARAMETER Parameters
        Hashtable of parameters to pass to the action.
    .PARAMETER Variables
        Hashtable of variables to inject into the action.
    .PARAMETER OutputTitle
        Title for the output window. Defaults to button text.
    .PARAMETER GridColumn
        If specified, sets Grid.Column attached property.
    .PARAMETER GridRow
        If specified, sets Grid.Row attached property.
    .PARAMETER EnabledWhen
        Conditional enabling based on another control's state. Accepts either:
        - A control proxy (e.g., $toggleControl): enabled while that control is truthy
        - A scriptblock (e.g., { $toggle -and $userName }): enabled while the expression is true
 
        Truthy values: CheckBox=checked, TextBox=non-empty, ComboBox=has selection.
    .PARAMETER Variable
        Optional name to register the button for -SubmitButton lookups.
        When specified, inputs using -SubmitButton with this name will trigger
        the button's click event when Enter is pressed.
    .PARAMETER ValidateScript
        Runs synchronously before the action. Return strings to block it and list them in a
        'Please fix the following issues' dialog, or nothing to let it run. A throw blocks too,
        and a written error only warns. It runs before hydration, so it can't see control
        variables. Check those at the top of -Action instead.
    .PARAMETER WPFProperties
        Hashtable of WPF properties to apply to the button.
    .EXAMPLE
        New-UiButton -Text "Save" -Icon "Save" -Accent -Action { Save-Data }
    .EXAMPLE
        New-UiButton -Text "Run Query" -Action { Get-Process } -HideEmptyOutput
    .EXAMPLE
        New-UiButton -Text "Deploy" -File "C:\Scripts\Deploy.ps1" -ArgumentList @{ Environment = 'Prod' }
    .EXAMPLE
        New-UiButton -Text "Backup" -File ".\scripts\backup.bat" -NoOutput
    .EXAMPLE
        # Capture variables for use in other buttons or after window closes
        New-UiButton -Text "Load" -Capture services, loadTime -Action {
            $services = Get-Service | Where-Object Status -eq 'Running'
            $loadTime = Get-Date
        }
        # In another button, $services and $loadTime are now available
    .EXAMPLE
        # Return error strings to block the click - returning nothing will let it run.
        New-UiButton -Text 'Deploy' -ValidateScript {
            $problems = @()
            if (!(Test-Path '\\deploy\staging$')) { $problems += 'Staging share is unreachable.' }
            if (!(Test-Connection prod-web01 -Count 1 -Quiet)) { $problems += 'prod-web01 is not online.' }
            $problems
        } -Action { Publish-Build }
    .EXAMPLE
        # A horizontal toolbar
        New-UiPanel -Orientation Horizontal -Content {
            New-UiButton -Text "Add" -Icon "Add" -Action { Add-Entry }
            New-UiButton -Text "Delete" -Icon "Delete" -Action { Remove-Entry }
        }
    .EXAMPLE
        # Actions against the results grid selection
        New-UiButton -Text 'Get Processes' -Action { Get-Process } -ResultActions {
            New-UiResultAction 'Stop' -Icon Stop -Confirm 'Stop {0} processes?' -Action { $_ | Stop-Process -Force }
            New-UiResultAction 'Details' -Action { $Selected | Format-List * | Out-String | Write-Host }
        }
    .EXAMPLE
        # Legacy hashtable form, still supported
        New-UiButton -Text 'Get Processes' -Action { Get-Process } -ResultActions @(
            @{ Text = 'Stop'; Icon = 'Stop'; Confirm = 'Stop {0} processes?'; Action = { $_ | Stop-Process -Force } }
        )
    #>

    [CmdletBinding(DefaultParameterSetName = 'ScriptBlock')]
    param(
        [Parameter(Mandatory)]
        [string]$Text,

        [Parameter(Mandatory, ParameterSetName = 'ScriptBlock')]
        [scriptblock]$Action,

        [Parameter(Mandatory, ParameterSetName = 'File')]
        [string]$File,

        [Parameter(ParameterSetName = 'File')]
        [hashtable]$ArgumentList,

        [switch]$Accent,

        [int]$Width,

        [int]$Height = 28,

        # Action execution parameters
        [switch]$NoAsync,
        [switch]$NoWait,
        [switch]$NoOutput,
        [switch]$NoInteractive,
        [switch]$HideEmptyOutput,
        [switch]$ScrollToTop,
        # Untyped so it takes a New-UiResultAction definition block, an array of definitions, or the legacy hashtable array.
        [object]$ResultActions,
        [switch]$SingleSelect,
        [string[]]$LinkedVariables,
        [string[]]$LinkedFunctions,
        [string[]]$LinkedModules,
        [string[]]$Capture,
        [hashtable]$Parameters,
        [hashtable]$Variables,
        [string]$OutputTitle,

        # Runs synchronously before Action.
        # Hands back $null or an empty array on success, or an array of error strings on failure.
        [scriptblock]$ValidateScript,

        # Layout parameters
        [int]$GridColumn = -1,
        [int]$GridRow = -1,

        [Parameter()]
        [object]$EnabledWhen,

        [Parameter()]
        [string]$Variable,

        [Parameter()]
        [hashtable]$WPFProperties
    )

    DynamicParam { Get-IconDynamicParameter -ParameterName 'Icon' }

    begin { $Icon = $PSBoundParameters['Icon'] }

    process {

    # Can't use both. Pick one.
    if ($NoOutput -and $HideEmptyOutput) {
        throw "Parameters -NoOutput and -HideEmptyOutput are mutually exclusive. Use only one."
    }

    # Builder input normalizes to the hashtable array the output window expects. Legacy arrays pass through unchanged.
    if ($null -ne $ResultActions) {
        $ResultActions = [hashtable[]](ConvertTo-UiDefinitionArray -InputObject $ResultActions -ParameterName '-ResultActions' -CallerName 'New-UiButton')
    }

    # Catch bad variable names early instead of failing mid-execution
    if ($Capture) {
        foreach ($varName in $Capture) {
            if (![PsUi.Constants]::IsValidIdentifier($varName)) {
                throw "Invalid variable name for -Capture: '$varName'. Names must start with a letter or underscore and contain only letters, numbers, underscores, or hyphens."
            }
        }
    }

    # Convert -File parameter to an Action scriptblock
    if ($PSCmdlet.ParameterSetName -eq 'File') {
        $Action = ConvertTo-UiFileAction -File $File -ArgumentList $ArgumentList
    }

    $session = Assert-UiSession -CallerName 'New-UiButton'
    Write-Debug "Text='$Text', Icon='$Icon', Accent=$Accent, NoAsync=$NoAsync"

    $colors  = Get-ThemeColors
    $parent  = $session.CurrentParent
    Write-Debug "Parent: $($parent.GetType().Name)"

    $btnWidth = if ($Width -gt 0) { $Width } else { [double]::NaN }
    $button = [PsUi.ControlFactory]::CreateButton($Text, $btnWidth, $Height)

    # Configure button content (icon + text or just text)
    $button.Padding = [System.Windows.Thickness]::new(8, 4, 8, 4)
    $button.Margin = [System.Windows.Thickness]::new(4)

    $iconText = if ($Icon) { [PsUi.ModuleContext]::GetIcon($Icon) } else { $null }

    if ($iconText) {
        # Create horizontal stack for icon + text
        $contentPanel = [System.Windows.Controls.StackPanel]@{
            Orientation = 'Horizontal'
            VerticalAlignment = 'Center'
        }

        $iconBlock = [System.Windows.Controls.TextBlock]@{
            Text = $iconText
            FontFamily = [PsUi.ModuleContext]::ActiveIconFontFamily
            FontSize = 12
            FontWeight = 'Light'
            VerticalAlignment = 'Center'
            Margin = [System.Windows.Thickness]::new(0, 0, 6, 0)
        }

        # Accent buttons use contrasting foreground, regular buttons use accent color for icon
        if ($Accent) {
            $iconBlock.Tag = 'AccentButtonIcon'
            $iconBlock.Foreground = ConvertTo-UiBrush $colors.AccentHeaderFg
        }
        else {
            $iconBlock.Tag = 'AccentBrush'
            $iconBlock.SetResourceReference([System.Windows.Controls.TextBlock]::ForegroundProperty, 'AccentBrush')
        }
        [PsUi.ThemeEngine]::RegisterElement($iconBlock)

        [void]$contentPanel.Children.Add($iconBlock)

        $textBlock = [System.Windows.Controls.TextBlock]@{
            Text              = $Text
            VerticalAlignment = 'Center'
            TextTrimming      = 'CharacterEllipsis'
        }
        
        # Accent buttons take a contrasting foreground and everything else takes ButtonForeground.
        if ($Accent) {
            $textBlock.Tag        = 'AccentButtonText'
            $textBlock.Foreground = ConvertTo-UiBrush $colors.AccentHeaderFg
        }
        else {
            $textBlock.Tag = 'ButtonFgBrush'
            $textBlock.SetResourceReference([System.Windows.Controls.TextBlock]::ForegroundProperty, 'ButtonForegroundBrush')
        }
        
        [void]$contentPanel.Children.Add($textBlock)

        # Always use ViewBox for icon+text buttons to handle overflow gracefully
        $viewBox = [System.Windows.Controls.Viewbox]@{
            StretchDirection = 'DownOnly'
            Stretch          = 'Uniform'
        }
        # WPF throws outright on a negative Max, and a -Width under 16 or a -Height under 8 makes it neg.
        if ($Width -gt 16)  { $viewBox.MaxWidth  = $Width - 16 }
        if ($Height -gt 8)  { $viewBox.MaxHeight = $Height - 8 }
        $viewBox.Child = $contentPanel
        $button.Content = $viewBox
    }
    else {
        # Just text, so a ViewBox only if it needs scaling.
        $textBlock = [System.Windows.Controls.TextBlock]@{
            Text              = $Text
            TextAlignment     = 'Center'
            VerticalAlignment = 'Center'
        }
        
        # Accent buttons take a contrasting foreground and everything else takes ButtonForeground.
        if ($Accent) {
            $textBlock.Tag        = 'AccentButtonText'
            $textBlock.Foreground = ConvertTo-UiBrush $colors.AccentHeaderFg
        }
        else {
            $textBlock.Tag = 'ButtonFgBrush'
            $textBlock.SetResourceReference([System.Windows.Controls.TextBlock]::ForegroundProperty, 'ButtonForegroundBrush')
        }

        if ($Width -gt 0 -or $PSBoundParameters.ContainsKey('Height')) {
            # A fixed size on either axis, so the ViewBox does the scaling. Same neg max issue as the icon path above.
            $viewBox = [System.Windows.Controls.Viewbox]@{
                StretchDirection = 'DownOnly'
                Stretch          = 'Uniform'
            }
            if ($Width -gt 16) { $viewBox.MaxWidth  = $Width - 16 }
            if ($Height -gt 8) { $viewBox.MaxHeight = $Height - 8 }
            $viewBox.Child = $textBlock
            $button.Content = $viewBox
        }
        else { $button.Content = $textBlock }
    }

    # Apply grid positioning if specified
    if ($GridColumn -ge 0) { [System.Windows.Controls.Grid]::SetColumn($button, $GridColumn)}
    if ($GridRow -ge 0) { [System.Windows.Controls.Grid]::SetRow($button, $GridRow) }

    $ctxParams = @{
        Action            = $Action
        LinkedVariables   = $LinkedVariables
        LinkedFunctions   = $LinkedFunctions
        LinkedModules     = $LinkedModules
        ExplicitVariables = $Variables
    }
    $actionContext = Get-UiActionContext @ctxParams

    $capturedVars  = $actionContext.CapturedVars
    $capturedFuncs = $actionContext.CapturedFuncs
    $resolvedModules = $actionContext.LinkedModules

    # WPF windows on the async runspace's STA thread inevtiably die. If the action calls a window spawner, flip to sync so the spawn lands on the host's UI thread instead.
    if ($Action -and !$PSBoundParameters.ContainsKey('NoAsync') -and (Test-UiActionOpensWindow -Action $Action)) {
        Write-Debug "[New-UiButton] The action opens a window, forcing -NoAsync."
        $NoAsync = $true
    }

    $handNamed  = @($LinkedVariables) + @($(if ($Variables) { $Variables.Keys }))
    $scopeNames = @($actionContext.AutoDetectedVars | Where-Object { $_ -notin $handNamed })

    # An async click registers its own AsyncExecutor as ActiveExecutor before the action runs, so a Stop-UiAsync inside it cancels this very button instead of the job. Warn, don't flip, unlike the spawner case above, the action still runs, it just cancels the wrong thing.
    if (!$NoAsync -and !$PSBoundParameters.ContainsKey('NoAsync') -and $actionContext.AutoDetectedFuncs -contains 'Stop-UiAsync') {
        Write-Warning "New-UiButton '$Text': the action calls Stop-UiAsync but the button is async, so it will cancel itself instead of the running job. Add -NoAsync to this button."
    }

    # Store action context in button tag for click handler
    $displayTitle = if ($OutputTitle) { $OutputTitle } else { $Text }

    $button.Tag = @{
        Action          = $Action
        Parameters      = $Parameters
        WindowRef       = $session.Window
        SessionId       = $session.SessionId
        Text            = $displayTitle
        NoAsync         = $NoAsync
        NoWait          = $NoWait
        IsCSharpLoaded  = [PsUi.ModuleContext]::IsInitialized
        ResultActions   = $ResultActions
        SingleSelect    = $SingleSelect
        CapturedVars    = $capturedVars
        ScopeNames      = $scopeNames
        CapturedFuncs   = $capturedFuncs
        LinkedModules   = $resolvedModules
        Capture         = $Capture
        NoOutput        = $NoOutput
        NoInteractive   = $NoInteractive
        HideEmptyOutput = $HideEmptyOutput
        ScrollToTop     = $ScrollToTop
        ValidateScript  = $ValidateScript
        IsAccent        = $Accent.IsPresent
    }

    # Apply accent styling AFTER Tag is set (so Set-ButtonStyle can merge IsAccent properly)
    if ($Accent) { Set-ButtonStyle -Button $button -Accent }

    # Click handler
    $button.Add_Click({
        param($sender, $eventArgs)
        $ctx = $this.Tag
        # IDictionary check, not just null: a Tag replaced after construction is non-null, sails past a null guard, and the click dies invoking a null Action ("expression after '&'...").
        if ($ctx -isnot [System.Collections.IDictionary] -or !$ctx.Contains('Action')) {
            Write-Warning "[New-UiButton] Tag no longer holds the action context (overwritten after construction?) - cannot execute action"
            return
        }
        Write-Debug "Click handler fired, Action is null: $($null -eq $ctx.Action)"
        $btn = $this

        # The thread's current session is whichever window set it last (like an open child), so the click runs under the one that built the button
        $sessionToken = Push-UiSession -SessionId $ctx.SessionId

        $originalContent = $btn.Content

        # Capture current button size before swapping content to spinner
        $originalMinWidth  = $btn.MinWidth
        $originalMinHeight = $btn.MinHeight
        if ($btn.ActualWidth -gt 0) { $btn.MinWidth = $btn.ActualWidth }
        if ($btn.ActualHeight -gt 0) { $btn.MinHeight = $btn.ActualHeight }

        # Run pre-validation script synchronously if provided
        if ($ctx.ValidateScript) {
            try {
                $validationErrors = Invoke-UiCallback -ScriptBlock $ctx.ValidateScript -Label 'ValidateScript'
                if ($validationErrors -and $validationErrors.Count -gt 0) {
                    # Use [char]0x2022 for bullet point (PS 5.1 compatible, unlike `u{2022})
                    $bullet = [char]0x2022
                    $errorMessage = "Please fix the following issues:`n`n" + (($validationErrors | ForEach-Object { " $bullet $_" }) -join "`n")
                    Show-UiMessageDialog -Title 'Validation Error' -Message $errorMessage -Icon Warning -Buttons OK | Out-Null
                    Pop-UiSession -Token $sessionToken
                    return
                }
            }
            catch {
                Show-UiMessageDialog -Title 'Validation Error' -Message "Validation failed: $_" -Icon Error -Buttons OK | Out-Null
                Pop-UiSession -Token $sessionToken
                return
            }
        }

        $themeColors = Get-ThemeColors

        # Use contrasting spinner color for accent buttons
        $isAccentButton = $btn.Tag -is [System.Collections.IDictionary] -and $btn.Tag['IsAccent']
        $spinnerColor = if ($isAccentButton) { $themeColors.AccentHeaderFg } else { $themeColors.Accent }
        $spinner = New-UiLoadingSpinner -Size 14 -Color $spinnerColor
        $btn.Content = $spinner
        $btn.IsEnabled = $false

        $actionErrors = [System.Collections.Generic.List[object]]::new()

        try {
            $forceSynchronous = $ctx.NoAsync

            if ($forceSynchronous -eq $true -or !$ctx.IsCSharpLoaded) {
                if ($forceSynchronous -ne $true) { Write-Warning "Async unavailable. Running synchronously." }
                $null = Invoke-UiCallback -ScriptBlock $ctx.Action -ArgumentList $ctx.Parameters -ErrorList $actionErrors
                $btn.Content   = $originalContent
                $btn.MinWidth  = $originalMinWidth
                $btn.MinHeight = $originalMinHeight
                $btn.IsEnabled = $true

                if ($actionErrors.Count) {
                    $listed = Format-UiErrorList -Errors $actionErrors
                    Show-UiMessageDialog -Title "Error: $($ctx.Text)" -Message $listed -Icon Error -Buttons OK | Out-Null
                }
            }
            else {
                $executor = [PsUi.AsyncExecutor]::new()

                # Store the AsyncExecutor in the session for Stop-UiAsync cancellation
                $execSession = [PsUi.SessionManager]::Current
                if ($execSession) { $execSession.ActiveExecutor = $executor }

                # Give the AsyncExecutor the window's Dispatcher so completions land on the UI thread (critical for NoOutput mode)
                $executor.UiDispatcher = $btn.Dispatcher

                # Route the full running lifecycle (start, progress, errors, warnings, cancellation, completion) to any -AutoProgress / -AutoCancel status bar
                if ($execSession) { Add-StatusBarAutoWiring -Executor $executor -Session $execSession -ActionName $ctx.Text }

                $currentThemeColors = Get-ThemeColors
                $varsWithTheme = if ($ctx.CapturedVars) { $ctx.CapturedVars.Clone() } else { @{} }
                $varsWithTheme = Remove-UiStoreShadow -Variables $varsWithTheme -AutoNames $ctx.ScopeNames
                if ($currentThemeColors) {
                    $varsWithTheme['__WPFThemeColors'] = $currentThemeColors
                }

                # Inject credentials from session.Variables at CLICK TIME (not capture time)
                $clickSession = [PsUi.SessionManager]::Current
                if ($clickSession) {
                    foreach ($credKvp in $clickSession.Variables.GetEnumerator()) {
                        $credName = $credKvp.Key
                        $credWrapper = $credKvp.Value
                        
                        # Skip if already in captured vars
                        if ($varsWithTheme.ContainsKey($credName)) { continue }
                        
                        # Credential wrappers need their inner controls extracted
                        if ($credWrapper -and $credWrapper.PSObject.TypeNames -contains 'PsUi.CredentialControl') {
                            $userBox = $credWrapper.UsernameBox
                            $passBox = $credWrapper.PasswordBox
                            
                            if ($userBox -and $passBox) {
                                $username = $userBox.Text
                                $secPass  = $passBox.SecurePassword
                                
                                if (![string]::IsNullOrWhiteSpace($username) -and $secPass.Length -gt 0) {
                                    $cred = [System.Management.Automation.PSCredential]::new($username, $secPass)
                                    $varsWithTheme[$credName] = $cred
                                    Write-Debug "Injected credential '$credName' at click time"
                                }
                            }
                        }
                    }
                }

                if ($ctx.NoOutput) {
                    # NoOutput mode alone registers handlers to put the button back and dispose the AsyncExecutor.
                    # Show-UiOutput modes handle this themselves via the output window lifecycle
                    $buttonToRestore    = $btn
                    $contentToRestore   = $originalContent
                    $minWidthToRestore  = $originalMinWidth
                    $minHeightToRestore = $originalMinHeight
                    $executorToDispose  = $executor

                    # Hook window close to prevent zombie background runs.
                    # If the window closes mid task, cancel the AsyncExecutor so it doesn't crash completing onto the dead UI thread.
                    #
                    # GetNewClosure() captures the entire scope into a dynamic module. If you are doing something insane like creating 100 buttons in a loop where the scope has a giant array, congrats - you now have 100 references to that array.
                    # For normal forms with 5-20 buttons this is fine. Window close cleans it up. If you hit memory issues, refactor your loop or stop holding massive objects in scope.
                    $parentWindow = $ctx.WindowRef
                    if ($parentWindow) {
                        $closedHandler = [System.EventHandler]{
                            param($sender, $eventArgs)
                            if ($executorToDispose.IsRunning) {
                                try { $executorToDispose.Cancel() } catch { <# Best-effort cleanup #> }
                            }
                        }.GetNewClosure()
                        $parentWindow.Add_Closed($closedHandler)
                        
                        # Keep the handler reference, so it can come off again when the task completes
                        $windowToCleanup = $parentWindow
                        $handlerToRemove = $closedHandler
                    }

                    # Add input providers unless NoInteractive was specified
                    # Without providers the action still runs pooled, Read-Host just returns an empty string
                    if (!$ctx.NoInteractive) {
                        $inputParams = @{
                            Executor     = $executor
                            DebugEnabled = $false
                        }
                        Add-InputProviders @inputParams
                    }

                    # Unhooks the window handler and puts the button back, then disposes the AsyncExecutor.
                    # Called from OnComplete, OnError, and OnCancelled to avoid triple copy-paste.
                    $restoreAndDispose = {
                        param([string]$CallerName)
                        if ($windowToCleanup -and $handlerToRemove) {
                            try { $windowToCleanup.Remove_Closed($handlerToRemove) } catch { <# Window may already be closed #> }
                        }
                        try {
                            if ($buttonToRestore.Dispatcher.HasShutdownStarted) { return }
                            $buttonToRestore.Dispatcher.Invoke([Action]{
                                $buttonToRestore.Content   = $contentToRestore
                                $buttonToRestore.MinWidth  = $minWidthToRestore
                                $buttonToRestore.MinHeight = $minHeightToRestore
                                $buttonToRestore.IsEnabled = $true
                            })
                        }
                        catch { Write-Debug "$CallerName UI restore skipped (window closed): $_" }
                        try { $executorToDispose.Dispose() } catch { Write-Debug "$CallerName dispose error: $_" }

                        # Release ActiveExecutor or it holds the disposed AsyncExecutor forever and the next Stop-UiAsync silently does nothing. If a newer job owns it by now, this run finishing must not clear it. Inline, not a helper since private fns don't resolve inside GetNewClosure handlers outside a real window.
                        if ($execSession -and [object]::ReferenceEquals($execSession.ActiveExecutor, $executorToDispose)) { $execSession.ActiveExecutor = $null }
                    }.GetNewClosure()

                    $executor.add_OnComplete({
                        & $restoreAndDispose 'OnComplete'
                    }.GetNewClosure())

                    # An -Intercept bar already badges each error. No dialog needed.
                    $errorsToBar = [bool]($execSession -and (Test-StatusBarIntercept -Session $execSession))

                    # OnComplete follows every error once a thread picks the run up so a tear down here would dispose it halfway through on a plain Write-Error
                    $runState = @{ Started = $false }
                    $executor.add_OnStarted({ $runState.Started = $true }.GetNewClosure())

                    # OnError comes in after the click has returned
                    $errorSessionId = $ctx.SessionId
                    $pushSession    = ${function:Push-UiSession}
                    $popSession     = ${function:Pop-UiSession}

                    $executor.add_OnError({
                        param($errorRecord)
                        # No console here so without a bar the error goes in a dialog
                        if ($errorRecord -and !$errorsToBar) {
                            try {
                                if (!$buttonToRestore.Dispatcher.HasShutdownStarted) {
                                    $errorMsg = $errorRecord.ToString()
                                    $buttonToRestore.Dispatcher.Invoke([Action]{
                                        $errorToken = & $pushSession -SessionId $errorSessionId
                                        Show-UiMessageDialog -Title 'Action Error' -Message $errorMsg -Icon Error
                                        & $popSession -Token $errorToken
                                    })
                                }
                            }
                            catch { Write-Debug "OnError dialog skipped (window closed): $_" }
                        }
                        if (!$runState.Started) { & $restoreAndDispose 'OnError' }
                    }.GetNewClosure())

                    $executor.add_OnCancelled({
                        & $restoreAndDispose 'OnCancelled'
                    }.GetNewClosure())

                    # Set capture variables if specified
                    if ($ctx.Capture) {
                        $executor.CaptureVariables = [string[]]$ctx.Capture
                    }

                    # Fire and forget. The handlers put the button back when it finishes.
                    $executor.ExecuteAsync(
                        $ctx.Action,
                        $ctx.Parameters,
                        $varsWithTheme,
                        $ctx.CapturedFuncs,
                        [string[]]@($ctx.LinkedModules | Where-Object { $_ })
                    )
                    Pop-UiSession -Token $sessionToken
                    return
                }
                else {
                    # Output window path (HideEmptyOutput and normal share the same flow)
                    try {
                        $outParams = @{
                            Executor                  = $executor
                            Title                     = $ctx.Text
                            ParentWindow              = $ctx.WindowRef
                            Action                    = $ctx.Action
                            Parameters                = $ctx.Parameters
                            ResultActions             = $ctx.ResultActions
                            SingleSelect              = $ctx.SingleSelect
                            LinkedVariableValues      = $varsWithTheme
                            LinkedFunctionDefinitions = $ctx.CapturedFuncs
                            LinkedModules             = $ctx.LinkedModules
                            Capture                   = $ctx.Capture
                            NoWait                    = $ctx.NoWait
                        }
                        if ($ctx.HideEmptyOutput) { $outParams['HideUntilContent'] = $true }
                        if ($ctx.ScrollToTop) { $outParams['ScrollToTop'] = $true }

                        $outputWindow = Show-UiOutput @outParams

                        # NoWait mode hooks Closed so the button comes back when the output window closes.
                        if ($ctx.NoWait -and $outputWindow) {
                            $buttonToRestore    = $btn
                            $contentToRestore   = $originalContent
                            $minWidthToRestore  = $originalMinWidth
                            $minHeightToRestore = $originalMinHeight
                            $outputWindow.Add_Closed({
                                $buttonToRestore.Content   = $contentToRestore
                                $buttonToRestore.MinWidth  = $minWidthToRestore
                                $buttonToRestore.MinHeight = $minHeightToRestore
                                $buttonToRestore.IsEnabled = $true
                            }.GetNewClosure())
                            Pop-UiSession -Token $sessionToken
                            return
                        }
                    }
                    catch {
                        Write-Warning "Output window error: $($_.Exception.Message)"
                        # Kill the AsyncExecutor if it is still running
                        try {
                            if ($executor.IsRunning) { $executor.Cancel() }
                            $executor.Dispose()
                            if ($execSession -and [object]::ReferenceEquals($execSession.ActiveExecutor, $executor)) { $execSession.ActiveExecutor = $null }
                        } catch { Write-Debug "Output cleanup error: $_" }
                    }
                }

                # Restore button after output window closes (whether success or failure)
                $btn.Content = $originalContent
                $btn.MinWidth = $originalMinWidth
                $btn.MinHeight = $originalMinHeight
                $btn.IsEnabled = $true
            }
        }
        catch {
            $btn.Content = $originalContent
            $btn.MinWidth = $originalMinWidth
            $btn.MinHeight = $originalMinHeight
            $btn.IsEnabled = $true

            # Log error details for debugging
            Write-Debug "Action error: $($_.Exception.GetType().Name) - $($_.Exception.Message)"
            Write-Debug "Stack: $($_.ScriptStackTrace)"

            # Any errors the action wrote before it stopped go first
            $message = Format-UiErrorList -Errors $actionErrors -Trailing $_.Exception.Message
            Show-UiMessageDialog -Title "Error: $($ctx.Text)" -Message $message -Icon Error -Buttons OK | Out-Null
        }
        Pop-UiSession -Token $sessionToken
    })

    # Apply custom WPF properties if specified
    if ($WPFProperties) {
        # Tag is reserved. The click handler reads its action context off it, so a Tag from the calling script would break the button
        # Copy before the Remove, because [hashtable] binds the calling script's own table by reference.
        if ($WPFProperties.ContainsKey('Tag')) {
            Write-Warning "New-UiButton: -WPFProperties Tag is reserved (stores the click action context). Ignoring."
            $WPFProperties = @{} + $WPFProperties
            [void]$WPFProperties.Remove('Tag')
        }
        if ($WPFProperties.Count -gt 0) { Set-UiProperties -Control $button -Properties $WPFProperties }
    }

    # Hook conditional enabling if specified
    if ($EnabledWhen) { Register-UiCondition -TargetControl $button -Condition $EnabledWhen }

    # Register button by name for -SubmitButton lookups
    if ($Variable) { $session.RegisterButton($Variable, $button) }

    Write-Debug "Adding to $($parent.GetType().Name)"
    $addedToParent = $false
    if ($parent -is [System.Windows.Controls.Panel]) {
        [void]$parent.Children.Add($button)
        $addedToParent = $true
    }
    elseif ($parent -is [System.Windows.Controls.ItemsControl]) {
        [void]$parent.Items.Add($button)
        $addedToParent = $true
    }
    elseif ($parent -is [System.Windows.Controls.ContentControl]) {
        $parent.Content = $button
        $addedToParent = $true
    }

    # Only return button if not added to parent (for manual layout scenarios)
    if (!$addedToParent) {
        return $button
    }
    }
}