workflows/default/systems/runtime/modules/DotBotTheme.psm1
|
# DOTBOT Control Panel - PowerShell Theme # Oscilloscope aesthetic with configurable color themes # Reads selected theme from ui-settings.json, colors from theme-config.json # Track last modification time to avoid unnecessary re-reads $script:LastThemeCheckTime = $null $script:LastThemeFileTime = $null $script:UiSettingsPath = $null # Helper function to get the selected theme name from ui-settings.json function Get-SelectedThemeName { if (-not $script:UiSettingsPath) { $script:UiSettingsPath = Join-Path $PSScriptRoot "..\..\..\.control\ui-settings.json" # Normalize path (handle relative traversal) $script:UiSettingsPath = [System.IO.Path]::GetFullPath($script:UiSettingsPath) } if (-not (Test-Path $script:UiSettingsPath)) { return "amber" # Default theme } try { $settings = Get-Content $script:UiSettingsPath -Raw | ConvertFrom-Json if ($settings.theme) { return $settings.theme } return "amber" # Default if theme not set } catch { return "amber" # Default on error } } # Helper function to load theme preset from theme-config.json function Get-ThemePreset { param([string]$ThemeName) $uiThemePath = Join-Path $PSScriptRoot "..\..\ui\static\theme-config.json" $defaultThemePath = Join-Path $PSScriptRoot "..\..\..\defaults\theme.default.json" $configPath = if (Test-Path $uiThemePath) { $uiThemePath } else { $defaultThemePath } if (-not (Test-Path $configPath)) { return $null } try { $config = Get-Content $configPath -Raw | ConvertFrom-Json $preset = $config.presets.$ThemeName if (-not $preset) { # Fall back to amber if requested theme not found $preset = $config.presets.amber } return $preset } catch { return $null } } # Helper function to build theme from preset function Build-ThemeFromPreset { param([object]$Preset) return @{ # Primary semantic colors from preset Primary = $PSStyle.Foreground.FromRgb($Preset.primary[0], $Preset.primary[1], $Preset.primary[2]) PrimaryDim = $PSStyle.Foreground.FromRgb($Preset.'primary-dim'[0], $Preset.'primary-dim'[1], $Preset.'primary-dim'[2]) Secondary = $PSStyle.Foreground.FromRgb($Preset.secondary[0], $Preset.secondary[1], $Preset.secondary[2]) Tertiary = $PSStyle.Foreground.FromRgb($Preset.tertiary[0], $Preset.tertiary[1], $Preset.tertiary[2]) Success = $PSStyle.Foreground.FromRgb($Preset.success[0], $Preset.success[1], $Preset.success[2]) SuccessDim = $PSStyle.Foreground.FromRgb($Preset.'success-dim'[0], $Preset.'success-dim'[1], $Preset.'success-dim'[2]) Error = $PSStyle.Foreground.FromRgb($Preset.error[0], $Preset.error[1], $Preset.error[2]) Warning = $PSStyle.Foreground.FromRgb($Preset.warning[0], $Preset.warning[1], $Preset.warning[2]) Info = $PSStyle.Foreground.FromRgb($Preset.info[0], $Preset.info[1], $Preset.info[2]) Muted = $PSStyle.Foreground.FromRgb($Preset.muted[0], $Preset.muted[1], $Preset.muted[2]) Bezel = $PSStyle.Foreground.FromRgb($Preset.bezel[0], $Preset.bezel[1], $Preset.bezel[2]) Reset = $PSStyle.Reset } } # Helper function to build fallback theme (hardcoded amber) function Build-FallbackTheme { return @{ # Primary phosphor colors (hardcoded fallback) Amber = $PSStyle.Foreground.FromRgb(232, 160, 48) # #e8a030 AmberDim = $PSStyle.Foreground.FromRgb(184, 120, 32) # #b87820 Green = $PSStyle.Foreground.FromRgb(0, 255, 136) # #00ff88 GreenDim = $PSStyle.Foreground.FromRgb(0, 170, 92) # #00aa5c Cyan = $PSStyle.Foreground.FromRgb(95, 179, 179) # #5fb3b3 Red = $PSStyle.Foreground.FromRgb(209, 105, 105) # #d16969 Blue = $PSStyle.Foreground.FromRgb(68, 136, 255) # #4488ff Purple = $PSStyle.Foreground.FromRgb(170, 136, 255) # #aa88ff # UI chrome colors Label = $PSStyle.Foreground.FromRgb(136, 136, 153) # #888899 Bezel = $PSStyle.Foreground.FromRgb(58, 59, 72) # #3a3b48 Reset = $PSStyle.Reset } } # Helper function to add legacy aliases to theme function Add-LegacyAliases { param([hashtable]$Theme, [bool]$FromPreset) if ($FromPreset) { # Legacy aliases for backward compatibility (preset has semantic names) $Theme.Amber = $Theme.Primary $Theme.AmberDim = $Theme.PrimaryDim $Theme.Green = $Theme.Success $Theme.GreenDim = $Theme.SuccessDim $Theme.Cyan = $Theme.Secondary $Theme.Red = $Theme.Error $Theme.Blue = $Theme.Info $Theme.Purple = $Theme.Tertiary $Theme.Label = $Theme.Muted } else { # Semantic aliases matching CSS usage (fallback has legacy names) $Theme.Primary = $Theme.Amber $Theme.PrimaryDim = $Theme.AmberDim $Theme.Secondary = $Theme.Cyan $Theme.Tertiary = $Theme.Purple $Theme.Success = $Theme.Green $Theme.SuccessDim = $Theme.GreenDim $Theme.Error = $Theme.Red $Theme.Warning = $Theme.Amber $Theme.Info = $Theme.Cyan $Theme.Muted = $Theme.Label } } # Core function to load/reload the theme function Initialize-Theme { $selectedTheme = Get-SelectedThemeName $themePreset = Get-ThemePreset -ThemeName $selectedTheme if ($themePreset) { $script:Theme = Build-ThemeFromPreset -Preset $themePreset Add-LegacyAliases -Theme $script:Theme -FromPreset $true } else { $script:Theme = Build-FallbackTheme Add-LegacyAliases -Theme $script:Theme -FromPreset $false } # Update tracking timestamps if ($script:UiSettingsPath -and (Test-Path $script:UiSettingsPath)) { $script:LastThemeFileTime = (Get-Item $script:UiSettingsPath).LastWriteTimeUtc } $script:LastThemeCheckTime = [DateTime]::UtcNow } # Get selected theme and load its colors $selectedTheme = Get-SelectedThemeName $themePreset = Get-ThemePreset -ThemeName $selectedTheme if ($themePreset) { # Build theme from preset (array format: [R, G, B]) $script:Theme = @{ # Primary semantic colors from preset Primary = $PSStyle.Foreground.FromRgb($themePreset.primary[0], $themePreset.primary[1], $themePreset.primary[2]) PrimaryDim = $PSStyle.Foreground.FromRgb($themePreset.'primary-dim'[0], $themePreset.'primary-dim'[1], $themePreset.'primary-dim'[2]) Secondary = $PSStyle.Foreground.FromRgb($themePreset.secondary[0], $themePreset.secondary[1], $themePreset.secondary[2]) Tertiary = $PSStyle.Foreground.FromRgb($themePreset.tertiary[0], $themePreset.tertiary[1], $themePreset.tertiary[2]) Success = $PSStyle.Foreground.FromRgb($themePreset.success[0], $themePreset.success[1], $themePreset.success[2]) SuccessDim = $PSStyle.Foreground.FromRgb($themePreset.'success-dim'[0], $themePreset.'success-dim'[1], $themePreset.'success-dim'[2]) Error = $PSStyle.Foreground.FromRgb($themePreset.error[0], $themePreset.error[1], $themePreset.error[2]) Warning = $PSStyle.Foreground.FromRgb($themePreset.warning[0], $themePreset.warning[1], $themePreset.warning[2]) Info = $PSStyle.Foreground.FromRgb($themePreset.info[0], $themePreset.info[1], $themePreset.info[2]) Muted = $PSStyle.Foreground.FromRgb($themePreset.muted[0], $themePreset.muted[1], $themePreset.muted[2]) Bezel = $PSStyle.Foreground.FromRgb($themePreset.bezel[0], $themePreset.bezel[1], $themePreset.bezel[2]) Reset = $PSStyle.Reset } # Legacy aliases for backward compatibility $script:Theme.Amber = $script:Theme.Primary $script:Theme.AmberDim = $script:Theme.PrimaryDim $script:Theme.Green = $script:Theme.Success $script:Theme.GreenDim = $script:Theme.SuccessDim $script:Theme.Cyan = $script:Theme.Secondary $script:Theme.Red = $script:Theme.Error $script:Theme.Blue = $script:Theme.Info $script:Theme.Purple = $script:Theme.Tertiary $script:Theme.Label = $script:Theme.Muted } else { # Fallback to hardcoded amber values if config not found $script:Theme = @{ # Primary phosphor colors (hardcoded fallback) Amber = $PSStyle.Foreground.FromRgb(232, 160, 48) # #e8a030 AmberDim = $PSStyle.Foreground.FromRgb(184, 120, 32) # #b87820 Green = $PSStyle.Foreground.FromRgb(0, 255, 136) # #00ff88 GreenDim = $PSStyle.Foreground.FromRgb(0, 170, 92) # #00aa5c Cyan = $PSStyle.Foreground.FromRgb(95, 179, 179) # #5fb3b3 Red = $PSStyle.Foreground.FromRgb(209, 105, 105) # #d16969 Blue = $PSStyle.Foreground.FromRgb(68, 136, 255) # #4488ff Purple = $PSStyle.Foreground.FromRgb(170, 136, 255) # #aa88ff # UI chrome colors Label = $PSStyle.Foreground.FromRgb(136, 136, 153) # #888899 Bezel = $PSStyle.Foreground.FromRgb(58, 59, 72) # #3a3b48 Reset = $PSStyle.Reset } # Semantic aliases matching CSS usage $script:Theme.Primary = $script:Theme.Amber $script:Theme.PrimaryDim = $script:Theme.AmberDim $script:Theme.Secondary = $script:Theme.Cyan $script:Theme.Tertiary = $script:Theme.Purple $script:Theme.Success = $script:Theme.Green $script:Theme.SuccessDim = $script:Theme.GreenDim $script:Theme.Error = $script:Theme.Red $script:Theme.Warning = $script:Theme.Amber $script:Theme.Info = $script:Theme.Cyan $script:Theme.Muted = $script:Theme.Label } function Get-DotBotTheme { <# .SYNOPSIS Returns the DOTBOT theme hashtable for direct use #> return $script:Theme } function Update-DotBotTheme { <# .SYNOPSIS Update the theme if ui-settings.json has changed since last read. Call this at natural breakpoints (between tasks, on pause/resume). .DESCRIPTION Checks if the ui-settings.json file has been modified since the last theme load. If so, reloads the theme. This allows theme changes in the browser UI to be reflected in console output without restarting scripts. .PARAMETER Force Force a theme reload regardless of file modification time. .OUTPUTS Returns $true if theme was updated, $false if no update was needed. .EXAMPLE Update-DotBotTheme .EXAMPLE Update-DotBotTheme -Force #> param( [switch]$Force ) # Ensure settings path is initialized if (-not $script:UiSettingsPath) { $script:UiSettingsPath = Join-Path $PSScriptRoot "..\..\..\.control\ui-settings.json" $script:UiSettingsPath = [System.IO.Path]::GetFullPath($script:UiSettingsPath) } # If file doesn't exist, nothing to refresh if (-not (Test-Path $script:UiSettingsPath)) { return $false } # Check if refresh is needed $currentFileTime = (Get-Item $script:UiSettingsPath).LastWriteTimeUtc if (-not $Force) { # Skip if file hasn't changed since last read if ($script:LastThemeFileTime -and $currentFileTime -le $script:LastThemeFileTime) { return $false } } # Reload theme Initialize-Theme return $true } function Write-Phosphor { <# .SYNOPSIS Write colored output using DOTBOT phosphor colors .PARAMETER Message The message to display .PARAMETER Color Color name: Amber, AmberDim, Green, GreenDim, Cyan, Red, Blue, Purple, Label .PARAMETER NoNewline Don't add newline at end #> param( [Parameter(Mandatory, Position = 0)] [string]$Message, [Parameter(Position = 1)] [ValidateSet('Amber', 'AmberDim', 'Green', 'GreenDim', 'Cyan', 'Red', 'Blue', 'Purple', 'Label', 'Bezel')] [string]$Color = 'Amber', [switch]$NoNewline ) $c = $script:Theme[$Color] $r = $script:Theme.Reset if ($NoNewline) { Write-Host "${c}${Message}${r}" -NoNewline } else { Write-Host "${c}${Message}${r}" } } function Write-Status { <# .SYNOPSIS Write a status message with icon prefix (oscilloscope style) .PARAMETER Message The message to display .PARAMETER Type Status type: Info, Success, Error, Warn, Process, Complete #> param( [Parameter(Mandatory, Position = 0)] [string]$Message, [Parameter(Position = 1)] [ValidateSet('Info', 'Success', 'Error', 'Warn', 'Process', 'Complete')] [string]$Type = 'Info' ) $icons = @{ Info = '›' Success = '✓' Error = '✗' Warn = '⚠' Process = '◆' Complete = '●' } $colors = @{ Info = $script:Theme.Cyan Success = $script:Theme.Green Error = $script:Theme.Red Warn = $script:Theme.Amber Process = $script:Theme.Amber Complete = $script:Theme.Green } $textColors = @{ Info = $script:Theme.Muted Success = $script:Theme.Success Error = $script:Theme.Error Warn = $script:Theme.Warning Process = $script:Theme.Primary Complete = $script:Theme.Success } $icon = $icons[$Type] $iconColor = $colors[$Type] $textColor = $textColors[$Type] $r = $script:Theme.Reset Write-Host "${iconColor}${icon}${r} ${textColor}${Message}${r}" } function Write-SubStatus { <# .SYNOPSIS Write an indented, dimmed detail line (subordinate to a Write-Status) .PARAMETER Message The message to display #> param( [Parameter(Mandatory, Position = 0)] [string]$Message ) $c = $script:Theme.Muted $r = $script:Theme.Reset Write-Host "${c}› $Message${r}" } function Write-Label { <# .SYNOPSIS Write a label: value pair (like sidebar items) #> param( [Parameter(Mandatory, Position = 0)] [string]$Label, [Parameter(Mandatory, Position = 1)] [string]$Value, [ValidateSet('Amber', 'Green', 'Cyan', 'Red', 'Blue', 'Purple')] [string]$ValueColor = 'Amber' ) $labelC = $script:Theme.Label $valueC = $script:Theme[$ValueColor] $r = $script:Theme.Reset Write-Host "${labelC}${Label}: ${r}${valueC}${Value}${r}" } function Write-Header { <# .SYNOPSIS Write a section header (uppercase, letter-spaced like CSS) #> param( [Parameter(Mandatory, Position = 0)] [string]$Text ) $c = $script:Theme.AmberDim $r = $script:Theme.Reset $formatted = ($Text.ToUpper().ToCharArray() -join ' ') Write-Host "" Write-Host "${c}── ${formatted} ──${r}" Write-Host "" } function Write-Led { <# .SYNOPSIS Write an LED indicator status line #> param( [Parameter(Mandatory, Position = 0)] [string]$Label, [Parameter(Position = 1)] [ValidateSet('On', 'Off', 'Warn', 'Error')] [string]$State = 'On', [ValidateSet('Green', 'Amber', 'Cyan', 'Red')] [string]$Color = 'Green' ) $ledColors = @{ On = $script:Theme[$Color] Off = $script:Theme.Bezel Warn = $script:Theme.Amber Error = $script:Theme.Red } $ledChars = @{ On = '●' Off = '○' Warn = '●' Error = '●' } $led = $ledColors[$State] $char = $ledChars[$State] $label = $script:Theme.Label $r = $script:Theme.Reset Write-Host "${led}${char}${r} ${label}${Label}${r}" } function Write-Separator { <# .SYNOPSIS Write a subtle separator line #> param( [int]$Width = 40 ) $c = $script:Theme.Bezel $r = $script:Theme.Reset Write-Host "${c}$('─' * $Width)${r}" } function Write-Banner { <# .SYNOPSIS Write the DOTBOT banner/logo #> param( [string]$Title = "DOTBOT", [string]$Subtitle = "", [int]$Width = 40 ) $amber = $script:Theme.Amber $dim = $script:Theme.AmberDim $r = $script:Theme.Reset $innerWidth = $Width - 2 # Account for ║ on each side $contentWidth = $innerWidth - 4 # Account for " " padding on each side Write-Host "" Write-Host "${amber}╔$('═' * $innerWidth)╗${r}" Write-Host "${amber}║${r} ${amber}$(Get-PaddedText -Text $Title -Width $contentWidth)${r} ${amber}║${r}" if ($Subtitle) { Write-Host "${amber}║${r} ${dim}$(Get-PaddedText -Text $Subtitle -Width $contentWidth)${r} ${amber}║${r}" } Write-Host "${amber}╚$('═' * $innerWidth)╝${r}" Write-Host "" } # ═══════════════════════════════════════════════════════════════════ # BOX DRAWING - ANSI-aware width handling # ═══════════════════════════════════════════════════════════════════ # Box character sets $script:BoxChars = @{ Rounded = @{ TL = '╭'; TR = '╮'; BL = '╰'; BR = '╯' H = '─'; V = '│' LT = '├'; RT = '┤'; TT = '┬'; BT = '┴'; X = '┼' } Square = @{ TL = '┌'; TR = '┐'; BL = '└'; BR = '┘' H = '─'; V = '│' LT = '├'; RT = '┤'; TT = '┬'; BT = '┴'; X = '┼' } Double = @{ TL = '╔'; TR = '╗'; BL = '╚'; BR = '╝' H = '═'; V = '║' LT = '╠'; RT = '╣'; TT = '╦'; BT = '╩'; X = '╬' } Heavy = @{ TL = '┏'; TR = '┓'; BL = '┗'; BR = '┛' H = '━'; V = '┃' LT = '┣'; RT = '┫'; TT = '┳'; BT = '┻'; X = '╋' } } function Get-VisualWidth { <# .SYNOPSIS Get the visual width of a string, ignoring ANSI escape sequences #> param([string]$Text) ($Text -replace '\x1b\[[0-9;]*m', '').Length } function Get-PaddedText { <# .SYNOPSIS Pad text to a visual width, accounting for ANSI codes #> param( [string]$Text, [int]$Width, [string]$PadChar = ' ', [ValidateSet('Left', 'Right', 'Center')] [string]$Align = 'Left' ) $visual = Get-VisualWidth $Text # Truncate with ellipsis if content exceeds target width if ($visual -gt $Width -and $Width -gt 1) { $plain = $Text -replace '\x1b\[[0-9;]*m', '' $truncLen = [Math]::Max(0, $Width - 1) $Text = $plain.Substring(0, [Math]::Min($truncLen, $plain.Length)) + [char]0x2026 + $PSStyle.Reset $visual = [Math]::Min($Width, $visual) } $totalPad = [Math]::Max(0, $Width - (Get-VisualWidth $Text)) switch ($Align) { 'Left' { return $Text + ($PadChar * $totalPad) } 'Right' { return ($PadChar * $totalPad) + $Text } 'Center' { $left = [Math]::Floor($totalPad / 2) $right = $totalPad - $left return ($PadChar * $left) + $Text + ($PadChar * $right) } } } function Write-Card { <# .SYNOPSIS Draw a card with rounded (or other style) borders .PARAMETER Title Optional title in the top border .PARAMETER Lines Array of content lines (can include ANSI colors) .PARAMETER Width Total width of the card including borders .PARAMETER BorderStyle Border style: Rounded, Square, Double, Heavy .PARAMETER BorderColor Color for the border from theme .PARAMETER TitleColor Color for the title from theme .PARAMETER Padding Internal horizontal padding (default 1) #> param( [string]$Title = "", [string[]]$Lines = @(), [int]$Width = 40, [ValidateSet('Rounded', 'Square', 'Double', 'Heavy')] [string]$BorderStyle = 'Rounded', [string]$BorderColor = 'AmberDim', [string]$TitleColor = 'Amber', [int]$Padding = 1 ) $t = $script:Theme $bc = $t[$BorderColor] $tc = $t[$TitleColor] $r = $t.Reset $box = $script:BoxChars[$BorderStyle] $innerWidth = $Width - 2 # account for │ on each side $contentWidth = $innerWidth - ($Padding * 2) $pad = ' ' * $Padding # Top border if ($Title) { $titleText = " $Title " $titleVis = Get-VisualWidth $titleText $remaining = [Math]::Max(0, $innerWidth - $titleVis - 1) # -1 for left dash $top = "${bc}$($box.TL)$($box.H)${r}${tc}${titleText}${r}${bc}$($box.H * $remaining)$($box.TR)${r}" } else { $top = "${bc}$($box.TL)$($box.H * $innerWidth)$($box.TR)${r}" } Write-Host $top # Content lines foreach ($line in $Lines) { $padded = Get-PaddedText -Text $line -Width $contentWidth Write-Host "${bc}$($box.V)${r}${pad}${padded}${pad}${bc}$($box.V)${r}" } # Bottom border Write-Host "${bc}$($box.BL)$($box.H * $innerWidth)$($box.BR)${r}" } function Write-CardRow { <# .SYNOPSIS Draw multiple cards side by side .PARAMETER Cards Array of hashtables, each with: Title, Lines, Width (optional) .PARAMETER Gap Space between cards #> param( [hashtable[]]$Cards, [int]$Gap = 2, [ValidateSet('Rounded', 'Square', 'Double', 'Heavy')] [string]$BorderStyle = 'Rounded', [string]$BorderColor = 'AmberDim', [string]$TitleColor = 'Amber' ) $t = $script:Theme $bc = $t[$BorderColor] $tc = $t[$TitleColor] $r = $t.Reset $box = $script:BoxChars[$BorderStyle] $gapStr = ' ' * $Gap # Normalize cards - ensure all have Width and Lines $normalizedCards = foreach ($card in $Cards) { @{ Title = $card.Title ?? "" Lines = $card.Lines ?? @() Width = $card.Width ?? 30 } } # Find max lines $maxLines = ($normalizedCards | ForEach-Object { $_.Lines.Count } | Measure-Object -Maximum).Maximum $maxLines = [Math]::Max($maxLines, 1) # Build each row # Top borders $topRow = "" foreach ($card in $normalizedCards) { $innerWidth = $card.Width - 2 if ($card.Title) { $titleText = " $($card.Title) " $titleVis = Get-VisualWidth $titleText $remaining = [Math]::Max(0, $innerWidth - $titleVis - 1) $topRow += "${bc}$($box.TL)$($box.H)${r}${tc}${titleText}${r}${bc}$($box.H * $remaining)$($box.TR)${r}${gapStr}" } else { $topRow += "${bc}$($box.TL)$($box.H * $innerWidth)$($box.TR)${r}${gapStr}" } } Write-Host $topRow.TrimEnd() # Content rows for ($i = 0; $i -lt $maxLines; $i++) { $contentRow = "" foreach ($card in $normalizedCards) { $innerWidth = $card.Width - 2 $line = if ($i -lt $card.Lines.Count) { " $($card.Lines[$i])" } else { "" } $padded = Get-PaddedText -Text $line -Width ($innerWidth - 1) $contentRow += "${bc}$($box.V)${r}${padded} ${bc}$($box.V)${r}${gapStr}" } Write-Host $contentRow.TrimEnd() } # Bottom borders $bottomRow = "" foreach ($card in $normalizedCards) { $innerWidth = $card.Width - 2 $bottomRow += "${bc}$($box.BL)$($box.H * $innerWidth)$($box.BR)${r}${gapStr}" } Write-Host $bottomRow.TrimEnd() } function Write-Table { <# .SYNOPSIS Draw a table with headers and rows .PARAMETER Headers Array of column headers .PARAMETER Rows Array of arrays, each inner array is a row. Use comma prefix for single-row tables. .PARAMETER ColumnWidths Array of widths for each column (auto-calculated if not provided) .EXAMPLE Write-Table -Headers @("Name", "Status") -Rows @( ,@("Task 1", "Done") ,@("Task 2", "Pending") ) #> param( [string[]]$Headers, [Parameter(Mandatory)] $Rows, [int[]]$ColumnWidths = @(), [ValidateSet('Rounded', 'Square', 'Double', 'Heavy')] [string]$BorderStyle = 'Rounded', [string]$BorderColor = 'AmberDim', [string]$HeaderColor = 'Amber' ) $t = $script:Theme $bc = $t[$BorderColor] $hc = $t[$HeaderColor] $rs = $t.Reset $box = $script:BoxChars[$BorderStyle] # Normalize rows - ensure we have an array of arrays $normalizedRows = @() foreach ($row in $Rows) { # Force each row into array context $normalizedRows += ,@($row) } # Auto-calculate column widths if not provided if ($ColumnWidths.Count -eq 0) { $ColumnWidths = @() for ($i = 0; $i -lt $Headers.Count; $i++) { $maxWidth = Get-VisualWidth $Headers[$i] foreach ($row in $normalizedRows) { if ($i -lt $row.Count) { $cellWidth = Get-VisualWidth "$($row[$i])" if ($cellWidth -gt $maxWidth) { $maxWidth = $cellWidth } } } $ColumnWidths += ($maxWidth + 2) # padding } } # Top border $top = "${bc}$($box.TL)" for ($i = 0; $i -lt $ColumnWidths.Count; $i++) { $top += "$($box.H * $ColumnWidths[$i])" if ($i -lt $ColumnWidths.Count - 1) { $top += "$($box.TT)" } } $top += "$($box.TR)${rs}" Write-Host $top # Header row $headerRow = "${bc}$($box.V)${rs}" for ($i = 0; $i -lt $Headers.Count; $i++) { $cell = Get-PaddedText -Text " $($Headers[$i])" -Width ($ColumnWidths[$i] - 1) $headerRow += "${hc}${cell}${rs} ${bc}$($box.V)${rs}" } Write-Host $headerRow # Header separator $sep = "${bc}$($box.LT)" for ($i = 0; $i -lt $ColumnWidths.Count; $i++) { $sep += "$($box.H * $ColumnWidths[$i])" if ($i -lt $ColumnWidths.Count - 1) { $sep += "$($box.X)" } } $sep += "$($box.RT)${rs}" Write-Host $sep # Data rows foreach ($row in $normalizedRows) { $dataRow = "${bc}$($box.V)${rs}" for ($i = 0; $i -lt $ColumnWidths.Count; $i++) { $cellContent = if ($i -lt $row.Count) { " $($row[$i])" } else { " " } $cell = Get-PaddedText -Text $cellContent -Width ($ColumnWidths[$i] - 1) $dataRow += "${cell} ${bc}$($box.V)${rs}" } Write-Host $dataRow } # Bottom border $bottom = "${bc}$($box.BL)" for ($i = 0; $i -lt $ColumnWidths.Count; $i++) { $bottom += "$($box.H * $ColumnWidths[$i])" if ($i -lt $ColumnWidths.Count - 1) { $bottom += "$($box.BT)" } } $bottom += "$($box.BR)${rs}" Write-Host $bottom } function Write-ProgressCard { <# .SYNOPSIS Draw a progress bar inside a card #> param( [string]$Title = "Progress", [int]$Percent = 0, [int]$Width = 40, [string]$BarColor = 'Green', [string]$EmptyColor = 'Bezel', [ValidateSet('Rounded', 'Square', 'Double', 'Heavy')] [string]$BorderStyle = 'Rounded' ) $t = $script:Theme $barC = $t[$BarColor] $emptyC = $t[$EmptyColor] $r = $t.Reset $innerWidth = $Width - 4 # borders + padding $filled = [Math]::Floor($innerWidth * ($Percent / 100)) $empty = $innerWidth - $filled $bar = "${barC}$('█' * $filled)${r}${emptyC}$('░' * $empty)${r}" $percentText = "${Percent}%" Write-Card -Title $Title -Width $Width -BorderStyle $BorderStyle -Lines @( $bar (Get-PaddedText -Text $percentText -Width $innerWidth -Align Center) ) } function Write-Panel { <# .SYNOPSIS Draw a simple panel with just a border (no title support, minimal overhead) #> param( [string[]]$Lines, [int]$Width = 0, [ValidateSet('Rounded', 'Square', 'Double', 'Heavy')] [string]$BorderStyle = 'Rounded', [string]$BorderColor = 'Bezel' ) $t = $script:Theme $bc = $t[$BorderColor] $r = $t.Reset $box = $script:BoxChars[$BorderStyle] # Auto-width if not specified if ($Width -eq 0) { $Width = ($Lines | ForEach-Object { Get-VisualWidth $_ } | Measure-Object -Maximum).Maximum + 4 } $innerWidth = $Width - 2 Write-Host "${bc}$($box.TL)$($box.H * $innerWidth)$($box.TR)${r}" foreach ($line in $Lines) { $padded = Get-PaddedText -Text " $line" -Width ($innerWidth - 1) Write-Host "${bc}$($box.V)${r}${padded} ${bc}$($box.V)${r}" } Write-Host "${bc}$($box.BL)$($box.H * $innerWidth)$($box.BR)${r}" } function Write-TaskHeader { <# .SYNOPSIS Render a standardized card at the start of each task. .PARAMETER TaskName Name of the task .PARAMETER TaskType Task type: prompt, script, mcp, task_gen .PARAMETER Model Model name being used .PARAMETER ProcessId Current process ID #> param( [Parameter(Mandatory, Position = 0)] [string]$TaskName, [string]$TaskType = 'prompt', [string]$Model = '', [string]$ProcessId = '' ) $t = $script:Theme $r = $t.Reset $ver = Get-DotBotVersion $ts = (Get-Date).ToString('d MMM yyyy HH:mm') $lines = @( "$($t.Muted)Time:$r $($t.Secondary)$ts$r" "$($t.Muted)Version:$r $($t.Secondary)v$ver$r" ) if ($Model) { $lines += "$($t.Muted)Model:$r $($t.Tertiary)$Model$r" } if ($TaskType) { $lines += "$($t.Muted)Type:$r $($t.Primary)$TaskType$r" } if ($ProcessId) { $lines += "$($t.Muted)Process:$r $($t.Muted)$ProcessId$r" } Write-Host '' Write-Card -Title $TaskName -Lines $lines -Width 60 -BorderStyle Rounded -BorderColor PrimaryDim -TitleColor Primary -Padding 1 } function Get-DotBotVersion { <# .SYNOPSIS Returns the dotbot version string from $env:DOTBOT_VERSION or version.json fallback. #> if ($env:DOTBOT_VERSION) { return $env:DOTBOT_VERSION } # Walk up from module location to find version.json $searchDir = $PSScriptRoot for ($i = 0; $i -lt 8; $i++) { $candidate = Join-Path $searchDir 'version.json' if (Test-Path $candidate) { try { $v = (Get-Content $candidate -Raw | ConvertFrom-Json).version if ($v) { $env:DOTBOT_VERSION = $v; return $v } } catch { Write-Verbose "Failed to parse data: $_" } } $searchDir = Split-Path $searchDir -Parent if (-not $searchDir) { break } } return 'unknown' } # Export functions Export-ModuleMember -Function @( 'Get-DotBotTheme' 'Update-DotBotTheme' 'Get-DotBotVersion' 'Write-Phosphor' 'Write-Status' 'Write-SubStatus' 'Write-Label' 'Write-Header' 'Write-Led' 'Write-Separator' 'Write-Banner' 'Get-VisualWidth' 'Get-PaddedText' 'Write-Card' 'Write-CardRow' 'Write-Table' 'Write-ProgressCard' 'Write-Panel' 'Write-TaskHeader' ) |