term.ps1 27 KB

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