synedgy.universal.helper.psm1

using namespace System.Net
using namespace System.Collections
#Region './Classes/00.Attributes/AiTool.ps1' -1

class AiTool : System.Attribute
{
    # AiTool() attribute used to expose a function as a PowerShell Universal AI tool.
    # Mirrors the APIEndpoint() attribute pattern: decorate a public function with this
    # attribute, then call Import-PSUAiTool to discover and register it in PSU.
    [bool]$IsAiTool = $true # Marker indicating this is an AI tool attribute
    [string]$Name # Name of the AI tool. Defaults to the function name when not set.
    [string]$Description # Description of the AI tool. Defaults to the function's comment-based help synopsis.
    [bool]$Authenticated = $false # Whether the tool requires an authenticated user
    [string[]]$Role # Role(s) required to access/invoke the tool
    [bool]$Mcp = $true # Whether the tool is exposed through Model Context Protocol (MCP)
    [string]$Environment # PSU environment the backing script should run in

    AiTool ()
    {
        # Default constructor for the attribute
    }
}
#EndRegion './Classes/00.Attributes/AiTool.ps1' 19
#Region './Classes/00.Attributes/APIEndpoint.ps1' -1

class APIEndpoint : System.Attribute
{
    [bool]$IsEndpoint = $true # Indicates that this is an API endpoint attribute
    [bool]$AutoSerialization = $true # Whether the endpoint should automatically serialize the output to JSON
    [string]$Name
    [string]$ApiPrefix = 'api' # Prefix for the API endpoint URL
    [string]$Version = 'v1' # Version of the API endpoint
    [string]$Path # aka the URL path of the endpoint
    [string]$Description # Description of the endpoint
    [string]$Documentation # Documentation name the endpoint should be attached to
    [ValidateSet('GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD')]
    [string[]]$Method # HTTP method (GET, POST, PUT, DELETE, etc.)
    [bool]$Authentication = $false # Whether the endpoint requires authentication
    [string[]]$Role # Role required to access the endpoint
    [string]$Tag # Tag for categorizing the endpoint
    [int]$Timeout# Timeout for the endpoint in seconds
    [string]$Environment # Environment where the endpoint is executed (e.g. the PowerShell Universal environment)
    [string]$ContentType = 'application/json; charset=utf-8' # Content type of the response
    [ValidateSet('Information', 'Warning', 'Error', 'Verbose', 'Debug')] # TODO: Create the scriptlbock to add the -$loglevelAction Continue
    [string[]]$LogLevel = @('Information') # Log level for the endpoint
    [scriptblock]$Parameters # Override of parameters for the endpoint to splat to the command

    APIEndpoint ()
    {
        # Default constructor for the attribute
    }
}
#EndRegion './Classes/00.Attributes/APIEndpoint.ps1' 28
#Region './Classes/00.Attributes/APIInput.ps1' -1

# using namespace System.Net

class APIInput : System.Attribute
{
    # APIInput() attribute used to specify type used for an API endpoint as input.
    [HttpStatusCode] $StatusCode = [HttpStatusCode]::OK
    [bool] $Required = $false
    [string] $Description
    [string] $Type
    [string] $ContentType = 'application/json'
}
#EndRegion './Classes/00.Attributes/APIInput.ps1' 12
#Region './Classes/00.Attributes/APIOutput.ps1' -1

# using namespace System.Net

class APIOutput : System.Attribute
{
    # APIOutput() attribute used to specify type used for an API endpoint as output.
    [HttpStatusCode] $StatusCode = [HttpStatusCode]::OK
    [string] $Description
    [string] $Type
    [string] $ContentType = 'application/json'

}
#EndRegion './Classes/00.Attributes/APIOutput.ps1' 12
#Region './Classes/00.Attributes/Documentation.ps1' -1

class Documentation : System.Attribute
{
    # Documentation() attribute used to specify type used for an API endpoint as output or input.
}
#EndRegion './Classes/00.Attributes/Documentation.ps1' 5
#Region './Private/Convert-AnsiToHtml.ps1' -1

function Convert-AnsiToHtml
{
    <#
        .SYNOPSIS
        Converts a string containing ANSI SGR escape sequences to HTML with inline span styles.

        .DESCRIPTION
        Parses ANSI SGR color and bold sequences and wraps affected text in <span> elements
        with inline CSS. Text segments without ANSI codes are passed through
        Convert-PlainTextToHtml. Accepts pipeline input for use in streaming pipelines.

        .PARAMETER Message
        The string potentially containing ANSI escape sequences. Accepts pipeline input.

        .EXAMPLE
        "$([char]27)[32mSuccess$([char]27)[0m: done" | Convert-AnsiToHtml
        # Returns: <span style='color:#66bb6a;'>Success</span>: done

        .EXAMPLE
        Get-PSUJobOutput -JobId 1 | Select-Object -Exp Message | Convert-AnsiToHtml
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param
    (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [AllowEmptyString()]
        [string]
        $Message
    )

    process
    {
        $ansiForegroundMap = @{
            '30' = '#000000'; '31' = '#ef5350'; '32' = '#66bb6a'; '33' = '#ffee58'
            '34' = '#42a5f5'; '35' = '#ab47bc'; '36' = '#26c6da'; '37' = '#e0e0e0'
            '90' = '#9e9e9e'; '91' = '#e57373'; '92' = '#81c784'; '93' = '#fff176'
            '94' = '#64b5f6'; '95' = '#ba68c8'; '96' = '#4dd0e1'; '97' = '#ffffff'
        }
        $ansiBackgroundMap = @{
            '40' = '#000000'; '41' = '#b71c1c'; '42' = '#1b5e20'; '43' = '#f57f17'
            '44' = '#0d47a1'; '45' = '#4a148c'; '46' = '#006064'; '47' = '#bdbdbd'
            '100' = '#616161'; '101' = '#ef9a9a'; '102' = '#a5d6a7'; '103' = '#fff59d'
            '104' = '#90caf9'; '105' = '#ce93d8'; '106' = '#80deea'; '107' = '#eeeeee'
        }

        $text        = if ($null -eq $Message) { '' } else { [string]$Message }
        $ansiMatches = [regex]::Matches($text, '(?:\x1B|\u241B)\[([0-9;]*)m')

        if (@($ansiMatches).Count -eq 0)
        {
            return ($text | Convert-PlainTextToHtml)
        }

        $state        = @{ Bold = $false; Foreground = $null; Background = $null }
        $fragments    = [System.Collections.Generic.List[string]]::new()
        $currentIndex = 0

        foreach ($match in $ansiMatches)
        {
            if ($match.Index -gt $currentIndex)
            {
                $segmentHtml = $text.Substring($currentIndex, $match.Index - $currentIndex) | Convert-PlainTextToHtml
                $styleParts  = [System.Collections.Generic.List[string]]::new()
                if ($state.Bold) { $null = $styleParts.Add('font-weight:700') }
                if (-not [string]::IsNullOrWhiteSpace($state.Foreground)) { $null = $styleParts.Add('color:{0}' -f $state.Foreground) }
                if (-not [string]::IsNullOrWhiteSpace($state.Background)) { $null = $styleParts.Add('background-color:{0}' -f $state.Background) }
                $fragment = if ($styleParts.Count -eq 0) { $segmentHtml } else { "<span style='{0}'>{1}</span>" -f ($styleParts -join ';'), $segmentHtml }
                $null = $fragments.Add($fragment)
            }

            $codesRaw = $match.Groups[1].Value
            $codes = if ([string]::IsNullOrWhiteSpace($codesRaw)) { @('0') } else { @($codesRaw -split ';') }
            foreach ($code in $codes)
            {
                if ([string]::IsNullOrWhiteSpace($code)) { continue }
                switch ($code)
                {
                    '0'  { $state.Bold = $false; $state.Foreground = $null; $state.Background = $null; continue }
                    '1'  { $state.Bold = $true; continue }
                    '22' { $state.Bold = $false; continue }
                    '39' { $state.Foreground = $null; continue }
                    '49' { $state.Background = $null; continue }
                }
                if ($ansiForegroundMap.ContainsKey($code)) { $state.Foreground = $ansiForegroundMap[$code]; continue }
                if ($ansiBackgroundMap.ContainsKey($code)) { $state.Background = $ansiBackgroundMap[$code]; continue }
            }

            $currentIndex = $match.Index + $match.Length
        }

        if ($currentIndex -lt $text.Length)
        {
            $segmentHtml = $text.Substring($currentIndex) | Convert-PlainTextToHtml
            $styleParts  = [System.Collections.Generic.List[string]]::new()
            if ($state.Bold) { $null = $styleParts.Add('font-weight:700') }
            if (-not [string]::IsNullOrWhiteSpace($state.Foreground)) { $null = $styleParts.Add('color:{0}' -f $state.Foreground) }
            if (-not [string]::IsNullOrWhiteSpace($state.Background)) { $null = $styleParts.Add('background-color:{0}' -f $state.Background) }
            $fragment = if ($styleParts.Count -eq 0) { $segmentHtml } else { "<span style='{0}'>{1}</span>" -f ($styleParts -join ';'), $segmentHtml }
            $null = $fragments.Add($fragment)
        }

        $fragments -join ''
    }
}
#EndRegion './Private/Convert-AnsiToHtml.ps1' 106
#Region './Private/Convert-PlainTextToHtml.ps1' -1

function Convert-PlainTextToHtml
{
    <#
        .SYNOPSIS
        Strips ANSI escape sequences, HTML-encodes plain text, and converts newlines to <br/>.

        .DESCRIPTION
        Accepts a plain text string via pipeline or direct parameter. Removes all ANSI/VT100
        escape sequences, HTML-encodes the result, and replaces line endings with <br/> tags
        suitable for rendering in an HTML terminal view.

        .PARAMETER Text
        The plain text string to convert. Accepts pipeline input.

        .EXAMPLE
        'Hello <World>' | Convert-PlainTextToHtml
        # Returns: Hello &lt;World&gt;

        .EXAMPLE
        "Line 1`nLine 2" | Convert-PlainTextToHtml
        # Returns: Line 1<br/>Line 2
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param
    (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [AllowEmptyString()]
        [string]
        $Text
    )

    process
    {
        $value = if ($null -eq $Text) { '' } else { [string]$Text }
        $value = [regex]::Replace($value, '(?:\x1B|\u241B)\[[0-9;?]*[A-Za-z]', '')
        $encodedValue = [System.Net.WebUtility]::HtmlEncode($value)
        $encodedValue.Replace("`r`n", '<br/>').Replace("`n", '<br/>').Replace("`r", '<br/>')
    }
}
#EndRegion './Private/Convert-PlainTextToHtml.ps1' 41
#Region './Private/ConvertFrom-PsuJobOutputEntry.ps1' -1

