term.ps1 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750
  1. <#
  2. .SYNOPSIS
  3. PowerShell port of the Terminal Panel Design System (docs/TERMINAL-DESIGN.md).
  4. .DESCRIPTION
  5. Mirror of skills/_lib/term.sh for PowerShell scripts in the claude-mods
  6. family. Provides chrome rendering (panels, sections, leaves), glyph
  7. registries (brand, health, diagram icon), color tokens, and width-aware
  8. text utilities so PowerShell-based skills produce output that's visually
  9. coherent with bash and Python siblings (fleet-ops, summon, etc.).
  10. Source from any PowerShell skill script:
  11. $LibDir = Join-Path $PSScriptRoot '..\..\_lib'
  12. . (Join-Path $LibDir 'term.ps1')
  13. Initialize-Term
  14. All component helpers return strings. The caller decides where to write
  15. them (stderr for chrome via [Console]::Error.WriteLine, stdout for data
  16. payloads). This keeps the ATP stream-separation contract intact.
  17. Honors: $env:NO_COLOR, $env:FORCE_COLOR, $env:TERM_ASCII, $env:TERM=dumb.
  18. Spec: ../docs/TERMINAL-DESIGN.md.
  19. #>
  20. # Guard against double-sourcing
  21. if ($Script:__TermPs1Loaded) { return }
  22. $Script:__TermPs1Loaded = $true
  23. # ─── Globals (populated by Initialize-Term) ──────────────────────────────────
  24. $Script:TermTty = $false
  25. $Script:TermColor = $false
  26. $Script:TermAsciiMode = $false
  27. $Script:TermWidth = 100 # claude-mods default (TERMINAL-DESIGN open question #1)
  28. # ANSI escape codes (empty when color disabled)
  29. $Script:TC_Green = ''
  30. $Script:TC_Yellow = ''
  31. $Script:TC_Orange = ''
  32. $Script:TC_Red = ''
  33. $Script:TC_Cyan = ''
  34. $Script:TC_Magenta = ''
  35. $Script:TC_Dim = ''
  36. $Script:TC_Off = ''
  37. # Tree connectors (set by Initialize-Term)
  38. $Script:T_Branch = '' # ├─ / +-
  39. $Script:T_Last = '' # └─ / `-
  40. $Script:T_Vert = '' # │ / |
  41. # Panel chrome
  42. $Script:P_TL = '' # ╭ / +
  43. $Script:P_BL = '' # ╰ / +
  44. $Script:P_HRule = '' # ─ / -
  45. $Script:P_Term = '' # ● / *
  46. # Header / alert / tip glyphs
  47. $Script:G_Branch = '' # ⎇ / (b)
  48. $Script:G_Alert = '' # ▲ / !
  49. $Script:G_Tip = '' # 💡 / (i)
  50. # Spinner frame banks
  51. $Script:Spin_Working = @()
  52. $Script:Spin_Heartbeat = @()
  53. # ─── Registries (Unicode | ASCII fallback) ──────────────────────────────────
  54. $Script:TermBrand = @{
  55. fleet = '⚡|[F]'
  56. forge = '🔨|[B]'
  57. psql = '🐘|[P]'
  58. watch = '📡|[M]'
  59. deploy = '🚀|[D]'
  60. git = '🌿|[G]'
  61. 'windows-ops' = '🩺|[H]' # stethoscope — diagnostics is the verb
  62. }
  63. $Script:TermHealthGlyph = @{
  64. healthy = '•|(+)'
  65. pending = '•|(.)'
  66. warning = '•|(!)'
  67. critical = '•|(!!)'
  68. busted = '⬤|(X)'
  69. unknown = '•|(?)'
  70. }
  71. $Script:TermDiagramIcon = @{
  72. user = '👤|(U)'
  73. web = '🌐|(W)'
  74. mobile = '📱|(M)'
  75. auth = '🔐|(A)'
  76. database = '🗄|(D)'
  77. cache = '⚡|(C)'
  78. queue = '📨|(Q)'
  79. storage = '📦|(P)'
  80. service = '⚙|*'
  81. api = '🔌|(I)'
  82. search = '🔍|(S)'
  83. timer = '⏱|(T)'
  84. build = '🔨|(B)'
  85. hook = '🪝|(H)'
  86. log = '📄|(F)'
  87. }
  88. # ─── Initialize-Term ─────────────────────────────────────────────────────────
  89. function Initialize-Term {
  90. <#
  91. .SYNOPSIS
  92. Detect terminal capabilities and populate global glyph/color state.
  93. Idempotent — safe to call multiple times.
  94. #>
  95. [CmdletBinding()]
  96. param()
  97. # TTY detection — stdout (not stderr; rendering targets stdout-ish)
  98. try {
  99. $Script:TermTty = -not [Console]::IsOutputRedirected
  100. } catch {
  101. $Script:TermTty = $false
  102. }
  103. # ASCII fallback: explicit env, or non-UTF environment
  104. $asciiEnv = $env:TERM_ASCII -eq '1' -or $env:FLEET_ASCII -eq '1'
  105. $lang = if ($env:LC_ALL) { $env:LC_ALL } elseif ($env:LANG) { $env:LANG } else { '' }
  106. $nonUtf = $lang -and ($lang -notmatch '[Uu][Tt][Ff]') -and ($env:TERM -eq 'dumb')
  107. $Script:TermAsciiMode = $asciiEnv -or $nonUtf
  108. # Color: TTY + not NO_COLOR, or FORCE_COLOR overrides
  109. if ($env:FORCE_COLOR) {
  110. $Script:TermColor = $true
  111. } elseif ($env:NO_COLOR -or -not $Script:TermTty -or $env:TERM -eq 'dumb') {
  112. $Script:TermColor = $false
  113. } else {
  114. $Script:TermColor = $true
  115. }
  116. # Terminal width — fall back to 100 (claude-mods default)
  117. if ($Script:TermTty) {
  118. try {
  119. $cols = [Console]::WindowWidth
  120. if ($cols -ge 40) { $Script:TermWidth = $cols }
  121. } catch {
  122. # WindowWidth throws when no console attached; keep default
  123. }
  124. }
  125. # Allow explicit override
  126. if ($env:TERM_WIDTH -match '^\d+$' -and [int]$env:TERM_WIDTH -ge 40) {
  127. $Script:TermWidth = [int]$env:TERM_WIDTH
  128. }
  129. # Glyphs by mode
  130. if ($Script:TermAsciiMode) {
  131. $Script:T_Branch = '+-'
  132. $Script:T_Last = '`-'
  133. $Script:T_Vert = '|'
  134. $Script:P_TL = '+'
  135. $Script:P_BL = '+'
  136. $Script:P_HRule = '-'
  137. $Script:P_Term = '*'
  138. $Script:G_Branch = '(b)'
  139. $Script:G_Alert = '!'
  140. $Script:G_Tip = '(i)'
  141. $Script:Spin_Working = @('|', '/', '-', '\')
  142. $Script:Spin_Heartbeat = @('.', ':', '*', ':')
  143. } else {
  144. $Script:T_Branch = '├─'
  145. $Script:T_Last = '└─'
  146. $Script:T_Vert = '│'
  147. $Script:P_TL = '╭'
  148. $Script:P_BL = '╰'
  149. $Script:P_HRule = '─'
  150. $Script:P_Term = '●'
  151. $Script:G_Branch = '⎇'
  152. $Script:G_Alert = '▲'
  153. $Script:G_Tip = '💡'
  154. $Script:Spin_Working = @('⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏')
  155. $Script:Spin_Heartbeat = @('·','∙','•','●','•','∙')
  156. }
  157. # ANSI escapes
  158. if ($Script:TermColor) {
  159. $esc = [char]27
  160. $Script:TC_Green = "${esc}[32m"
  161. $Script:TC_Yellow = "${esc}[33m"
  162. $Script:TC_Orange = "${esc}[38;5;208m"
  163. $Script:TC_Red = "${esc}[31m"
  164. $Script:TC_Cyan = "${esc}[36m"
  165. $Script:TC_Magenta = "${esc}[35m"
  166. $Script:TC_Dim = "${esc}[2m"
  167. $Script:TC_Off = "${esc}[0m"
  168. # On Windows, ensure VT processing is enabled so ANSI works in PS 5.1
  169. if ($PSVersionTable.PSVersion.Major -le 5) {
  170. try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}
  171. }
  172. } else {
  173. $Script:TC_Green = ''; $Script:TC_Yellow = ''; $Script:TC_Orange = ''
  174. $Script:TC_Red = ''; $Script:TC_Cyan = ''; $Script:TC_Magenta = ''
  175. $Script:TC_Dim = ''; $Script:TC_Off = ''
  176. }
  177. }
  178. # ─── Color helper ────────────────────────────────────────────────────────────
  179. function Get-TermColor {
  180. <#
  181. .SYNOPSIS
  182. Wrap text in an ANSI color escape. Returns plain text when color disabled.
  183. #>
  184. [CmdletBinding()]
  185. param(
  186. [Parameter(Mandatory)][ValidateSet('green','yellow','orange','red','cyan','magenta','dim')]
  187. [string]$Token,
  188. [Parameter(Mandatory)][AllowEmptyString()][string]$Text
  189. )
  190. if (-not $Script:TermColor) { return $Text }
  191. $code = switch ($Token) {
  192. 'green' { $Script:TC_Green }
  193. 'yellow' { $Script:TC_Yellow }
  194. 'orange' { $Script:TC_Orange }
  195. 'red' { $Script:TC_Red }
  196. 'cyan' { $Script:TC_Cyan }
  197. 'magenta' { $Script:TC_Magenta }
  198. 'dim' { $Script:TC_Dim }
  199. }
  200. return "${code}${Text}$($Script:TC_Off)"
  201. }
  202. # ─── Registry lookup ─────────────────────────────────────────────────────────
  203. function Get-TermGlyph {
  204. <#
  205. .SYNOPSIS
  206. Return registered Unicode glyph (or ASCII fallback when in ASCII mode).
  207. .PARAMETER Registry
  208. Which registry to consult: Brand | Health | Diagram
  209. #>
  210. [CmdletBinding()]
  211. param(
  212. [Parameter(Mandatory)][ValidateSet('Brand','Health','Diagram')]$Registry,
  213. [Parameter(Mandatory)][string]$Key
  214. )
  215. $map = switch ($Registry) {
  216. 'Brand' { $Script:TermBrand }
  217. 'Health' { $Script:TermHealthGlyph }
  218. 'Diagram' { $Script:TermDiagramIcon }
  219. }
  220. $entry = $map[$Key]
  221. if (-not $entry) { return '?' }
  222. $parts = $entry -split '\|', 2
  223. if ($Script:TermAsciiMode) { return $parts[1] } else { return $parts[0] }
  224. }
  225. # ─── Display-width helpers ───────────────────────────────────────────────────
  226. function Get-TermDisplayWidth {
  227. <#
  228. .SYNOPSIS
  229. Approximate display column count for a string, accounting for emoji
  230. double-width and ignoring ANSI color escapes.
  231. #>
  232. [CmdletBinding()]
  233. param([Parameter(ValueFromPipeline)][AllowEmptyString()][string]$Text = '')
  234. process {
  235. if (-not $Text) { return 0 }
  236. # Strip ANSI escapes (CSI sequences)
  237. $esc = [char]27
  238. $clean = $Text -replace "${esc}\[[0-9;]*m", ''
  239. $width = 0
  240. # EnumerateRunes handles surrogate pairs correctly
  241. foreach ($rune in [System.Globalization.StringInfo]::GetTextElementEnumerator($clean)) {
  242. $cp = if ([string]$rune.Current.Length -gt 0) { [int][char]([string]$rune.Current)[0] } else { 0 }
  243. # Simple wide-emoji heuristic: rune length >1 (surrogate pair) OR in known wide ranges
  244. $s = [string]$rune.Current
  245. if ($s.Length -gt 1) {
  246. $width += 2 # surrogate pair — almost always wide
  247. } elseif (($cp -ge 0x2600 -and $cp -le 0x27BF) -or
  248. ($cp -ge 0x2B00 -and $cp -le 0x2BFF) -or
  249. $cp -eq 0x26A1 -or # ⚡
  250. $cp -eq 0x2728 -or # ✨
  251. $cp -eq 0x2B24) { # ⬤
  252. $width += 2
  253. } else {
  254. $width += 1
  255. }
  256. }
  257. return $width
  258. }
  259. }
  260. function Get-TermTruncated {
  261. <#
  262. .SYNOPSIS
  263. Ellipsis-truncate text to fit in MaxCols display columns.
  264. #>
  265. [CmdletBinding()]
  266. param(
  267. [Parameter(Mandatory, Position=0)][AllowEmptyString()][string]$Text,
  268. [Parameter(Mandatory, Position=1)][int]$MaxCols
  269. )
  270. $w = Get-TermDisplayWidth $Text
  271. if ($w -le $MaxCols) { return $Text }
  272. $ell = if ($Script:TermAsciiMode) { '..' } else { '…' }
  273. $ellW = Get-TermDisplayWidth $ell
  274. # Naive truncation by character count — close enough for our needs
  275. $maxChars = $MaxCols - $ellW
  276. if ($maxChars -lt 0) { $maxChars = 0 }
  277. if ($Text.Length -le $maxChars) { return $Text + $ell }
  278. return $Text.Substring(0, $maxChars) + $ell
  279. }
  280. # ─── Panel chrome ────────────────────────────────────────────────────────────
  281. function New-TermPanelOpen {
  282. <#
  283. .SYNOPSIS
  284. Render the panel header bar: ╭── 🩺 brand · subtitle ─── INDICATOR ───●
  285. .PARAMETER Brand
  286. Brand key from the registry (e.g. 'windows-ops').
  287. .PARAMETER Name
  288. Tool name shown after the brand emoji (e.g. 'windows-ops').
  289. .PARAMETER Subtitle
  290. Optional subtitle after the name (e.g. 'health-audit').
  291. .PARAMETER Indicator
  292. Optional right-side context indicator (e.g. 'TITAN', 'Y / Disk 1').
  293. #>
  294. [CmdletBinding()]
  295. param(
  296. [Parameter(Mandatory)][string]$Brand,
  297. [Parameter(Mandatory)][string]$Name,
  298. [string]$Subtitle = '',
  299. [string]$Indicator = ''
  300. )
  301. $emoji = Get-TermGlyph -Registry Brand -Key $Brand
  302. $title = if ($Subtitle) {
  303. "$Name $(Get-TermColor dim "· $Subtitle")"
  304. } else {
  305. $Name
  306. }
  307. $titleVis = if ($Subtitle) { "$Name · $Subtitle" } else { $Name }
  308. $leftRaw = "$($Script:P_TL)$($Script:P_HRule)$($Script:P_HRule) $emoji "
  309. $left = "$leftRaw$(Get-TermColor cyan $title) "
  310. $leftVis = "$leftRaw${titleVis} "
  311. if ($Indicator) {
  312. $right = " $(Get-TermColor dim $Indicator) $($Script:P_HRule)$($Script:P_HRule)$($Script:P_HRule)$(Get-TermColor cyan $Script:P_Term)"
  313. $rightVis = " $Indicator $($Script:P_HRule)$($Script:P_HRule)$($Script:P_HRule)$($Script:P_Term)"
  314. } else {
  315. $right = "$($Script:P_HRule)$($Script:P_HRule)$($Script:P_HRule)$(Get-TermColor cyan $Script:P_Term)"
  316. $rightVis = "$($Script:P_HRule)$($Script:P_HRule)$($Script:P_HRule)$($Script:P_Term)"
  317. }
  318. $leftW = Get-TermDisplayWidth $leftVis
  319. $rightW = Get-TermDisplayWidth $rightVis
  320. $fill = $Script:TermWidth - $leftW - $rightW
  321. if ($fill -lt 4) { $fill = 4 }
  322. $rule = Get-TermColor cyan ($Script:P_HRule * $fill)
  323. return "${left}${rule}${right}"
  324. }
  325. function New-TermPanelClose {
  326. <#
  327. .SYNOPSIS
  328. Render the panel footer bar: ╰── hotkeys ─── health1 health2 ───●
  329. .PARAMETER Hotkeys
  330. Pre-rendered hotkey string (use New-TermHotkey + joining with ' · ').
  331. .PARAMETER Healths
  332. Pre-rendered health-indicator string (use New-TermHealth + joining with ' ').
  333. #>
  334. [CmdletBinding()]
  335. param(
  336. [string]$Hotkeys = '',
  337. [string]$Healths = ''
  338. )
  339. $leftRaw = "$($Script:P_BL)$($Script:P_HRule)$($Script:P_HRule) "
  340. $left = "$leftRaw$Hotkeys "
  341. # For width calc strip ANSI from the rendered hotkeys/healths
  342. $hotkeysVis = ($Hotkeys -replace "$([char]27)\[[0-9;]*m", '')
  343. $leftVis = "$leftRaw$hotkeysVis "
  344. if ($Healths) {
  345. $right = " $Healths $($Script:P_HRule)$($Script:P_HRule)$($Script:P_HRule)$(Get-TermColor cyan $Script:P_Term)"
  346. $healthsVis = ($Healths -replace "$([char]27)\[[0-9;]*m", '')
  347. $rightVis = " $healthsVis $($Script:P_HRule)$($Script:P_HRule)$($Script:P_HRule)$($Script:P_Term)"
  348. } else {
  349. $right = "$($Script:P_HRule)$($Script:P_HRule)$($Script:P_HRule)$(Get-TermColor cyan $Script:P_Term)"
  350. $rightVis = "$($Script:P_HRule)$($Script:P_HRule)$($Script:P_HRule)$($Script:P_Term)"
  351. }
  352. $leftW = Get-TermDisplayWidth $leftVis
  353. $rightW = Get-TermDisplayWidth $rightVis
  354. $fill = $Script:TermWidth - $leftW - $rightW
  355. if ($fill -lt 4) { $fill = 4 }
  356. $rule = Get-TermColor cyan ($Script:P_HRule * $fill)
  357. return "${left}${rule}${right}"
  358. }
  359. function New-TermPanelVert {
  360. <# Body-line spacer: a single │ on its own line. #>
  361. [CmdletBinding()]
  362. param()
  363. return Get-TermColor dim $Script:T_Vert
  364. }
  365. # ─── Body components ─────────────────────────────────────────────────────────
  366. function New-TermSection {
  367. <#
  368. .SYNOPSIS
  369. Section header: ├── LABEL (n) with label colored by state.
  370. .PARAMETER State
  371. State token (FAILING/WARN/PASS/INFO or fleet-style RUNNING/READY/FAILED/CONFLICT).
  372. .PARAMETER Label
  373. Section label text.
  374. .PARAMETER Count
  375. Item count. Pass -1 to omit the (n).
  376. #>
  377. [CmdletBinding()]
  378. param(
  379. [Parameter(Mandatory)][string]$State,
  380. [Parameter(Mandatory)][string]$Label,
  381. [int]$Count = -1
  382. )
  383. $color = switch -Regex ($State) {
  384. '^(RUNNING|PENDING|WARN|warning|WATCHLIST)$' { 'yellow' }
  385. '^(READY|PASS|LANDED|DONE|OK|healthy)$' { 'green' }
  386. '^(FAILING|FAILED|ERROR|CRITICAL|critical|alarm|busted)$' { 'red' }
  387. '^(CONFLICT)$' { 'magenta' }
  388. default { '' }
  389. }
  390. $renderedLabel = if ($color) { Get-TermColor $color $Label } else { $Label }
  391. # Section is a panel-edge attachment — '├──' IS the left edge, no leading '│' prefix.
  392. $conn = Get-TermColor dim "$($Script:T_Branch)$($Script:P_HRule)"
  393. $countStr = if ($Count -ge 0) { ' ' + (Get-TermColor dim "($Count)") } else { '' }
  394. return "${conn} ${renderedLabel}${countStr}"
  395. }
  396. function New-TermSummary {
  397. <#
  398. .SYNOPSIS
  399. Summary line: ├── text (all dim, metadata-only branch).
  400. #>
  401. [CmdletBinding()]
  402. param([Parameter(Mandatory)][string]$Text)
  403. # Summary attaches at the panel edge like a section.
  404. $conn = Get-TermColor dim "$($Script:T_Branch)$($Script:P_HRule)"
  405. $body = Get-TermColor dim $Text
  406. return "${conn} ${body}"
  407. }
  408. function New-TermLeaf {
  409. <#
  410. .SYNOPSIS
  411. Single leaf row: │ ├── name ●─●─◉ meta age
  412. .PARAMETER Name
  413. Leaf name (ellipsis-truncated to fit name column).
  414. .PARAMETER Rail
  415. Pre-rendered leaf-glyph string (rail or pip bar or plain text).
  416. .PARAMETER Meta
  417. Meta column content (e.g. 'M4 ?1', 'clean', error count).
  418. .PARAMETER Age
  419. Age column content (right-aligned, e.g. '12m', '2h').
  420. .PARAMETER IsLast
  421. Use └── connector instead of ├── (for last sibling in a section).
  422. .PARAMETER NameColWidth
  423. Override name column width (default 32).
  424. .PARAMETER RailColWidth
  425. Override rail column width (default 14).
  426. .PARAMETER MetaColWidth
  427. Override meta column width (default 12).
  428. #>
  429. [CmdletBinding()]
  430. param(
  431. [Parameter(Mandatory)][string]$Name,
  432. [string]$Rail = '',
  433. [string]$Meta = '',
  434. [string]$Age = '',
  435. [switch]$IsLast,
  436. [int]$NameColWidth = 32,
  437. [int]$RailColWidth = 14,
  438. [int]$MetaColWidth = 12
  439. )
  440. $vert = Get-TermColor dim $Script:T_Vert
  441. $connRaw = if ($IsLast) { $Script:T_Last } else { $Script:T_Branch }
  442. $conn = Get-TermColor dim "$connRaw$($Script:P_HRule)"
  443. $truncName = Get-TermTruncated -Text $Name -MaxCols $NameColWidth
  444. $nameW = Get-TermDisplayWidth $truncName
  445. $namePad = ' ' * [Math]::Max(0, $NameColWidth - $nameW)
  446. $railW = Get-TermDisplayWidth ($Rail -replace "$([char]27)\[[0-9;]*m", '')
  447. $railPad = ' ' * [Math]::Max(0, $RailColWidth - $railW)
  448. $metaColored = if ($Meta) { Get-TermColor dim $Meta } else { '' }
  449. $metaW = Get-TermDisplayWidth $Meta
  450. $metaPad = ' ' * [Math]::Max(0, $MetaColWidth - $metaW)
  451. $ageColored = if ($Age) { Get-TermColor dim $Age } else { '' }
  452. return "${vert} ${conn} ${truncName}${namePad} ${Rail}${railPad} ${metaColored}${metaPad} ${ageColored}"
  453. }
  454. function New-TermAlert {
  455. <#
  456. .SYNOPSIS
  457. Inline alert sub-row: │ │ ▲ message (orange/red).
  458. .PARAMETER Severity
  459. warning (orange) or critical (red).
  460. #>
  461. [CmdletBinding()]
  462. param(
  463. [Parameter(Mandatory)][ValidateSet('warning','critical')]$Severity,
  464. [Parameter(Mandatory)][string]$Text
  465. )
  466. $color = if ($Severity -eq 'critical') { 'red' } else { 'orange' }
  467. $vert = Get-TermColor dim $Script:T_Vert
  468. $vert2 = Get-TermColor dim $Script:T_Vert
  469. $tri = Get-TermColor $color $Script:G_Alert
  470. # Per design § 4.7: panel-vert, 3-space section indent, leaf-continuation vert,
  471. # 3-space sub-indent (aligns the alert under the leaf's tree connector).
  472. return "${vert} ${vert2} ${tri} ${Text}"
  473. }
  474. function New-TermHint {
  475. <#
  476. .SYNOPSIS
  477. Hint row with the tip glyph: │ 💡 text (dim, no tree connector).
  478. Used for "to get started" / "did you know" rows in empty states.
  479. #>
  480. [CmdletBinding()]
  481. param([Parameter(Mandatory)][string]$Text)
  482. $vert = Get-TermColor dim $Script:T_Vert
  483. $tip = $Script:G_Tip
  484. return "${vert} ${tip} $(Get-TermColor dim $Text)"
  485. }
  486. function New-TermToast {
  487. <#
  488. .SYNOPSIS
  489. Toast row: ├── 🩺 message (dim cyan emoji + default fg text).
  490. #>
  491. [CmdletBinding()]
  492. param(
  493. [Parameter(Mandatory)][string]$Brand,
  494. [Parameter(Mandatory)][string]$Text
  495. )
  496. $emoji = Get-TermGlyph -Registry Brand -Key $Brand
  497. $vert = Get-TermColor dim $Script:T_Vert
  498. $conn = Get-TermColor dim "$($Script:T_Branch)$($Script:P_HRule)"
  499. $msg = "$(Get-TermColor cyan $emoji) $Text"
  500. return "${vert}${conn} ${msg}"
  501. }
  502. # ─── Leaf-glyph builders ─────────────────────────────────────────────────────
  503. function New-TermRail {
  504. <#
  505. .SYNOPSIS
  506. Build a commit-graph rail: ●─●─●─◉ (HEAD) or ●─●─⊗ (CONFLICT).
  507. .PARAMETER Commits
  508. Number of landed commits (including the HEAD position).
  509. .PARAMETER Head
  510. HEAD | CONFLICT | EMPTY
  511. #>
  512. [CmdletBinding()]
  513. param(
  514. [Parameter(Mandatory)][ValidateRange(0,99)][int]$Commits,
  515. [ValidateSet('HEAD','CONFLICT','EMPTY')][string]$Head = 'HEAD'
  516. )
  517. $commit = if ($Script:TermAsciiMode) { '*' } else { '●' }
  518. $link = if ($Script:TermAsciiMode) { '-' } else { '─' }
  519. $headg = if ($Script:TermAsciiMode) { '@' } else { '◉' }
  520. $conflict = if ($Script:TermAsciiMode) { 'X' } else { '⊗' }
  521. if ($Commits -le 0 -and $Head -eq 'EMPTY') {
  522. return $link
  523. }
  524. $out = ''
  525. for ($i = 0; $i -lt ($Commits - 1); $i++) {
  526. $out += "$(Get-TermColor green $commit)$link"
  527. }
  528. switch ($Head) {
  529. 'HEAD' {
  530. if ($Commits -ge 1) { $out += "$(Get-TermColor green $commit)$link" }
  531. $out += Get-TermColor yellow $headg
  532. }
  533. 'CONFLICT' {
  534. if ($Commits -ge 1) { $out += "$(Get-TermColor green $commit)$link" }
  535. $out += Get-TermColor red $conflict
  536. }
  537. 'EMPTY' {
  538. if ($Commits -ge 1) { $out += Get-TermColor green $commit }
  539. }
  540. }
  541. return $out
  542. }
  543. function New-TermPipBar {
  544. <#
  545. .SYNOPSIS
  546. Build a pip bar: ▰▰▰▰▱▱▱▱▱▱ with state-color based on metric type.
  547. .PARAMETER Type
  548. progress | score | capacity (drives color selection per design § 4.10).
  549. .PARAMETER Filled
  550. Filled count.
  551. .PARAMETER Total
  552. Total / denominator.
  553. .PARAMETER Width
  554. Pip count (default 10 = clean 10% increments).
  555. #>
  556. [CmdletBinding()]
  557. param(
  558. [Parameter(Mandatory)][ValidateSet('progress','score','capacity')]$Type,
  559. [Parameter(Mandatory)][int]$Filled,
  560. [Parameter(Mandatory)][int]$Total,
  561. [int]$Width = 10
  562. )
  563. $pipFull = if ($Script:TermAsciiMode) { '#' } else { '▰' }
  564. $pipEmpty = if ($Script:TermAsciiMode) { '-' } else { '▱' }
  565. # Natural denominator override: if total <= 12 and not 100, use total as width
  566. if ($Total -ne 100 -and $Total -gt 0 -and $Total -le 12) {
  567. $Width = $Total
  568. }
  569. $pct = if ($Total -gt 0) { [int](100 * $Filled / $Total) } else { 0 }
  570. $pips = if ($Total -eq 100) { [int]($Filled / 10) } else { $Filled }
  571. if ($pips -lt 0) { $pips = 0 }
  572. if ($pips -gt $Width) { $pips = $Width }
  573. $color = switch ($Type) {
  574. 'progress' {
  575. if ($pct -ge 100) { 'green' } else { 'yellow' }
  576. }
  577. 'score' {
  578. if ($pct -lt 33) { 'red' }
  579. elseif ($pct -lt 66) { 'yellow' }
  580. else { 'green' }
  581. }
  582. 'capacity' {
  583. if ($pct -ge 80) { 'red' }
  584. elseif ($pct -ge 60) { 'yellow' }
  585. else { 'green' }
  586. }
  587. }
  588. $out = ''
  589. for ($i = 0; $i -lt $pips; $i++) { $out += Get-TermColor $color $pipFull }
  590. for ($i = $pips; $i -lt $Width; $i++) { $out += Get-TermColor dim $pipEmpty }
  591. return $out
  592. }
  593. # ─── Right-side furniture ────────────────────────────────────────────────────
  594. function New-TermHealth {
  595. <#
  596. .SYNOPSIS
  597. Health indicator: • text (with ⬤ for busted state).
  598. .PARAMETER State
  599. healthy | pending | warning | critical | busted | unknown
  600. #>
  601. [CmdletBinding()]
  602. param(
  603. [Parameter(Mandatory)][ValidateSet('healthy','pending','warning','critical','busted','unknown')]$State,
  604. [Parameter(Mandatory)][string]$Text
  605. )
  606. $glyph = Get-TermGlyph -Registry Health -Key $State
  607. $color = switch ($State) {
  608. 'healthy' { 'green' }
  609. 'pending' { 'yellow' }
  610. 'warning' { 'orange' }
  611. 'critical' { 'red' }
  612. 'busted' { 'dim' }
  613. default { 'dim' }
  614. }
  615. return "$(Get-TermColor $color $glyph) $Text"
  616. }
  617. function New-TermHotkey {
  618. <#
  619. .SYNOPSIS
  620. Hotkey hint: "R refresh" with R in cyan.
  621. #>
  622. [CmdletBinding()]
  623. param(
  624. [Parameter(Mandatory)][string]$Key,
  625. [Parameter(Mandatory)][string]$Verb
  626. )
  627. return "$(Get-TermColor cyan $Key) $Verb"
  628. }
  629. function Join-TermHotkeys {
  630. <# Combine hotkeys with the dot separator. Accumulates from pipeline OR -InputObject array. #>
  631. [CmdletBinding()]
  632. param([Parameter(ValueFromPipeline, Position=0)][string]$InputObject)
  633. begin { $items = New-Object System.Collections.Generic.List[string] }
  634. process { if ($InputObject) { $items.Add($InputObject) } }
  635. end { return ($items -join ' · ') }
  636. }
  637. function Join-TermHealths {
  638. <# Combine health indicators with two-space separator (per design § 4.3). #>
  639. [CmdletBinding()]
  640. param([Parameter(ValueFromPipeline, Position=0)][string]$InputObject)
  641. begin { $items = New-Object System.Collections.Generic.List[string] }
  642. process { if ($InputObject) { $items.Add($InputObject) } }
  643. end { return ($items -join ' ') }
  644. }
  645. # ─── Spinners ────────────────────────────────────────────────────────────────
  646. function Get-TermSpinnerFrame {
  647. <#
  648. .SYNOPSIS
  649. Return the spinner glyph for the given tick (frame index).
  650. .PARAMETER Family
  651. working (fast, task-progress) or heartbeat (slow, daemon-alive).
  652. .PARAMETER Tick
  653. Frame index; modded by family frame count.
  654. #>
  655. [CmdletBinding()]
  656. param(
  657. [Parameter(Mandatory)][ValidateSet('working','heartbeat')]$Family,
  658. [Parameter(Mandatory)][int]$Tick
  659. )
  660. $frames = switch ($Family) {
  661. 'working' { $Script:Spin_Working }
  662. 'heartbeat' { $Script:Spin_Heartbeat }
  663. }
  664. if (-not $frames) { return '?' }
  665. return $frames[$Tick % $frames.Count]
  666. }
  667. # ─── Convenience: Write a panel-block to stderr ──────────────────────────────
  668. function Write-TermLine {
  669. <#
  670. .SYNOPSIS
  671. Write a pre-rendered chrome line to stderr. Use for panel rows so
  672. stdout remains data-only (ATP stream separation).
  673. #>
  674. [CmdletBinding()]
  675. param([Parameter(ValueFromPipeline)][AllowEmptyString()][string]$Line)
  676. process { [Console]::Error.WriteLine($Line) }
  677. }
  678. function Write-TermData {
  679. <#
  680. .SYNOPSIS
  681. Write payload data to stdout (the data product of the script).
  682. Use for JSON, machine-readable records, anything downstream tooling
  683. will consume.
  684. #>
  685. [CmdletBinding()]
  686. param([Parameter(ValueFromPipeline)][AllowEmptyString()][string]$Line)
  687. process { [Console]::Out.WriteLine($Line) }
  688. }