function ConvertFrom-PsuJobOutputEntry
{
    <#
        .SYNOPSIS
        Converts a raw PSU job output entry to a normalized display row object.

        .DESCRIPTION
        Maps raw objects returned by Get-PSUJobOutput (or stored in Job.Output) to a
        consistent PSCustomObject with Timestamp, Stream, StreamColor, and Message
        properties, suitable for rendering in a terminal or structured table view.
        StreamColor is a CSS var() reference (e.g. 'var(--psu-term-stream-error)') rather
        than a literal color, so rendered output follows the light/dark theme set by
        Get-UDPsuJobThemeStyleBlock instead of being hardcoded to one palette.
        Accepts pipeline input for use in streaming pipelines.

        .PARAMETER Entry
        A raw PSU job output entry object. Accepts pipeline input.

        .EXAMPLE
        Get-PSUJobOutput -JobId 1234 | ConvertFrom-PsuJobOutputEntry

        .EXAMPLE
        $job.Output | ConvertFrom-PsuJobOutputEntry | Where-Object { $_.Stream -eq 'Error' }
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param
    (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [object]
        $Entry
    )

    process
    {
        $streamName = switch ($Entry.Type)
        {
            4             { 'Error' }
            3             { 'Warning' }
            2             { 'Verbose' }
            1             { 'Debug' }
            0             { 'Information' }
            'Error'       { 'Error' }
            'Warning'     { 'Warning' }
            'Verbose'     { 'Verbose' }
            'Debug'       { 'Debug' }
            'Information' { 'Information' }
            Default       { 'Output' }
        }

        $streamColor = switch ($streamName)
        {
            'Error'       { 'var(--psu-term-stream-error)' }
            'Warning'     { 'var(--psu-term-stream-warning)' }
            'Information' { 'var(--psu-term-stream-information)' }
            'Verbose'     { 'var(--psu-term-stream-verbose)' }
            'Debug'       { 'var(--psu-term-stream-debug)' }
            Default       { 'var(--psu-term-stream-default)' }
        }

        $messageValue = $null
        foreach ($prop in @('message', 'Message', 'data', 'Data', 'output', 'Output', 'text', 'Text'))
        {
            if ($Entry.PSObject.Properties.Match($prop).Count -eq 0) { continue }
            $candidate = $Entry.$prop
            if ($null -eq $candidate) { continue }
            $candidateText = [string]$candidate
            if (-not [string]::IsNullOrWhiteSpace($candidateText)) { $messageValue = $candidateText; break }
        }

        if ([string]::IsNullOrWhiteSpace([string]$messageValue))
        {
            $messageValue = if ($Entry -is [string]) { [string]$Entry }
                            else { [string](Microsoft.PowerShell.Utility\ConvertTo-Json -InputObject $Entry -Compress -Depth 5) }
        }

        [PSCustomObject]@{
            Timestamp   = $Entry.Timestamp
            Stream      = $streamName
            StreamColor = $streamColor
            Message     = $messageValue
        }
    }
}
#EndRegion './Private/ConvertFrom-PsuJobOutputEntry.ps1' 85
#Region './Private/ConvertTo-UDPsuThemedIconMarkup.ps1' -1

function ConvertTo-UDPsuThemedIconMarkup
{
    <#
        .SYNOPSIS
        Builds inline SVG markup for the synedgy PowerShell icon, themed for light/dark/auto.

        .DESCRIPTION
        Always uses the plain monochrome icon variants: pwsh_custom_black.svg for light theme
        and pwsh_custom_white.svg for dark theme. When Theme is 'Auto', both variants are
        rendered and wrapped in '.psu-theme-light-only' / '.psu-theme-dark-only' spans so that
        Get-UDPsuJobThemeStyleBlock's CSS rules can show/hide the correct one live, following
        PSU's own theme switch without a page reload. When Theme is 'Light' or 'Dark', only the
        matching variant is rendered directly (no wrapper spans needed).

        .PARAMETER ModuleBase
        The module's base path (from $MyInvocation.MyCommand.Module.ModuleBase in the caller),
        used to locate images/synedgy_pwsh/*.svg.

        .PARAMETER IdPrefix
        A unique prefix used to uniquify SVG element ids/classes so multiple inlined copies of
        the same SVG on one page do not collide.

        .PARAMETER ExtraStyle
        Inline CSS applied to the root <svg> element (sizing, display, etc.).

        .PARAMETER Theme
        'Auto' (default), 'Light', or 'Dark'.

        .EXAMPLE
        ConvertTo-UDPsuThemedIconMarkup -ModuleBase $moduleBase -IdPrefix 'jh-123' -ExtraStyle 'height:26px;' -Theme 'Auto'
    #>

    [CmdletBinding()]
    [OutputType([System.String])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $ModuleBase,

        [Parameter(Mandatory = $true)]
        [System.String]
        $IdPrefix,

        [Parameter()]
        [System.String]
        $ExtraStyle = '',

        [Parameter()]
        [ValidateSet('Auto', 'Light', 'Dark')]
        [System.String]
        $Theme = 'Auto'
    )

    $imagesPath = Join-Path -Path $ModuleBase -ChildPath 'images'
    $imagesPath = Join-Path -Path $imagesPath -ChildPath 'synedgy_pwsh'
    $blackSvgPath = Join-Path -Path $imagesPath -ChildPath 'pwsh_custom_black.svg'
    $whiteSvgPath = Join-Path -Path $imagesPath -ChildPath 'pwsh_custom_white.svg'

    $renderSvg = {
        param([string]$svgPath, [string]$prefix, [string]$extraStyle)
        if (-not [System.IO.File]::Exists($svgPath)) { return '' }
        $c = [System.IO.File]::ReadAllText($svgPath)
        $c = $c -replace '<\?xml[^>]*\?>', ''
        # Uniquify gradient/element IDs and their references
        $c = $c -replace 'id="([^"]+)"',         "id=`"$prefix-`$1`""
        $c = $c -replace 'url\(#([^)]+)\)',       "url(#$prefix-`$1)"
        $c = $c -replace 'xlink:href="#([^"]+)"', "xlink:href=`"#$prefix-`$1`""
        # Uniquify CSS class names so two inlined SVGs don't share .cls-N rules
        $c = $c -replace '\.cls-(\d+)',           ".$prefix-cls-`$1"
        $c = $c -replace 'class="cls-(\d+)"',     "class=`"$prefix-cls-`$1`""
        $c = $c -replace '<svg ',                 "<svg style=`"$extraStyle`" "
        return $c.Trim()
    }

    switch ($Theme)
    {
        'Light'
        {
            return & $renderSvg $blackSvgPath ($IdPrefix + '-lt') $ExtraStyle
        }
        'Dark'
        {
            return & $renderSvg $whiteSvgPath ($IdPrefix + '-dk') $ExtraStyle
        }
        Default
        {
            $lightSvg = & $renderSvg $blackSvgPath ($IdPrefix + '-lt') $ExtraStyle
            $darkSvg  = & $renderSvg $whiteSvgPath ($IdPrefix + '-dk') $ExtraStyle

            $markup = ''
            if ($lightSvg)
            {
                $markup += '<span class="psu-theme-light-only" style="display:contents;">' + $lightSvg + '</span>'
            }
            if ($darkSvg)
            {
                $markup += '<span class="psu-theme-dark-only" style="display:none;">' + $darkSvg + '</span>'
            }
            return $markup
        }
    }
}
#EndRegion './Private/ConvertTo-UDPsuThemedIconMarkup.ps1' 103
#Region './Private/Get-HttpMethodFromPSVerb.ps1' -1

function Get-HttpMethodFromPSVerb
{
    <#
        .SYNOPSIS
        Converts a PowerShell verb to an HTTP method.

        .DESCRIPTION
        This function takes a PowerShell verb and returns the corresponding HTTP method.

        .PARAMETER Verb
        The PowerShell verb to convert.

        .OUTPUTS
        Returns the HTTP method as a string.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param (
        [Parameter(Mandatory = $true)]
        [string]$Verb
    )

    switch -regex ($Verb)
    {
        # The POST method is used to submit an entity to the specified resource, often causing a change in state or side effects on the server.
        # The PUT method replaces all current representations of the target resource with the request payload.
        # The DELETE method deletes the specified resource.
        # The PATCH method is used to apply partial modifications to a resource.
        # The HEAD method asks for a response identical to that of a GET request, but without the response body.
        # The GET method requests a representation of the specified resource.
        # The OPTIONS method is used to describe the communication options for the target resource.
        # The CONNECT method establishes a tunnel to the server identified by the target resource.
        # The TRACE method performs a message loop-back test along the path to the target resource.

        'Add'           { 'POST' }
        'Clear'         { 'DELETE'}
        'Close'         { 'HEAD'}
        'Copy'          { 'PUT'}
        'Enter'         { 'PATCH' }
        'Exit'          { 'GET'}
        'Find'          { 'GET'}
        'Format'        { 'GET'}
        'Get'           { 'GET'}
        'Hide'          { 'PATCH' }
        'Join'          { 'PUT'}
        'Lock'          { 'POST' }
        'Move'          { 'PUT' }
        'New'           { 'POST' }
        'Open'          { 'GET' }
        'Optimize'      { 'PUT'}
        'Push'          { 'POST' }
        'Pop'           { 'DELETE' }
        'Redo'          { 'PATCH' }
        'Remove'        { 'DELETE' }
        'Rename'        { 'PUT' }
        'Reset'         { 'PATCH' }
        'Resize'        { 'PATCH' }
        'Search'        { 'GET' }
        'Select'        { 'GET' }
        'Set'           { 'PUT' }
        'Show'          { 'GET' }
        'Skip'          { 'PATCH' }
        'Split'         { 'PATCH' }
        'Step'          { 'PATCH' }
        'Switch'        { 'GET' }
        'Undo'          { 'PATCH' }
        'Unlock'        { 'DELETE' }
        'Watch'         { 'GET' }
        'Connect'       { 'POST' }
        'Disconnect'    { 'DELETE' }
        'Read'          { 'GET' }
        'Receive'       { 'POST' }
        'Send'          { 'POST' }
        'Write'         { 'PUT' }
        'Backup'        { 'PUT' }
        'Checkpoint'    { 'PUT' }
        'Compare'       { 'GET' }
        'Compress'      { 'POST' }
        'Convert'       { 'POST' }
        'ConvertFrom'   { 'GET' }
        'ConvertTo'     { 'POST' }
        'Dismount'      { 'DELETE' }
        'Edit'          { 'PATCH' }
        'Expand'        { 'GET' }
        'Export'        { 'POST' }
        'Group'         { 'GET' }
        'Import'        { 'POST' }
        'Initialize'    { 'PUT' }
        'Limit'         { 'PATCH' }
        'Merge'         { 'PUT' }
        'Mount'         { 'POST' }
        'Out'           { 'POST' }
        'Publish'       { 'POST' }
        'Restore'       { 'PUT' }
        'Save'          { 'PUT' }
        'Sync'          { 'POST' }
        'Unpublish'     { 'DELETE' }
        'Update'        { 'PUT' }
        'Debug'         { 'GET' }
        'Measure'       { 'GET' }
        'Ping'          { 'GET' }
        'Repair'        { 'PATCH' }
        'Resolve'       { 'GET' }
        'Test'          { 'GET' }
        'Trace'         { 'TRACE' }
        'Approve'       { 'POST' }
        'Assert'        { 'PATCH' }
        'Build'         { 'POST' }
        'Complete'      { 'PUT' }
        'Confirm'       { 'POST' }
        'Deny'          { 'DELETE' }
        'Deploy'        { 'POST' }
        'Disable'       { 'PATCH' }
        'Enable'        { 'PATCH' }
        'Install'       { 'POST' }
        'Invoke'        { 'POST' }
        'Register'      { 'POST' }
        'Request'       { 'POST' }
        'Restart'       { 'PATCH' }
        'Resume'        { 'PATCH' }
        'Start'         { 'POST' }
        'Stop'          { 'DELETE' }
        'Submit'        { 'POST' }
        'Suspend'       { 'PATCH' }
        'Uninstall'     { 'DELETE' }
        'Unregister'    { 'DELETE' }
        'Wait'          { 'GET' }
        'Use'           { 'GET' }
        'Block'         { 'POST' }
        'Grant'         { 'POST' }
        'Protect'       { 'POST' }
        'Revoke'        { 'DELETE' }
        'Unblock'       { 'PUT' }
        'Unprotect'     { 'PUT' }
        default         { throw "Unsupported verb: $Verb" }
    }
}
#EndRegion './Private/Get-HttpMethodFromPSVerb.ps1' 138
#Region './Private/Get-UDPsuJobThemePalette.ps1' -1

function Get-UDPsuJobThemePalette
{
    <#
        .SYNOPSIS
        Returns the light and dark color palettes used by the PSU job UD components.

        .DESCRIPTION
        Single source of truth for the CSS custom property values used by
        New-UDPsuJobHeader and New-UDPsuJobTerminalView to theme their rendered markup.
        The light palette is inspired by the classic PowerShell ISE / VS Code light theme
        color conventions (white background, dark text, ISE-style syntax accent colors for
        the job output stream types). The dark palette preserves the original color scheme.

        .EXAMPLE
        $palette = Get-UDPsuJobThemePalette
        $palette.Light['bg']
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param ()

    return @{
        Light = [ordered]@{
            'bg'                 = '#ffffff'
            'chrome-bg'          = '#f3f3f3'
            'fg'                 = '#1e1e1e'
            'muted-fg'           = '#6e6e6e'
            'border'             = '#d4d4d4'
            'accent'             = '#0060c0'
            'tooltip-bg'         = '#e8e8e8'
            'tooltip-fg'         = '#1e1e1e'
            'status-bar-bg'      = '#eaeaea'
            'chip-bg'            = '#eeeeee'
            'tab-active-bg'      = '#ffffff'
            'stream-error'       = '#a31515'
            'stream-warning'     = '#b5860b'
            'stream-information' = '#0451a5'
            'stream-verbose'     = '#008080'
            'stream-debug'       = '#af00db'
            'stream-default'     = '#1e1e1e'
        }
        Dark = [ordered]@{
            'bg'                 = '#111111'
            'chrome-bg'          = '#263238'
            'fg'                 = '#e0e0e0'
            'muted-fg'           = '#90a4ae'
            'border'             = '#37474f'
            'accent'             = '#42a5f5'
            'tooltip-bg'         = '#37474f'
            'tooltip-fg'         = '#e0e0e0'
            'status-bar-bg'      = '#1c2429'
            'chip-bg'            = '#263238'
            'tab-active-bg'      = '#1e2a2f'
            'stream-error'       = '#ef5350'
            'stream-warning'     = '#ffa726'
            'stream-information' = '#42a5f5'
            'stream-verbose'     = '#ffee58'
            'stream-debug'       = '#ab47bc'
            'stream-default'     = '#e0e0e0'
        }
    }
}
#EndRegion './Private/Get-UDPsuJobThemePalette.ps1' 63
#Region './Private/Get-UDPsuJobThemeStyleBlock.ps1' -1

function Get-UDPsuJobThemeStyleBlock
{
    <#
        .SYNOPSIS
        Builds the <style> block that themes a PSU job UD component instance.

        .DESCRIPTION
        Emits CSS custom properties (--psu-term-*) scoped to the component's own element id,
        so multiple instances on the same page never collide. When Theme is 'Auto', both the
        light (default) and dark (activated via PSU's own `html[data-theme="dark"]` attribute,
        which PSU's built-in theme switch sets and updates live) rule sets are emitted, so the
        component automatically follows PSU's theme without a page reload. When Theme is
        'Light' or 'Dark', only that palette is emitted, overriding the ambient PSU theme for
        this component instance. Also emits the show/hide rules used to swap between the
        black/white themed icon variants rendered by ConvertTo-UDPsuThemedIconMarkup.

        .PARAMETER ElementId
        The root element id of the component instance to scope these CSS rules to.

        .PARAMETER Theme
        'Auto' (default) follows PSU's theme switch live. 'Light' or 'Dark' force that theme
        for this component instance regardless of the ambient PSU theme.

        .PARAMETER CustomCss
        Optional raw CSS appended after the theme rules, so it naturally overrides them
        (e.g. targeting `#<ElementId> { --psu-term-accent: #ff00ff; }` or any other selector).

        .EXAMPLE
        Get-UDPsuJobThemeStyleBlock -ElementId 'psu-job-terminal-123' -Theme 'Auto'
    #>

    [CmdletBinding()]
    [OutputType([System.String])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $ElementId,

        [Parameter()]
        [ValidateSet('Auto', 'Light', 'Dark')]
        [System.String]
        $Theme = 'Auto',

        [Parameter()]
        [System.String]
        $CustomCss = ''
    )

    $palette = Get-UDPsuJobThemePalette

    $toVarsCss = {
        param($set)
        ($set.GetEnumerator() | ForEach-Object { '--psu-term-{0}:{1};' -f $_.Key, $_.Value }) -join ''
    }

    $rules = switch ($Theme)
    {
        'Light'
        {
            '#{0} {{ {1} }}' -f $ElementId, (& $toVarsCss $palette.Light)
        }
        'Dark'
        {
            '#{0} {{ {1} }}' -f $ElementId, (& $toVarsCss $palette.Dark)
        }
        Default
        {
            (
                ('#{0} {{ {1} }}' -f $ElementId, (& $toVarsCss $palette.Light)) +
                ('html[data-theme="dark"] #{0} {{ {1} }}' -f $ElementId, (& $toVarsCss $palette.Dark)) +
                ('html[data-theme="dark"] #{0} .psu-theme-light-only {{ display:none; }}' -f $ElementId) +
                ('html[data-theme="dark"] #{0} .psu-theme-dark-only {{ display:contents; }}' -f $ElementId)
            )
        }
    }

    return '<style>{0}{1}</style>' -f $rules, $CustomCss
}
#EndRegion './Private/Get-UDPsuJobThemeStyleBlock.ps1' 79
#Region './Public/Get-ModuleAiTool.ps1' -1

function Get-ModuleAiTool
{
    <#
        .SYNOPSIS
        Retrieves the AI tool metadata for functions decorated with [AiTool()] in a module.

        .DESCRIPTION
        Scans the exported functions of a module for the AiTool attribute and builds a
        metadata dictionary for each one, suitable for registering with PowerShell
        Universal via New-PSUScript and New-PSUAiTool (see Import-PSUAiTool).

        .PARAMETER Module
        The module to scan for AI tool functions.

        .PARAMETER Environment
        Overrides the PSU environment the backing script should run in. Falls back to the
        Environment set on the attribute, when present.

        .PARAMETER Authenticated
        Overrides whether discovered tools require an authenticated user. Falls back to
        the Authenticated value set on the attribute.

        .PARAMETER Mcp
        Overrides whether discovered tools are exposed through MCP. Falls back to the Mcp
        value set on the attribute (defaults to $true).

        .EXAMPLE
        Get-ModuleAiTool -Module PSUConfig
    #>

    [CmdletBinding()]
    [OutputType([System.Collections.IDictionary])]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', Justification = 'Output type is a dictionary')]
    param
    (
        [Parameter()]
        [Microsoft.PowerShell.Commands.ModuleSpecification]
        $Module,

        [Parameter()]
        [string]
        $Environment,

        [Parameter()]
        [switch]
        $Authenticated,

        [Parameter()]
        [switch]
        $Mcp
    )

    $aiToolModule = Get-Module $Module -ErrorAction Stop
    Write-Verbose -Message ('Module: {0} Version: {1}' -f $aiToolModule.Name, $aiToolModule.Version)

    $aiToolFunctions = $aiToolModule.ExportedFunctions.Values.Where{
        $_.ScriptBlock.Attributes.Where{
            $_.TypeId.ToString() -eq 'AiTool' -and $_.IsAiTool -eq $true
        }
    }

    Write-Verbose -Message ('Discovered AI tool functions are: {0}' -f ($aiToolFunctions.Name -join ', '))
    foreach ($aiToolFunction in $aiToolFunctions)
    {
        # You can have more than one AiTool attribute on a single function, e.g. to
        # register it under multiple names/roles.
        $aiToolAttributes = $aiToolFunction.ScriptBlock.Attributes.Where{
            $_.TypeId.ToString() -eq 'AiTool' -and $_.IsAiTool -eq $true
        }

        foreach ($aiToolAttribute in $aiToolAttributes)
        {
            $name = $aiToolAttribute.Name
            if ([string]::IsNullOrEmpty($name))
            {
                $name = $aiToolFunction.Name
            }

            $description = $aiToolAttribute.Description
            if ([string]::IsNullOrEmpty($description))
            {
                $description = (Get-Help -Name $aiToolFunction.Name -ErrorAction SilentlyContinue).Synopsis
            }

            $scriptFullPath = '{0}\{1}' -f $aiToolModule.Name, $aiToolFunction.Name

            $aiToolData = @{
                Name           = $name
                Description    = $description
                ScriptFullPath = $scriptFullPath
                FunctionInfo   = $aiToolFunction
            }

            if ($PSBoundParameters.ContainsKey('Environment'))
            {
                $aiToolData['Environment'] = $Environment
            }
            elseif (-not [string]::IsNullOrEmpty($aiToolAttribute.Environment))
            {
                $aiToolData['Environment'] = $aiToolAttribute.Environment
            }

            if ($PSBoundParameters.ContainsKey('Authenticated'))
            {
                $aiToolData['Authenticated'] = $Authenticated.IsPresent
            }
            else
            {
                $aiToolData['Authenticated'] = $aiToolAttribute.Authenticated
            }

            if ($aiToolAttribute.Role -and $aiToolAttribute.Role.Count -gt 0)
            {
                $aiToolData['Role'] = $aiToolAttribute.Role
            }

            if ($PSBoundParameters.ContainsKey('Mcp'))
            {
                $aiToolData['Mcp'] = $Mcp.IsPresent
            }
            else
            {
                $aiToolData['Mcp'] = $aiToolAttribute.Mcp
            }

            $aiToolData
        }
    }
}
#EndRegion './Public/Get-ModuleAiTool.ps1' 129
#Region './Public/Get-ModuleApiEndpoint.ps1' -1

function Get-ModuleApiEndpoint
{
    <#
    .SYNOPSIS
        Retrieves the API endpoint for the current module.

    .DESCRIPTION
        This function retrieves the API endpoint metadata for a specified service by getting the public functions of the module.

    .EXAMPLE
        Get-ModuleApiEndpoint
    #>

    [CmdletBinding()]
    [OutputType([System.Collections.IDictionary])]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'No parameters needed')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', Justification = 'Output type is a dictionary')]
    param
    (
        [Parameter()]
        [Microsoft.PowerShell.Commands.ModuleSpecification]
        $Module,

        [Parameter()]
        [ValidateSet('Information', 'Debug', 'Verbose')]
        [string[]]
        $LogLevel,

        [Parameter()]
        [string]
        $Environment,

        [Parameter()]
        [switch]
        $Authentication,

        [Parameter()]
        [string]
        $ApiPrefix
    )

    # TODO: Load module in Thread and process it there (once done the thread is closed and DLL handles freed)
    # $thread = [powershell]::Create([System.Management.Automation.RunspaceMode]::CurrentRunspace)
    $apiModule = Get-Module $Module -ErrorAction Stop
    Write-Verbose -Message ('Module: {0} Version: {1}' -f $apiModule.Name, $apiModule.Version)
    $endpoints = $apiModule.ExportedFunctions.Values.Where{
        $_.ScriptBlock.Attributes.Where{
            $_.TypeId.ToString() -eq 'APIEndpoint' -and $_.IsEndpoint -eq $true
        }
    }

    Write-Verbose -Message ('Discovered API functions are: {0}' -f ($endpoints.Name -join ', '))
    foreach ($functionEndpoint in $endpoints)
    {
        # You can have more than one endpoint for a single function
        # by repeating the APIEndpoint attribute
        # This is useful for versioning or different methods, and overriding parameters signature (say between v1 and v2)
        $ApiEndpoints = $functionEndpoint.ScriptBlock.Attributes.Where{
            $_.TypeId.ToString() -eq 'APIEndpoint' -and $_.IsEndpoint -eq $true
        }

        # TODO: $ApiInputs = $functionEndpoint.ScriptBlock.Attributes.Where{ $_.TypeId.ToString() -eq 'APIInput'}
        # TODO: $ApiOutputs = $functionEndpoint.ScriptBlock.Attributes.Where{ $_.TypeId.ToString() -eq 'APIOutput'}

        foreach ($ApiEndpoint in $ApiEndpoints)
        {
            $urlPath        = $ApiEndpoint.Path
            $version        = $ApiEndpoint.version
            $Name           = $ApiEndpoint.Name

            if ($PSBoundParameters.ContainsKey('ApiPrefix'))
            {
                Write-Debug -Message ('Using ApiPrefix from Parameter (override) {0}' -f $ApiPrefix)
            }
            elseif (-not [string]::IsNullOrEmpty($ApiEndpoints.ApiPrefix))
            {
                $ApiPrefix = $ApiEndpoint.ApiPrefix
            }

            if ($PSBoundParameters.ContainsKey('Documentation'))
            {
                Write-Debug -Message ('Using documentation from Parameter (override) {0}' -f $Documentation)
            }
            elseif (-not [string]::IsNullOrEmpty($ApiEndpoints.Documentation))
            {
                $Documentation = $ApiEndpoint.Documentation
            }

            if ([string]::IsNullOrEmpty($urlPath))
            {
                $urlPath = $functionEndpoint.Noun.ToLower()
            }

            if ($PSBoundParameters.ContainsKey('ApiPrefix'))
            {
                $apiPrefix = $PSBoundParameters['ApiPrefix']
                # Building the /{apiPrefix}/endpoint style URL
                # not using the endpoint versioning from the attribute
                $url = ('{0}/{1}' -f $apiPrefix, $urlPath.TrimStart('/').ToLower()) -replace '//', '/'
            }
            else
            {
                # Building the /api/v1/endpoint style URL
                $url = (('/{0}/{1}/{2}' -f $apiPrefix, $version, $urlPath.TrimStart('/')).ToLower()) -replace '//', '/'
            }

            Write-Information -MessageData "Processing endpoint: $url for function: $($functionEndpoint.Name)"
            if ([string]::IsNullOrEmpty($Name))
            {
                $Name = '{0}{1}{2}' -f $functionEndpoint.verb.ToLower(), $functionEndpoint.Noun, $version
            }

            $description = $ApiEndpoint.Description
            if ([string]::IsNullOrEmpty($description))
            {
                $description = (Get-Help -Name $functionEndpoint.Name -ErrorAction SilentlyContinue).Description.Text
            }

            $method = $ApiEndpoint.Method
            if ([string]::IsNullOrEmpty($method))
            {
                $method = Get-HttpMethodFromPSVerb -Verb $functionEndpoint.Verb
            }

            $apiEndpointData = @{
                # Mandatory fields for the endpoint
                url          = $url
                Description  = $description
                Method       = $method
                FunctionInfo = $functionEndpoint
            }

            if (-not [string]::IsNullOrEmpty($documentation))
            {
                $apiEndpointData['Documentation'] = $documentation
            }

            if ($PSBoundParameters.ContainsKey('LogLevel'))
            {
                $apiEndpointData['LogLevel'] = $PSBoundParameters['LogLevel']
            }
            elseif ($ApiEndpoint.LogLevel -and $ApiEndpoint.LogLevel.Count -gt 0)
            {
                $apiEndpointData['LogLevel'] = $ApiEndpoint.LogLevel
            }

            # Optional fields for the endpoint
            if ($PSBoundParameters.ContainsKey('Environment'))
            {
                $apiEndpointData['Environment'] = $PSBoundParameters['Environment']
            }
            elseif (-not [string]::IsNullOrEmpty($ApiEndpoint.Environment))
            {
                $apiEndpointData['Environment'] = $ApiEndpoint.Environment
            }

            if ($PSBoundParameters.ContainsKey('Authentication'))
            {
                $apiEndpointData['Authentication'] = $Authentication.IsPresent
            }
            else
            {
                $apiEndpointData['Authentication'] = $ApiEndpoint.Authentication
            }

            if ($PSBoundParameters.ContainsKey('Role'))
            {
                $apiEndpointData['Role'] = $PSBoundParameters['Role']
            }
            elseif ($ApiEndpoint.Role -and $ApiEndpoint.Role.Count -gt 0)
            {
                $apiEndpointData['Role'] = $ApiEndpoint.Role
            }

            if ($ApiEndpoint.Tag -gt 0)
            {
                $apiEndpointData['Tag'] = $ApiEndpoint.Tag
            }

            if ($ApiEndpoint.Timeout -and $ApiEndpoint.Timeout -gt 0)
            {
                $apiEndpointData['Timeout'] = $ApiEndpoint.Timeout
            }

            if (-not [string]::IsNullOrEmpty($ApiEndpoint.ContentType))
            {
                $apiEndpointData['ContentType'] = $ApiEndpoint.ContentType
            }

            if ($ApiEndpoint.Parameters -and $ApiEndpoint.Parameters -is [scriptblock])
            {
                $apiEndpointData['Parameters'] = $ApiEndpoint.Parameters
            }

            $apiEndpointData
        }
    }
}
#EndRegion './Public/Get-ModuleApiEndpoint.ps1' 198
#Region './Public/Get-ModuleApiEndpointScriptblock.ps1' -1


# using namespace System.Collections
function Get-ModuleApiEndpointScriptblock
{
    <#
        .SYNOPSIS
        Returns a scriptblock that can be used as an endpoint for a PSU API.

        .DESCRIPTION
        This function returns a scriptblock that can be used as an endpoint for the module API.
        The scriptblock will return the module's API endpoints.

        .EXAMPLE
        $scriptBlock = Get-ModuleApiEndpointScriptblock
        Invoke-Command -ScriptBlock $scriptBlock

        .OUTPUTS
        [PSCustomObject[]] - An array of custom objects representing the module's API endpoints.
    #>

    [CmdletBinding()]
    [OutputType([ScriptBlock])]
    param
    (
        [Parameter(Mandatory = $true)]
        [IDictionary]
        $ModuleApiEndpoint
    )

    #region Force Endpoint to have pascalCase parameters
    $paramBlock = $ModuleApiEndpoint.FunctionInfo.ScriptBlock.ast.Body.ParamBlock
    $paramBlockStr = $paramBlock.Extent.Text
    # change the Extent to camelCase the Extent name
    $paramBlock.Parameters | ForEach-Object {
        # Update $_.Name to be camelCase
        $parameter = $_
        $oldParamName = $parameter.Name.VariablePath.UserPath

        # If the parameter name is not in camelCase, convert it
        $FirstChar = $oldParamName.Substring(0, 1) # first letter after the $
        $RestOfName = $oldParamName.Substring(1) # rest of the name after the first letter
        $newParamName = '{0}{1}' -f $FirstChar.ToLowerInvariant(),$RestOfName
        $replacedParamName = $parameter.Name.Extent.text -creplace ([regex]::Escape($oldParamName)), $newParamName
        $startOffSet = $parameter.Name.Extent.StartOffset - $paramBlock.Extent.StartOffset
        $paramBlockStr = $paramBlockStr.Remove($startOffSet,$replacedParamName.Length).Insert($startOffSet,$replacedParamName)

        #TODO: Change switch parameter to bool if PSU prefers that
    }
    #TODO: Add the Inputs/Outputs documentation to the CBH section
    #endregion

    if ($PSBoundParameters.ContainsKey('Environment'))
    {
        $paramBlockStr = $paramBlockStr -replace '\$Environment', '$PSBoundParameters["Environment"]'
    }

    [string] $InformationAction, $VerboseAction, $DebugAction = ''

    if ($ModuleApiEndpoint.LogLevel -contains 'Information')
    {
        $InformationAction = '$InformationPreference = ''Continue'';'
    }

    if ($ModuleApiEndpoint.LogLevel -contains 'Verbose')
    {
        $VerboseAction = '$VerbosePreference = ''Continue'';'
    }

    if ($ModuleApiEndpoint.LogLevel -contains 'Debug')
    {
        $DebugAction = '$DebugPreference = ''Continue'';'
    }

    $testingStream = '
    Write-Host "this is a Write-Host test message"
    Write-Information "this is a Write-Information test message"
    Write-Debug -Message "This is a Write-Debug test message"
    Write-Verbose -Verbose -Message "This is a Write-Verbose test message"
'

    $loggingConfig = @(
        $InformationAction,
        $VerboseAction,
        $DebugAction
        # $testingStream
        #TODO: raise a bug with PowerShell Universal (only Information/Write-Host works)
    ) -join ''


    $functionCall = '{0}{1} {2} @PSBoundParameters{1}' -f $loggingConfig,"`r`n",$ModuleApiEndpoint.FunctionInfo.Name
    # $functionCall = '$result = {0} @PSBoundParameters' -f $ModuleApiEndpoint.FunctionInfo.Name
    # JSON automatic serialization
    # $jsonConvert = '$result | ConvertTo-Json -Depth 10'
    $scriptBlockStr = ' {0}{1}{1} {2}{1} {3}' -f $paramBlockStr,"`r`n", $functionCall,$jsonConvert
    Write-Information -MessageData ('Creating scriptblock for module API endpoint: {0}. {1}' -f $ModuleApiEndpoint.url, $scriptBlockStr)
    # TODO: Implement Generic HTTP codes

    # Create a scriptblock from the string
    $scriptBlock = [scriptblock]::Create($scriptBlockStr)
    return $scriptBlock
}
#EndRegion './Public/Get-ModuleApiEndpointScriptblock.ps1' 100
#Region './Public/Import-PSUAiTool.ps1' -1

function Import-PSUAiTool
{
    <#
        .SYNOPSIS
        Discovers functions decorated with [AiTool()] in a module and registers them as
        PowerShell Universal AI tools.

        .DESCRIPTION
        For each function found via Get-ModuleAiTool, ensures a backing PSU Script
        resource exists (New-PSUScript, keyed by <Module>\<Command>) and registers the
        corresponding AI tool (New-PSUAiTool). Unlike Import-PSUEndpoint, which builds an
        inline wrapper scriptblock for New-PSUEndpoint, New-PSUAiTool requires an existing
        Script resource referenced by its ScriptFullPath, so this function always
        (re-)declares that backing script alongside the AI tool. Both New-PSUScript and
        New-PSUAiTool are idempotent when called repeatedly with the same identity, which
        is safe since PSU resource declaration files are re-executed on every config
        reload.

        .PARAMETER Module
        The module to scan for AI tool functions.

        .PARAMETER Environment
        The PSU environment the backing script should run in. Falls back to the
        Environment set on the AiTool attribute, when present.

        .PARAMETER Authenticated
        Forces whether discovered tools require an authenticated user, overriding the
        AiTool attribute's value.

        .PARAMETER Mcp
        Forces whether discovered tools are exposed through MCP, overriding the AiTool
        attribute's value.

        .EXAMPLE
        Import-PSUAiTool -Module PSUConfig -Environment 'PSUConfig'
    #>

    [CmdletBinding(SupportsShouldProcess = $true)]
    [OutputType([Object])]
    param
    (
        [Parameter()]
        [Microsoft.PowerShell.Commands.ModuleSpecification]
        $Module,

        [Parameter()]
        [string]
        $Environment,

        [Parameter()]
        [switch]
        $Authenticated,

        [Parameter()]
        [switch]
        $Mcp
    )

    $moduleAiToolParams = @{}

    if ($PSBoundParameters.ContainsKey('Module'))
    {
        $moduleAiToolParams['Module'] = $Module
    }

    if ($PSBoundParameters.ContainsKey('Environment'))
    {
        $moduleAiToolParams['Environment'] = $Environment
    }

    if ($PSBoundParameters.ContainsKey('Authenticated'))
    {
        $moduleAiToolParams['Authenticated'] = $Authenticated.IsPresent
    }

    if ($PSBoundParameters.ContainsKey('Mcp'))
    {
        $moduleAiToolParams['Mcp'] = $Mcp.IsPresent
    }

    $aiTools = Get-ModuleAiTool @moduleAiToolParams
    $aiTools | ForEach-Object -Process {
        $aiTool = $_

        $newScriptParams = @{
            Module  = $Module
            Command = $aiTool.FunctionInfo.Name
        }
        if ($aiTool.ContainsKey('Environment'))
        {
            $newScriptParams['Environment'] = $aiTool.Environment
        }

        if ($PSCmdlet.ShouldProcess(('Registering backing PSU Script for AI tool {0}: {1}' -f $aiTool.Name, ($newScriptParams | ConvertTo-Json -Compress))))
        {
            New-PSUScript @newScriptParams
        }

        $newAiToolParams = @{
            Name           = $aiTool.Name
            Description    = $aiTool.Description
            ScriptFullPath = $aiTool.ScriptFullPath
        }

        if ($aiTool.ContainsKey('Authenticated'))
        {
            $newAiToolParams['Authenticated'] = $aiTool.Authenticated
        }

        if ($aiTool.ContainsKey('Role'))
        {
            $newAiToolParams['Role'] = $aiTool.Role
        }

        if ($aiTool.ContainsKey('Mcp'))
        {
            $newAiToolParams['Mcp'] = $aiTool.Mcp
        }

        if ($PSCmdlet.ShouldProcess(('Creating PSUAiTool with {0}' -f ($newAiToolParams | ConvertTo-Json -Compress))))
        {
            New-PSUAiTool @newAiToolParams
        }
    }
}
#EndRegion './Public/Import-PSUAiTool.ps1' 125
#Region './Public/Import-PSUEndpoint.ps1' -1

function Import-PSUEndpoint
{
    [CmdletBinding(SupportsShouldProcess = $true)]
    [OutputType([Object])]
    param
    (
        [Parameter()]
        [Microsoft.PowerShell.Commands.ModuleSpecification]
        $Module,

        [Parameter()]
        [string]
        $Environment,

        [Parameter()]
        [string]
        $ApiPrefix,

        [Parameter()]
        [string]
        # Name of the documentation endpoint to use for this endpoint.
        # If not specified, the one defined by the attribute will be used, or the default one.
        $Documentation,

        [Parameter()]
        [switch]
        # Force authentication on the endpoint regardless of its configuration.
        $Authentication,

        [Parameter()]
        [ValidateSet('Information','Debug', 'Verbose')]
        [string[]]
        $LogLevel = 'Information'
    )

    $moduleApiEndpointParams = @{}

    if ($PSBoundParameters.ContainsKey('Module'))
    {
        $moduleApiEndpointParams['Module'] = $Module
    }

    if ($PSBoundParameters.ContainsKey('Environment'))
    {
        $moduleApiEndpointParams['Environment'] = $Environment
    }

    if ($PSBoundParameters.ContainsKey('ApiPrefix'))
    {
        $moduleApiEndpointParams['ApiPrefix'] = $ApiPrefix
    }

    if ($PSBoundParameters.ContainsKey('Authentication'))
    {
        $moduleApiEndpointParams['Authentication'] = $Authentication.IsPresent
    }

    if ($PSBoundParameters.ContainsKey('LogLevel'))
    {
        $moduleApiEndpointParams['LogLevel'] = $LogLevel
    }

    if ($PSBoundParameters.ContainsKey('Documentation'))
    {
        $moduleApiEndpointParams['Documentation'] = $Documentation
    }

    $endpoints = Get-ModuleApiEndpoint @moduleApiEndpointParams
    $endpoints | ForEach-Object -Process {
        # Create a new endpoint for each function that has the APIEndpoint attribute
        $psuEndpointParams = @{} + $_
        $psuCommand = Get-Command -Name 'New-PSUEndpoint' -Module Universal
        $psuEndpointParams.Keys.Where{
            $_ -notin $psuCommand.Parameters.Keys
        } | ForEach-Object {
            $null = $psuEndpointParams.Remove($_)
        }

        if ($false -eq $psuEndpointParams['Authentication'] -and $psuEndpointParams.ContainsKey('Role'))
        {
            $null = $psuEndpointParams.Remove('Role')
        }

        $moduleEndpointScriptblockParameters = @{
            ModuleApiEndpoint = $_
        }

        $endpointScriptBlock = Get-ModuleApiEndpointScriptblock @moduleEndpointScriptblockParameters

        if ($PSCmdlet.ShouldProcess(('Creating PSUEndpoint with {0}' -f ($psuEndpointParams | ConvertTo-Json -depth 6 -Compress))))
        {
            New-PSUEndpoint @psuEndpointParams -Endpoint $endpointScriptBlock
        }
    }
}
#EndRegion './Public/Import-PSUEndpoint.ps1' 96
#Region './Public/UDComponents/New-UDPsuJobHeader.ps1' -1

function New-UDPsuJobHeader
{
    <#
        .SYNOPSIS
        Renders a summary header card for a PowerShell Universal job.

        .DESCRIPTION
        Produces a styled HTML metadata card showing the script path, a
        copy-to-clipboard job ID chip, status badge, and timestamps
        (Created, Started, Completed, Duration). Designed to pair with
        New-UDPsuJobTerminalView on a job detail page, but can be used
        independently wherever a concise job summary is needed.

        Colors are driven by CSS custom properties defined in a per-instance
        <style> block (see Get-UDPsuJobThemeStyleBlock in synedgy.universal.helper),
        so the card automatically follows PSU's own light/dark theme switch (Theme
        'Auto', the default), or can be forced to a specific theme, or further
        customized via -CustomCss.

        .PARAMETER Job
        The PSU job object returned by Get-PSUJob. Used to derive all
        displayed fields.

        .PARAMETER ElementId
        Root element id, used to scope the theme CSS to this instance. Defaults
        to "psu-job-header-<JobId>".

        .PARAMETER Theme
        'Auto' (default) follows PSU's own theme switch live, with no page reload
        required. 'Light' or 'Dark' force that theme for this component instance
        regardless of the ambient PSU theme.

        .PARAMETER CustomCss
        Optional raw CSS appended after the theme rules, so it naturally overrides
        them. For example, override a single color variable:
        '#psu-job-header-123 { --psu-term-accent: #ff00ff; }'.

        .EXAMPLE
        $job = Get-PSUJob -Id $id -AppToken $token
        New-UDPsuJobHeader -Job $job

        .EXAMPLE
        # Typical usage on a job detail page
        $job = Get-PSUJob -Id ([Int64]$id)
        New-UDPsuJobHeader -Job $job
        New-UDPsuJobTerminalView -JobId $job.Id -JobStatus ([string]$job.Status) `
            -JobOutputSnapshot @($job.Output) -IncludeStructuredTable

        .EXAMPLE
        # Force dark theme regardless of PSU's ambient theme
        New-UDPsuJobHeader -Job $job -Theme Dark
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param
    (
        [Parameter(Mandatory)]
        $Job,

        [Parameter()]
        [System.String]
        $ElementId = ('psu-job-header-{0}' -f $Job.Id),

        [Parameter()]
        [ValidateSet('Auto', 'Light', 'Dark')]
        [System.String]
        $Theme = 'Auto',

        [Parameter()]
        [System.String]
        $CustomCss = ''
    )

    $formatDt = {
        param($dt)
        if ($dt -and ([datetime]$dt).Year -gt 1) { '{0:yyyy-MM-dd HH:mm:ss}' -f ([datetime]$dt) } else { 'N/A' }
    }

    $createdStr   = & $formatDt $Job.CreatedTime
    $startedStr   = & $formatDt $Job.StartTime
    $completedStr = & $formatDt $Job.EndTime

    $durationStr = 'N/A'
    if ($Job.StartTime -and $Job.EndTime)
    {
        $s = [datetime]$Job.StartTime
        $e = [datetime]$Job.EndTime
        if ($s.Year -gt 1 -and $e.Year -gt 1 -and $e -gt $s)
        {
            $span        = $e - $s
            $durationStr = if ($span.TotalHours -ge 1)      { '{0}h {1:mm\:ss}' -f [int]$span.TotalHours, $span }
                           elseif ($span.TotalMinutes -ge 1) { '{0:m\:ss}' -f $span }
                           else                              { '{0}s' -f [int]$span.TotalSeconds }
        }
    }

    $statusStyle = switch ([string]$Job.Status)
    {
        'Completed' { 'background:#1b5e20;color:#66bb6a;' }
        'Failed'    { 'background:#7f0000;color:#ef9a9a;' }
        'Warning'   { 'background:#4a3000;color:#ffa726;' }
        'Running'   { 'background:#0d47a1;color:#90caf9;' }
        'Error'     { 'background:#7f0000;color:#ef9a9a;' }
        Default     { 'background:var(--psu-term-chip-bg);color:var(--psu-term-muted-fg);' }
    }

    $jobIdStr    = [string]$Job.Id
    $scriptPath  = [System.Net.WebUtility]::HtmlEncode([string]$Job.ScriptFullPath)
    $statusLabel = [System.Net.WebUtility]::HtmlEncode(([string]$Job.Status).ToUpper())

    $chipId      = 'job-id-chip-' + $jobIdStr
    $copyOnClick = (
        "navigator.clipboard.writeText('" + $jobIdStr + "')." +
        "then(function(){" +
        "var e=document.getElementById('" + $chipId + "');" +
        "var o=e.innerHTML;" +
        "e.textContent='Copied!';" +
        "e.style.color='#66bb6a';" +
        "setTimeout(function(){e.innerHTML=o;e.style.color='';},1500)" +
        "}).catch(function(){})"
    )

    $metaCol = 'display:flex;flex-direction:column;gap:3px;'
    $metaLbl = 'color:var(--psu-term-muted-fg);font-size:10px;font-family:Consolas,Monaco,monospace;text-transform:uppercase;letter-spacing:0.6px;'
    $metaVal = 'color:var(--psu-term-fg);font-size:12px;font-family:Consolas,Monaco,monospace;'

    # Inline SVG decoration - terminal icon top-right of the card
    $decorSvgHtml = ''
    $moduleBase   = $MyInvocation.MyCommand.Module.ModuleBase
    if ($moduleBase)
    {
        $decorSvg = ConvertTo-UDPsuThemedIconMarkup -ModuleBase $moduleBase -IdPrefix ('jh-' + $jobIdStr) -ExtraStyle 'width:auto;height:100%;' -Theme $Theme
        if ($decorSvg)
        {
            $decorSvgHtml = '<div style="position:absolute;top:10px;right:16px;height:70px;opacity:0.18;pointer-events:none;">' + $decorSvg + '</div>'
        }
    }

    $themeStyleBlock = Get-UDPsuJobThemeStyleBlock -ElementId $ElementId -Theme $Theme -CustomCss $CustomCss

    $markup = (
        $themeStyleBlock +
        '<div id="' + $ElementId + '" style="position:relative;overflow:hidden;background:var(--psu-term-bg);border-radius:8px;padding:16px 20px;margin-bottom:12px;border:1px solid var(--psu-term-border);">' +
        $decorSvgHtml +

        '<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:14px;">' +
        '<span style="color:var(--psu-term-fg);font-family:Consolas,Monaco,monospace;font-size:13px;font-weight:600;">' + $scriptPath + '</span>' +
        '<span id="' + $chipId + '" onclick="' + $copyOnClick + '" title="Click to copy Job ID" ' +
        'style="background:var(--psu-term-chip-bg);color:var(--psu-term-muted-fg);font-family:Consolas,Monaco,monospace;font-size:11px;' +
        'padding:3px 10px;border-radius:12px;border:1px solid var(--psu-term-border);cursor:pointer;user-select:none;white-space:nowrap;">' +
        '#' + $jobIdStr + '&nbsp;&nbsp;&#10697;' +
        '</span>' +
        '</div>' +

        '<div style="display:flex;gap:24px;flex-wrap:wrap;align-items:flex-start;">' +

        '<div style="' + $metaCol + '">' +
        '<span style="' + $metaLbl + '">Status</span>' +
        '<span style="' + $statusStyle + 'font-size:11px;font-family:Consolas,Monaco,monospace;font-weight:700;padding:2px 10px;border-radius:3px;letter-spacing:0.3px;">' + $statusLabel + '</span>' +
        '</div>' +

        '<div style="' + $metaCol + '">' +
        '<span style="' + $metaLbl + '">Created</span>' +
        '<span style="' + $metaVal + '">' + [System.Net.WebUtility]::HtmlEncode($createdStr) + '</span>' +
        '</div>' +

        '<div style="' + $metaCol + '">' +
        '<span style="' + $metaLbl + '">Started</span>' +
        '<span style="' + $metaVal + '">' + [System.Net.WebUtility]::HtmlEncode($startedStr) + '</span>' +
        '</div>' +

        '<div style="' + $metaCol + '">' +
        '<span style="' + $metaLbl + '">Completed</span>' +
        '<span style="' + $metaVal + '">' + [System.Net.WebUtility]::HtmlEncode($completedStr) + '</span>' +
        '</div>' +

        '<div style="' + $metaCol + '">' +
        '<span style="' + $metaLbl + '">Duration</span>' +
        '<span style="' + $metaVal + '">' + [System.Net.WebUtility]::HtmlEncode($durationStr) + '</span>' +
        '</div>' +

        '</div>' +
        '</div>'
    )

    New-UDHtml -Markup $markup
}
#EndRegion './Public/UDComponents/New-UDPsuJobHeader.ps1' 188
#Region './Public/UDComponents/New-UDPsuJobTerminalView.ps1' -1

function New-UDPsuJobTerminalView
{
    <#
        .SYNOPSIS
        Renders a terminal-style view for a PowerShell Universal job.

        .DESCRIPTION
        Displays job output in a terminal-style window. The terminal content panel is a
        New-UDDynamic that refreshes on demand via a button click, fetching the latest
        output from Get-PSUJobOutput each time. The structured events tab and status bar
        are rendered once from the initial fetch at page load.

        Uses private helpers from synedgy.universal.helper: ConvertFrom-PsuJobOutputEntry,
        Convert-AnsiToHtml, Convert-PlainTextToHtml.

        .PARAMETER JobId
        The PSU job identifier to render output for.

        .PARAMETER AppToken
        PSU app token used to call Get-PSUJobOutput. If omitted, the call is made without
        an explicit token (suitable when running inside the PSU dashboard with an ambient
        connection).

        .PARAMETER UniversalServerUrl
        PSU server URL for Get-PSUJobOutput. Defaults to UniversalServerUrl from module
        config (via Get-ModuleConfig, when available), then http://localhost:5000.

        .PARAMETER MaxRows
        Maximum number of most-recent output rows to render. Use 0 for no limit.

        .PARAMETER IncludeStructuredTable
        Include a structured events tab alongside the terminal view.

        .PARAMETER JobStatus
        Current job status string (passed to the terminal dynamic for context).

        .PARAMETER JobOutputSnapshot
        Pre-fetched job output records used as initial data for the structured table
        and status bar. The terminal dynamic performs its own live fetch.

        .PARAMETER ElementId
        Root element id. Defaults to "psu-job-terminal-<JobId>".

        .PARAMETER AutoRefreshInterval
        Interval in seconds for auto-refreshing the terminal output when the job is active
        (not in a terminal state). Defaults to 5. Set to 0 to disable auto-refresh entirely.

        .PARAMETER Theme
        'Auto' (default) follows PSU's own theme switch live (PSU sets a `data-theme`
        attribute on <html> that updates instantly when the user toggles it, with no page
        reload), so this component's colors and icon variant switch automatically. 'Light'
        or 'Dark' force that theme for this component instance regardless of the ambient
        PSU theme.

        .PARAMETER CustomCss
        Optional raw CSS appended after the theme rules, so it naturally overrides them.
        For example, override a single color variable:
        '#psu-job-terminal-123 { --psu-term-accent: #ff00ff; }'.

        .PARAMETER HideLineNumbers
        Hides line numbers by default for a first-time viewer (shown by default when
        omitted). Once a viewer manually toggles line numbers, their choice is remembered
        in the browser's localStorage and takes precedence over this default on later
        visits.

        .PARAMETER HideTimestamps
        Hides timestamps by default for a first-time viewer (shown by default when
        omitted). Once a viewer manually toggles timestamps, their choice is remembered in
        the browser's localStorage and takes precedence over this default on later visits.

        .EXAMPLE
        New-UDPsuJobTerminalView -JobId 10494 -AppToken $secret:automation_bot_token -IncludeStructuredTable -AutoRefreshInterval 10

        .EXAMPLE
        # Force light theme regardless of PSU's ambient theme
        New-UDPsuJobTerminalView -JobId 10494 -Theme Light

        .EXAMPLE
        # Hide line numbers and timestamps by default for first-time viewers
        New-UDPsuJobTerminalView -JobId 10494 -HideLineNumbers -HideTimestamps
    #>

    [CmdletBinding(
        SupportsShouldProcess = $true,
        ConfirmImpact = 'Low'
    )]
    [OutputType([object])]
    param
    (
        [Parameter(Mandatory = $true)]
        [Int64]
        $JobId,

        [Parameter()]
        [System.String]
        $AppToken,

        [Parameter()]
        [System.String]
        $UniversalServerUrl,

        [Parameter()]
        [ValidateRange(0, 5000)]
        [Int32]
        $MaxRows = 0,

        [Parameter()]
        [System.Management.Automation.SwitchParameter]
        $IncludeStructuredTable,

        [Parameter()]
        [System.String]
        $JobStatus,

        [Parameter()]
        [object[]]
        $JobOutputSnapshot = @(),

        [Parameter()]
        [System.String]
        $ElementId = ('psu-job-terminal-{0}' -f $JobId),

        [Parameter()]
        [ValidateRange(0, 300)]
        [Int32]
        $AutoRefreshInterval = 5,

        [Parameter()]
        [ValidateSet('Auto', 'Light', 'Dark')]
        [System.String]
        $Theme = 'Auto',

        [Parameter()]
        [System.String]
        $CustomCss = '',

        [Parameter()]
        [System.Management.Automation.SwitchParameter]
        $HideLineNumbers,

        [Parameter()]
        [System.Management.Automation.SwitchParameter]
        $HideTimestamps
    )

    $resolvedAppToken = $AppToken

    $configUrl    = if (Get-Command -Name 'Get-ModuleConfig' -ErrorAction SilentlyContinue)
    {
        try { [string](Get-ModuleConfig).UniversalServerUrl } catch { $null }
    }
    $resolvedUrl  = if (-not [string]::IsNullOrWhiteSpace($UniversalServerUrl)) { $UniversalServerUrl.TrimEnd('/') }
                    elseif (-not [string]::IsNullOrWhiteSpace($configUrl))      { $configUrl.TrimEnd('/') }
                    else                                                         { 'http://localhost:5000' }

    $resolvedIncludeStructuredTable = $IncludeStructuredTable.IsPresent

    # --- Initial fetch: drives the structured table and status bar ---
    $initialRaw = @()
    try
    {
        $fetchParams = @{
            JobId        = $JobId
            AsObject     = $true
            ComputerName = $resolvedUrl
            ErrorAction  = 'Stop'
        }
        if (-not [string]::IsNullOrWhiteSpace($resolvedAppToken))
        {
            $fetchParams['AppToken'] = $resolvedAppToken
        }
        $initialRaw = @(Get-PSUJobOutput @fetchParams)
    }
    catch
    {
        $initialRaw = @()
    }

    if (@($initialRaw).Count -eq 0)
    {
        $initialRaw = @($JobOutputSnapshot)
    }

    $initialDisplayRows = @(
        $initialRaw |
            Microsoft.PowerShell.Utility\Sort-Object -Property Timestamp |
            ConvertFrom-PsuJobOutputEntry
    )
    if ($MaxRows -gt 0)
    {
        $initialDisplayRows = @($initialDisplayRows | Microsoft.PowerShell.Utility\Select-Object -Last $MaxRows)
    }

    # --- Status bar ---
    $streamCounts = @(
        $initialDisplayRows |
            Microsoft.PowerShell.Utility\Group-Object -Property Stream |
            Microsoft.PowerShell.Utility\Sort-Object -Property Name
    )
    $statusStreamSpans = @(
        $streamCounts.ForEach{
            $c = switch ($_.Name) {
                'Error' { 'var(--psu-term-stream-error)' }; 'Warning' { 'var(--psu-term-stream-warning)' }; 'Information' { 'var(--psu-term-stream-information)' }
                'Verbose' { 'var(--psu-term-stream-verbose)' }; 'Debug' { 'var(--psu-term-stream-debug)' }; Default { 'var(--psu-term-stream-default)' }
            }
            "<span style='color:{0};'>{1}:&thinsp;{2}</span>" -f $c, $_.Name, $_.Count
        }
    )
    $statusInner = if (@($statusStreamSpans).Count -gt 0)
    {
        ($statusStreamSpans -join "<span style='color:var(--psu-term-border);padding:0 6px;'>|</span>") +
        ("<span style='color:var(--psu-term-border);padding:0 6px;'>|</span><span style='color:var(--psu-term-muted-fg);'>Events:&nbsp;{0}</span>" -f @($initialDisplayRows).Count)
    }
    else
    {
        "<span style='color:var(--psu-term-muted-fg);'>Events:&nbsp;0</span>"
    }

    # --- Inline SVG assets from module images ---
    $svgIconHtml      = ''
    $svgWatermarkHtml = ''
    $moduleBase       = $MyInvocation.MyCommand.Module.ModuleBase
    if ($moduleBase)
    {
        $iconSvg = ConvertTo-UDPsuThemedIconMarkup -ModuleBase $moduleBase -IdPrefix ($ElementId + '-icon') -ExtraStyle 'height:26px;width:auto;display:block;' -Theme $Theme
        $wmSvg   = ConvertTo-UDPsuThemedIconMarkup -ModuleBase $moduleBase -IdPrefix ($ElementId + '-wm')   -ExtraStyle 'height:100%;width:auto;'         -Theme $Theme
        if ($iconSvg) { $svgIconHtml = '<span style="padding:4px 8px 4px 12px;display:flex;align-items:center;">' + $iconSvg + '</span><span style="padding:0 14px 0 0;color:var(--psu-term-fg);font-family:Consolas,Monaco,monospace;font-size:15px;font-weight:700;letter-spacing:2px;display:flex;align-items:center;border-right:1px solid var(--psu-term-border);">TERMINAL</span>' }
        if ($wmSvg)   { $svgWatermarkHtml = '<div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);height:65%;pointer-events:none;opacity:0.05;z-index:0;">' + $wmSvg + '</div>' }
    }
    $iconSpan = if ($svgIconHtml) { $svgIconHtml } else { '<span style="padding:0 14px;color:var(--psu-term-accent);font-family:Consolas,Monaco,monospace;font-size:14px;font-weight:700;display:flex;align-items:center;border-right:1px solid var(--psu-term-border);">&gt;_</span>' }

    # --- Title bar ---
    if ($resolvedIncludeStructuredTable)
    {
        $onClickTerminal = (
            "document.getElementById('" + $ElementId + "-panel-terminal').style.display='';" +
            "document.getElementById('" + $ElementId + "-panel-structured').style.display='none';" +
            "document.getElementById('" + $ElementId + "-cbtn-terminal').style.background='var(--psu-term-tab-active-bg)';" +
            "document.getElementById('" + $ElementId + "-cbtn-terminal').style.color='var(--psu-term-fg)';" +
            "document.getElementById('" + $ElementId + "-cbtn-terminal').style.borderBottom='2px solid var(--psu-term-accent)';" +
            "document.getElementById('" + $ElementId + "-cbtn-structured').style.background='transparent';" +
            "document.getElementById('" + $ElementId + "-cbtn-structured').style.color='var(--psu-term-muted-fg)';" +
            "document.getElementById('" + $ElementId + "-cbtn-structured').style.borderBottom='2px solid transparent';"
        )
        $onClickStructured = (
            "document.getElementById('" + $ElementId + "-panel-structured').style.display='';" +
            "document.getElementById('" + $ElementId + "-panel-terminal').style.display='none';" +
            "document.getElementById('" + $ElementId + "-cbtn-structured').style.background='var(--psu-term-tab-active-bg)';" +
            "document.getElementById('" + $ElementId + "-cbtn-structured').style.color='var(--psu-term-fg)';" +
            "document.getElementById('" + $ElementId + "-cbtn-structured').style.borderBottom='2px solid var(--psu-term-accent)';" +
            "document.getElementById('" + $ElementId + "-cbtn-terminal').style.background='transparent';" +
            "document.getElementById('" + $ElementId + "-cbtn-terminal').style.color='var(--psu-term-muted-fg)';" +
            "document.getElementById('" + $ElementId + "-cbtn-terminal').style.borderBottom='2px solid transparent';"
        )
    # JS tooltip helper - appends to body to bypass overflow:hidden on parent containers
    $tipShow = "var _t=document.createElement('div');_t.id='psu-tip';_t.textContent=this.dataset.tip;" +
               "_t.style.cssText='position:fixed;background:var(--psu-term-tooltip-bg);color:var(--psu-term-tooltip-fg);font-family:Consolas,Monaco,monospace;font-size:11px;padding:4px 8px;border-radius:4px;pointer-events:none;z-index:99999;white-space:nowrap;box-shadow:0 2px 6px rgba(0,0,0,0.4);';" +
               "document.body.appendChild(_t);" +
               "var r=this.getBoundingClientRect();" +
               "_t.style.left=(r.left+r.width/2-_t.offsetWidth/2)+'px';" +
               "_t.style.top=(r.top-_t.offsetHeight-6)+'px';"
    $tipHide = "var _t=document.getElementById('psu-tip');if(_t)_t.remove();"

    $lnToggleJs = (
        "var p1=document.getElementById('" + $ElementId + "-panel-terminal');" +
        "var p2=document.getElementById('" + $ElementId + "-panel-structured');" +
        "if(p1)p1.classList.toggle('" + $ElementId + "-ln-hidden');" +
        "if(p2)p2.classList.toggle('" + $ElementId + "-ln-hidden');" +
        "var off=p1&&p1.classList.contains('" + $ElementId + "-ln-hidden');" +
        "this.style.opacity=off?'0.35':'1';" +
        "try{localStorage.setItem('" + $ElementId + "-ln',off?'0':'1')}catch(e){}"
    )
    $lnToggleBtn = (
        '<button id="' + $ElementId + '-ln-toggle" onclick="' + $lnToggleJs + '" data-tip="Toggle line numbers" ' +
        'onmouseenter="' + $tipShow + '" onmouseleave="' + $tipHide + '" ' +
        'style="padding:4px 10px;background:transparent;border:none;cursor:pointer;' +
        'color:var(--psu-term-muted-fg);font-family:Consolas,Monaco,monospace;font-size:13px;font-weight:700;' +
        'display:flex;align-items:center;outline:none;line-height:1;" ' +
        'onmouseover="this.style.color=''var(--psu-term-fg)''" onmouseout="this.style.color=''var(--psu-term-muted-fg)''">#</button>'
    )
    $tsToggleJs = (
        "var p1=document.getElementById('" + $ElementId + "-panel-terminal');" +
        "var p2=document.getElementById('" + $ElementId + "-panel-structured');" +
        "if(p1)p1.classList.toggle('" + $ElementId + "-ts-hidden');" +
        "if(p2)p2.classList.toggle('" + $ElementId + "-ts-hidden');" +
        "var off=p1&&p1.classList.contains('" + $ElementId + "-ts-hidden');" +
        "this.style.opacity=off?'0.35':'1';" +
        "try{localStorage.setItem('" + $ElementId + "-ts',off?'0':'1')}catch(e){}"
    )
    $tsToggleBtn = (
        '<button id="' + $ElementId + '-ts-toggle" onclick="' + $tsToggleJs + '" data-tip="Toggle timestamps" ' +
        'onmouseenter="' + $tipShow + '" onmouseleave="' + $tipHide + '" ' +
        'style="padding:4px 8px;background:transparent;border:none;cursor:pointer;' +
        'color:var(--psu-term-muted-fg);font-family:Consolas,Monaco,monospace;font-size:13px;' +
        'display:flex;align-items:center;outline:none;line-height:1;" ' +
        'onmouseover="this.style.color=''var(--psu-term-fg)''" onmouseout="this.style.color=''var(--psu-term-muted-fg)''">&#128336;</button>'
    )
    $toggleBtns = '<div style="margin-left:auto;display:flex;align-items:center;">' + $lnToggleBtn + $tsToggleBtn + '</div>'
    # Restore toggle states on initial render: an explicit prior user choice in localStorage
    # always wins; otherwise fall back to the -HideLineNumbers/-HideTimestamps defaults.
    $showLnDefaultJs = if ($HideLineNumbers.IsPresent) { 'false' } else { 'true' }
    $showTsDefaultJs = if ($HideTimestamps.IsPresent) { 'false' } else { 'true' }
    $lnRestoreJs = (
        "(function(){" +
        "try{" +
        "var p1=document.getElementById('" + $ElementId + "-panel-terminal');" +
        "var p2=document.getElementById('" + $ElementId + "-panel-structured');" +
        "var lnStored=localStorage.getItem('" + $ElementId + "-ln');" +
        "var lnVisible=(lnStored===null)?" + $showLnDefaultJs + ":(lnStored!=='0');" +
        "if(!lnVisible){" +
        "if(p1)p1.classList.add('" + $ElementId + "-ln-hidden');" +
        "if(p2)p2.classList.add('" + $ElementId + "-ln-hidden');" +
        "var b=document.getElementById('" + $ElementId + "-ln-toggle');if(b)b.style.opacity='0.35';}" +
        "var tsStored=localStorage.getItem('" + $ElementId + "-ts');" +
        "var tsVisible=(tsStored===null)?" + $showTsDefaultJs + ":(tsStored!=='0');" +
        "if(!tsVisible){" +
        "if(p1)p1.classList.add('" + $ElementId + "-ts-hidden');" +
        "if(p2)p2.classList.add('" + $ElementId + "-ts-hidden');" +
        "var b=document.getElementById('" + $ElementId + "-ts-toggle');if(b)b.style.opacity='0.35';}" +
        "}catch(e){}})();"
    )

        $titleBarHtml = (
            '<div style="display:flex;align-items:stretch;height:100%;width:100%;">' +
            $iconSpan +
            '<button role="tab" id="' + $ElementId + '-cbtn-terminal" onclick="' + $onClickTerminal + '" style="padding:8px 16px;background:var(--psu-term-tab-active-bg);color:var(--psu-term-fg);border:none;border-right:1px solid var(--psu-term-border);cursor:pointer;font-family:Consolas,Monaco,monospace;font-size:12px;border-bottom:2px solid var(--psu-term-accent);outline:none;">Output</button>' +
            '<button role="tab" id="' + $ElementId + '-cbtn-structured" onclick="' + $onClickStructured + '" style="padding:8px 16px;background:transparent;color:var(--psu-term-muted-fg);border:none;border-right:1px solid var(--psu-term-border);cursor:pointer;font-family:Consolas,Monaco,monospace;font-size:12px;border-bottom:2px solid transparent;outline:none;">Structured Events</button>' +
            $toggleBtns +
            '</div>'
        )
    }
    else
    {
        $titleBarHtml = (
            '<div style="display:flex;align-items:center;height:100%;width:100%;">' +
            $iconSpan +
            '<span style="color:var(--psu-term-muted-fg);font-family:Consolas,Monaco,monospace;font-size:12px;padding:0 12px;display:flex;align-items:center;">Output</span>' +
            $toggleBtns +
            '</div>'
        )
    }

    if (-not $PSCmdlet.ShouldProcess($JobId, 'Render PSU job terminal view'))
    {
        return
    }

    $terminalStates      = @('Completed', 'Failed', 'Warning', 'Error', 'Stopped')
    $isTerminalJob       = [string]$JobStatus -in $terminalStates
    $terminalDynamicId   = "$ElementId-terminal-content"
    $structuredDynamicId = "$ElementId-structured-content"
    # ArgumentList: 0=JobId, 1=Token, 2=Url, 3=MaxRows, 4=view, 5=ElementId
    $dynArgs = @($JobId, $resolvedAppToken, $resolvedUrl, $MaxRows)

    # Shared dynamic content factory - fetches output and renders the given view type.
    # viewType: 'terminal' | 'structured'
    $makeDynContent = {
        $dynJobId   = [Int64]$ArgumentList[0]
        $dynToken   = [string]$ArgumentList[1]
        $dynUrl     = [string]$ArgumentList[2]
        $dynMaxRows = [Int32]$ArgumentList[3]
        $dynView    = [string]$ArgumentList[4]   # 'terminal' or 'structured'
        $dynEid     = [string]$ArgumentList[5]   # ElementId for CSS class scoping

        Microsoft.PowerShell.Core\Import-Module -Name 'synedgy.universal.helper' -ErrorAction SilentlyContinue
        $helperModule = Microsoft.PowerShell.Core\Get-Module -Name 'synedgy.universal.helper'

        $dynRaw = @()
        try
        {
            $fetchParams = @{
                JobId        = $dynJobId
                AsObject     = $true
                ComputerName = $dynUrl
                ErrorAction  = 'Stop'
            }
            if (-not [string]::IsNullOrWhiteSpace($dynToken))
            {
                $fetchParams['AppToken'] = $dynToken
            }
            $dynRaw = @(Get-PSUJobOutput @fetchParams)
        }
        catch { $dynRaw = @() }

        $markup = if ($null -ne $helperModule)
        {
            & $helperModule {
                param($raw, $max, $view, $eid)
                $rows = @($raw | Sort-Object -Property Timestamp | ConvertFrom-PsuJobOutputEntry)
                if ($max -gt 0) { $rows = @($rows | Select-Object -Last $max) }
                $lnClass = 'ln-' + $eid
                $tsClass = 'ts-' + $eid
                $lnStyle = 'color:var(--psu-term-muted-fg);min-width:3ch;text-align:right;margin-right:12px;user-select:none;flex-shrink:0;font-variant-numeric:tabular-nums;'

                if ($view -eq 'structured')
                {
                    $i = 0
                    $structRows = @($rows | ForEach-Object {
                        $i++
                        $ts      = if ($_.Timestamp) { '{0:yyyy-MM-dd HH:mm:ss}' -f ($_.Timestamp -as [datetime]) } else { '' }
                        $msgHtml = $_.Message | Convert-PlainTextToHtml
                        "<tr><td class='$lnClass' style='padding:4px 6px 4px 10px;color:var(--psu-term-muted-fg);text-align:right;user-select:none;white-space:nowrap;vertical-align:top;font-variant-numeric:tabular-nums;'>$i</td><td class='$tsClass' style='padding:4px 10px;color:var(--psu-term-muted-fg);white-space:nowrap;vertical-align:top;'>{0}</td><td style='padding:4px 10px;color:{1};font-weight:600;white-space:nowrap;vertical-align:top;'>{2}</td><td style='padding:4px 10px;color:var(--psu-term-fg);white-space:pre-wrap;vertical-align:top;'>{3}</td></tr>" -f [System.Net.WebUtility]::HtmlEncode($ts), $_.StreamColor, [System.Net.WebUtility]::HtmlEncode($_.Stream), $msgHtml
                    })
                    '<div style="max-height:500px;overflow:auto;background:var(--psu-term-bg);"><table style="width:100%;border-collapse:collapse;font-family:Consolas,Monaco,monospace;font-size:12px;"><thead><tr><th class="' + $lnClass + '" style="text-align:right;padding:6px 6px 6px 10px;color:var(--psu-term-muted-fg);border-bottom:1px solid var(--psu-term-border);background:var(--psu-term-bg);position:sticky;top:0;user-select:none;">#</th><th class="' + $tsClass + '" style="text-align:left;padding:6px 10px;color:var(--psu-term-muted-fg);border-bottom:1px solid var(--psu-term-border);background:var(--psu-term-bg);position:sticky;top:0;">Timestamp</th><th style="text-align:left;padding:6px 10px;color:var(--psu-term-muted-fg);border-bottom:1px solid var(--psu-term-border);background:var(--psu-term-bg);position:sticky;top:0;">Stream</th><th style="text-align:left;padding:6px 10px;color:var(--psu-term-muted-fg);border-bottom:1px solid var(--psu-term-border);background:var(--psu-term-bg);position:sticky;top:0;">Message</th></tr></thead><tbody>' + ($structRows -join '') + '</tbody></table></div>'
                }
                else
                {
                    $i = 0
                    $lines = @($rows | ForEach-Object {
                        $i++
                        $ts      = if ($_.Timestamp) { '{0:yyyy-MM-dd HH:mm:ss}' -f ($_.Timestamp -as [datetime]) } else { '' }
                        $msgHtml = $_.Message | Convert-AnsiToHtml
                        "<div style='display:flex;margin:0 0 4px 0;'><span class='$lnClass' style='$lnStyle'>$i</span><span><span class='$tsClass' style='color:var(--psu-term-muted-fg);'>[{0}]</span> <span style='color:{1};font-weight:600;'>[{2}]</span> <span>{3}</span></span></div>" -f $ts, $_.StreamColor, $_.Stream, $msgHtml
                    })
                    '<div style="font-family:Consolas,Monaco,monospace;font-size:12px;line-height:1.4;max-height:500px;overflow:auto;background:transparent;color:var(--psu-term-fg);padding:12px 14px;position:relative;z-index:1;">' + ($lines -join '') + '</div>'
                }
            } $dynRaw ([Int32]$dynMaxRows) $dynView $dynEid
        }
        else
        {
            '<div style="font-family:Consolas,Monaco,monospace;font-size:12px;padding:12px 14px;background:var(--psu-term-bg);color:var(--psu-term-stream-error);">Module unavailable.</div>'
        }

        $refreshedAt = '{0:yyyy-MM-dd HH:mm:ss}' -f [datetime]::Now
        New-UDHtml -Markup ($markup + "<div style='font-family:Consolas,Monaco,monospace;font-size:10px;color:var(--psu-term-muted-fg);padding:2px 14px 4px;background:var(--psu-term-bg);'>fetched $refreshedAt</div>")
    }

    New-UDElement -Tag 'div' -Id $ElementId -Attributes @{
        style = @{
            borderRadius = '6px'
            overflow     = 'hidden'
            border       = '1px solid var(--psu-term-border)'
            boxShadow    = '0 2px 8px rgba(0,0,0,0.3)'
            marginTop    = '4px'
        }
    } -Content {

        # Theme CSS custom properties (see Get-UDPsuJobThemeStyleBlock), line-number/timestamp
        # toggle CSS, and localStorage restore script. Emitted once here (not inside the
        # auto-refreshing dynamic panels below) since New-UDHtml's dangerouslySetInnerHTML
        # would otherwise reset it on every refresh.
        $themeStyleBlock = Get-UDPsuJobThemeStyleBlock -ElementId $ElementId -Theme $Theme -CustomCss $CustomCss
        $lnCss = '.' + $ElementId + '-ln-hidden .ln-' + $ElementId + ' { display:none !important; }' +
                 '.' + $ElementId + '-ts-hidden .ts-' + $ElementId + ' { display:none !important; }'
        New-UDHtml -Markup ($themeStyleBlock + '<style>' + $lnCss + '</style><script>' + $lnRestoreJs + '</script>')

        # Title bar
        New-UDElement -Tag 'div' -Attributes @{
            style = @{
                background   = 'var(--psu-term-chrome-bg)'
                display      = 'block'
                borderBottom = '1px solid var(--psu-term-border)'
            }
        } -Content {
            New-UDHtml -Markup $titleBarHtml
        }

        # Terminal panel - PSU wrapper preserves display style across dynamic re-renders
        New-UDElement -Tag 'div' -Id "$ElementId-panel-terminal" -Attributes @{
            style = @{ background = 'var(--psu-term-bg)'; position = 'relative' }
        } -Content {
            if ($svgWatermarkHtml) { New-UDHtml -Markup $svgWatermarkHtml }
            $termDynParams = @{
                Id           = $terminalDynamicId
                Content      = $makeDynContent
                ArgumentList = @($JobId, $resolvedAppToken, $resolvedUrl, $MaxRows, 'terminal', $ElementId)
            }
            if (-not $isTerminalJob -and $AutoRefreshInterval -gt 0)
            {
                $termDynParams['AutoRefresh']         = $true
                $termDynParams['AutoRefreshInterval'] = $AutoRefreshInterval
            }
            New-UDDynamic @termDynParams
        }

        # Structured panel - PSU wrapper, initially hidden; display controlled by tab JS only
        if ($resolvedIncludeStructuredTable)
        {
            New-UDElement -Tag 'div' -Id "$ElementId-panel-structured" -Attributes @{
                style = @{ display = 'none' }
            } -Content {
                New-UDDynamic -Id $structuredDynamicId -Content $makeDynContent -ArgumentList @($JobId, $resolvedAppToken, $resolvedUrl, $MaxRows, 'structured', $ElementId)
            }
        }

        # Status bar + refresh button (syncs both dynamics)
        $liveIndicator = if (-not $isTerminalJob) {
            "<span style='color:var(--psu-term-accent);margin-right:8px;'>&#9679;&nbsp;live</span>"
        } else { '' }

        New-UDElement -Tag 'div' -Attributes @{
            style = @{
                background   = 'var(--psu-term-status-bar-bg)'
                padding      = '4px 14px'
                borderTop    = '1px solid var(--psu-term-border)'
                fontFamily   = 'Consolas,Monaco,monospace'
                fontSize     = '11px'
                display      = 'flex'
                alignItems   = 'center'
            }
        } -Content {
            New-UDHtml -Markup ($liveIndicator + $statusInner)
            New-UDButton -Variant 'text' -Size 'small' -Icon (New-UDIcon -Icon 'sync') -OnClick {
                Sync-UDElement -Id $terminalDynamicId -ArgumentList @($JobId, $resolvedAppToken, $resolvedUrl, $MaxRows, 'terminal', $ElementId)
                if ($resolvedIncludeStructuredTable)
                {
                    Sync-UDElement -Id $structuredDynamicId -ArgumentList @($JobId, $resolvedAppToken, $resolvedUrl, $MaxRows, 'structured', $ElementId)
                }
            } -Style @{
                color      = 'var(--psu-term-muted-fg)'
                marginLeft = 'auto'
                minWidth   = 'unset'
                padding    = '0 8px'
                fontSize   = '11px'
            }
        }
    }
}
#EndRegion './Public/UDComponents/New-UDPsuJobTerminalView.ps1' 518
#Region './suffix.ps1' -1

#region TypeAccelerator export for classes

# If classes/types are specified here, they won't be fully qualified (careful with conflicts)
$typesToExportAsIs = @(
    'APIEndpoint'
    'Documentation'
    'APIInput'
    'APIOutput'
    'AiTool'
)

# The type accelerators created will be ModuleName.ClassName (to avoid conflicts with other modules until you use 'using moduleName'
$typesToExportWithNamespace = @(

)

# inspired from https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_classes?view=powershell-7.5

# Always clobber an existing type accelerator, but
# warn if a type accelerator with the same name exists.
function Get-CurrentModule
{
    <#
      .SYNOPSIS
      This is a Private function to always be able to retrieve the module info even outside
      of a function (i.e. PSM1 during module loading)

      .DESCRIPTION
      This function is only meant to be used from the psm1, hence not exported.

      .EXAMPLE
      $null = Get-CurrentModule

      #>

    [OutputType([System.Management.Automation.PSModuleInfo])]
    param
    ()

    # Get the current module
    $MyInvocation.MyCommand.ScriptBlock.Module
}

# Get the internal TypeAccelerators class to use its static methods.
$typeAcceleratorsClass = [psobject].Assembly.GetType(
    'System.Management.Automation.TypeAccelerators'
)
$moduleName = (Get-CurrentModule).Name
$existingTypeAccelerators = $typeAcceleratorsClass::Get

foreach ($typeToExport in  @($typesToExportWithNamespace + $typesToExportAsIs))
{
    if ($typeToExport -in $typesToExportAsIs)
    {
        $fullTypeToExport = $TypeToExport
    }
    else
    {
        $fullTypeToExport = '{0}.{1}' -f $moduleName,$TypeToExport
    }

    $type = $TypeToExport -as [System.Type]
    if (-not $type)
    {
        $Message = @(
            "Unable to register type accelerator '$fullTypeToExport' for '$typeToExport'"
            "Type '$typeToExport' not found."
        ) -join ' - '

        throw [System.Management.Automation.ErrorRecord]::new(
                [System.InvalidOperationException]::new($Message),
                'TypeAcceleratorTypeNotFound',
                [System.Management.Automation.ErrorCategory]::InvalidOperation,
                $fullTypeToExport
            )
    }
    else
    {
        if ($fullTypeToExport -in $existingTypeAccelerators.Keys)
        {
            $Message = @(
                "Overriding type accelerator '$($fullTypeToExport)' with '$($Type.FullName)'"
                'Accelerator already exists.'
            ) -join ' - '

            Write-Debug -Message $Message
        }
        else
        {
            Write-Verbose -Message "Added type accelerator '$($fullTypeToExport)' for '$($Type.FullName)'."
        }

        $null = $TypeAcceleratorsClass::Add($fullTypeToExport, $Type)
    }
}

# Remove type accelerators when the module is removed.
$MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = {
    foreach ($TypeName in $typesToExportWithNamespace)
    {
        $fullTypeToExport = '{0}.{1}' -f $moduleName,$TypeName
        $null = $TypeAcceleratorsClass::Remove($fullTypeToExport)
    }
}.GetNewClosure()

#endregion
#EndRegion './suffix.ps1' 106