src/UI/70-Help.ps1

# 70-Help.ps1 — tabbed help (design-system-v2 §7): '?' opens the current
# screen's help as tabs `this screen | keys | config | startup | about`;
# left/right switches, '/' filters within the visible tab, 'c' copies it,
# esc backs out (clearing an active filter first).
#
# EVERY tab is Spectre MARKUP, not escaped plain text (gh#8): help that
# describes a coloured, capsuled screen in flat monospace does not look like
# the thing it documents. Keys render as real capsules through Get-PSMMKeyCap,
# state glyphs carry their live colours, code goes through Format-PSMMCode and
# URLs through Get-PSMMLinkMarkup. Get-PSMMHelpText flattens all of it back to
# plain text for `c` copy and the tests.

# --- section building blocks ---------------------------------------------

# 14, not 12: a key capsule renders as ' key ', i.e. two columns wider than the
# key itself, and the widest key in the help is 'left/right' (12 columns). At
# 12 the pad floor of 1 pushed long-key rows one column right of everything
# else - the description column has to line up or the block reads as ragged.
$script:PSMM_HelpKeyWidth = 14

function script:Get-PSMMHelpHead {
    param([Parameter(Mandatory)][string]$Text)
    @(
        "[$script:PSMM_ColAccent]$(ConvertTo-PSMMSafe $Text)[/]"
        "[$script:PSMM_ColDim]$('-' * $Text.Length)[/]"
    )
}

# " <capsule> description" - the shape the live screens use.
function script:Get-PSMMHelpRow {
    param(
        [Parameter(Mandatory)][string]$Key,
        [AllowEmptyString()][string]$Text
    )
    $pad = ' ' * [Math]::Max(1, $script:PSMM_HelpKeyWidth - ($Key.Length + 2))
    " $(Get-PSMMKeyCap -Key $Key)$pad[$script:PSMM_ColMute]$(ConvertTo-PSMMSafe $Text)[/]"
}

# Continuation line under a key row, aligned with its description column.
function script:Get-PSMMHelpCont {
    param([AllowEmptyString()][string]$Text)
    " $(' ' * $script:PSMM_HelpKeyWidth)[$script:PSMM_ColMute]$(ConvertTo-PSMMSafe $Text)[/]"
}

# " term <markup>" for column/field explanations (term in accent).
# An empty term is the continuation line of the term above it.
function script:Get-PSMMHelpTerm {
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Term,
        [Parameter(Mandatory)][AllowEmptyString()][string]$Markup
    )
    $pad = ' ' * [Math]::Max(1, $script:PSMM_HelpKeyWidth - $Term.Length)
    if (-not $Term) { return " $(' ' * $script:PSMM_HelpKeyWidth)$Markup" }
    " [$script:PSMM_ColAccent]$(ConvertTo-PSMMSafe $Term)[/]$pad$Markup"
}

function script:Get-PSMMHelpText1 {
    param([AllowEmptyString()][string]$Text)
    if (-not $Text) { return '' }
    "[$script:PSMM_ColMute]$(ConvertTo-PSMMSafe $Text)[/]"
}

function script:Get-PSMMHelpSection {
    param([Parameter(Mandatory)][string]$Topic)
    $mid = [char]0x00B7
    switch ($Topic) {
        'grid' { @(
            (Get-PSMMHelpHead 'MAIN SCREEN (module grid)')
            (Get-PSMMHelpText1 'Every module your config files declare, one row each.')
            ''
            (Get-PSMMHelpTerm 'state' ("[$script:PSMM_ColOk]$([char]0x25CF) loaded[/][$script:PSMM_ColMute] (this session) $mid [/][$script:PSMM_ColWarn]$([char]0x25D0) installed[/][$script:PSMM_ColMute] (on disk)[/]"))
            (Get-PSMMHelpTerm '' ("[$script:PSMM_ColErr]$([char]0x25CB) missing[/][$script:PSMM_ColMute] $mid [/][$script:PSMM_ColInfo]$([char]0x25CC) unmanaged[/][$script:PSMM_ColMute] (in no config file - m shows them)[/]"))
            (Get-PSMMHelpTerm 'startup' (Get-PSMMHelpText1 'what happens at shell start: load / install (background) / off'))
            (Get-PSMMHelpTerm 'upkeep' (Get-PSMMHelpText1 'how psmm keeps it on disk: if-missing / check-only / latest'))
            (Get-PSMMHelpTerm '' (Get-PSMMHelpText1 '+pre means prerelease versions are allowed for that entry'))
            (Get-PSMMHelpTerm 'version' (Get-PSMMHelpText1 'loaded (or newest installed), prerelease label and all:'))
            (Get-PSMMHelpTerm '' ("$(Get-PSMMVersionMarkup -Version '0.1.0' -Prerelease 'beta8')[$script:PSMM_ColMute] is not [/]$(Get-PSMMVersionMarkup -Version '0.1.0')"))
            (Get-PSMMHelpTerm '' ("[$script:PSMM_ColWarn]$([char]0x21E1)[/][$script:PSMM_ColMute] = update available (after a k check); the cursor row[/]"))
            (Get-PSMMHelpTerm '' (Get-PSMMHelpText1 'names the target. pin = pinned to a version.'))
            (Get-PSMMHelpTerm 'scope' (Get-PSMMHelpText1 'user (CurrentUser) / all (AllUsers) / mixed. "all ro" means'))
            (Get-PSMMHelpTerm '' (Get-PSMMHelpText1 'the session is not elevated, so AllUsers copies are read-only.'))
            (Get-PSMMHelpTerm '' ("[$script:PSMM_ColErr]$([char]0x26A0)[/][$script:PSMM_ColMute] after the name: the entry has validation issues (g c details)[/]"))
            ''
            (Get-PSMMHelpText1 'The muted sentence under the table explains the cursor row in full')
            (Get-PSMMHelpText1 'words. The last hint row is the same on every screen: g goto,')
            (Get-PSMMHelpText1 '/ filter, ? help, ^q quit.')
            ''
            (Get-PSMMHelpRow 'space' 'select/deselect row (bulk actions target the selection,')
            (Get-PSMMHelpCont 'or just the cursor row when nothing is selected)')
            (Get-PSMMHelpRow 'enter' 'open the module action menu')
            (Get-PSMMHelpRow 'left/right' 'back out / open the row - the same pair on every screen')
            (Get-PSMMHelpRow '^l' 'load into this session')
            (Get-PSMMHelpRow '^u' 'unload from this session (^ means ctrl)')
            (Get-PSMMHelpRow 'i' 'install the targeted modules that are missing (background)')
            (Get-PSMMHelpRow 'u' 'update the targeted installed modules (background) -')
            (Get-PSMMHelpCont 'install and update are always separate keys')
            (Get-PSMMHelpRow 'k' 'check the gallery for updates (background)')
            (Get-PSMMHelpRow 'a' 'add a new entry')
            (Get-PSMMHelpRow 'r' 'reload everything from disk')
            (Get-PSMMHelpRow 'm' 'show/hide installed-but-unmanaged modules')
        ) }
        'module' { @(
            (Get-PSMMHelpHead 'MODULE MENU')
            (Get-PSMMHelpText1 'Facts and actions for one module, grouped by what they touch:')
            (Get-PSMMHelpText1 'session (this pwsh), upkeep (install/update/clean), entry (the config')
            (Get-PSMMHelpText1 'file line), files (the folders on disk), connection (Connect-*')
            (Get-PSMMHelpText1 'modules). Only actions that make sense for the row are offered.')
            ''
            (Get-PSMMHelpText1 'The facts panel answers "which copy is being used, and from where?":')
            (Get-PSMMHelpTerm 'path' (Get-PSMMHelpText1 'the folder the newest installed version lives in'))
            (Get-PSMMHelpTerm 'location' (Get-PSMMHelpText1 'the search-path root above it, its search order, and'))
            (Get-PSMMHelpTerm '' (Get-PSMMHelpText1 'whether that root is OneDrive-backed'))
            (Get-PSMMHelpTerm 'versions' (Get-PSMMHelpText1 'every installed version with its scope (x cleans up)'))
            (Get-PSMMHelpTerm 'cloud' (Get-PSMMHelpText1 'files still cloud-only - downloaded before the next load'))
            ''
            (Get-PSMMHelpRow '^l' 'load into this session')
            (Get-PSMMHelpRow '^u' 'unload from this session (^ means ctrl)')
            (Get-PSMMHelpRow 'i' 'install (when missing) - foreground, with progress')
            (Get-PSMMHelpRow 'u' 'update (when installed) - always a separate key')
            (Get-PSMMHelpRow 'b' "browse the module's commands with full help")
            (Get-PSMMHelpRow 'v' 'pin the entry to a version: pick from the versions on disk')
            (Get-PSMMHelpCont 'and in the gallery, or type a NuGet range like "[1.0,2.0)".')
            (Get-PSMMHelpCont 'Pinned modules are never nagged to update.')
            (Get-PSMMHelpRow 'w' 'allow / disallow prerelease versions for this entry')
            (Get-PSMMHelpRow 'x' 'remove all but the newest installed version')
            (Get-PSMMHelpRow 'p' 'move the module folder to another module location')
            (Get-PSMMHelpRow 's' 'check connection status (Connect-* modules: Graph, Az, EXO,')
            (Get-PSMMHelpCont 'PnP, Teams)')
            (Get-PSMMHelpRow 'o' 'disconnect the active session')
            (Get-PSMMHelpRow 'a' '(unmanaged modules) add to a config file')
            (Get-PSMMHelpRow 'e' 'edit the entry fields')
            (Get-PSMMHelpRow 'd' 'delete the entry')
            (Get-PSMMHelpRow 'm' 'move the entry to another config file')
            (Get-PSMMHelpRow 'left/right' 'back out / browse commands')
        ) }
        'commands' { @(
            (Get-PSMMHelpHead 'COMMAND BROWSER')
            (Get-PSMMHelpText1 'All commands the module exports.')
            ''
            (Get-PSMMHelpRow '/' 'filter (the same everywhere)')
            (Get-PSMMHelpRow 'enter' 'open tabbed help: Overview | Parameters | Examples')
            (Get-PSMMHelpRow 'left/right' 'back out / open - and, inside the help, switch tab')
            (Get-PSMMHelpRow 'up/dn' 'scroll')
            (Get-PSMMHelpRow 'c' 'copy the tab you are viewing to the clipboard')
            ''
            (Get-PSMMHelpText1 'Tip: help is much richer once the module is imported.')
        ) }
        'files' { @(
            (Get-PSMMHelpHead 'CONFIG FILES')
            (Get-PSMMHelpText1 'Every config source psmm found, in load order.')
            ''
            (Get-PSMMHelpRow 'space' 'toggle a whole file on/off (saved immediately)')
            (Get-PSMMHelpRow 'a' 'apply the load/unload changes to the running session')
            (Get-PSMMHelpRow 'n' 'create a new config, blank or from a scenario template')
            (Get-PSMMHelpRow 'm' 'move a file and keep it discoverable (Includes updated)')
            (Get-PSMMHelpRow 'left' 'back out')
            ''
            (Get-PSMMHelpText1 "A disabled file's entries are kept in the file untouched - disabling")
            (Get-PSMMHelpText1 'is how you park a whole module set ("work", "lab", ...).')
        ) }
        'paths' { @(
            (Get-PSMMHelpHead 'MODULE LOCATIONS')
            (Get-PSMMHelpText1 'Every folder PowerShell searches for modules, in search order.')
            (Get-PSMMHelpText1 'The FIRST entry is the CurrentUser location, which PowerShell')
            (Get-PSMMHelpText1 'derives from your Documents folder - when OneDrive backs up')
            (Get-PSMMHelpText1 'Documents (a common org policy), your modules silently live in')
            (Get-PSMMHelpText1 'OneDrive, and "Files On-Demand" can make them cloud-only')
            (Get-PSMMHelpText1 'placeholders that stall or fail module loading.')
            ''
            (Get-PSMMHelpRow 'd' 'download (hydrate) every cloud-only file under the')
            (Get-PSMMHelpCont 'highlighted path - asks how many to fetch at once first')
            (Get-PSMMHelpRow 'k' 'keep on this device: pin the folder so OneDrive keeps all')
            (Get-PSMMHelpCont 'files local from now on (downloads in the background)')
            (Get-PSMMHelpRow 'n' 'add a module location (creates the folder when needed) and')
            (Get-PSMMHelpCont 'optionally persist it for new sessions')
            (Get-PSMMHelpRow 'm' 'move every module folder from here to another location -')
            (Get-PSMMHelpCont 'gated behind typing "really move"; loaded modules and name')
            (Get-PSMMHelpCont 'collisions are skipped, never forced')
            (Get-PSMMHelpRow 's' 'set the primary (CurrentUser) module location - creates the')
            (Get-PSMMHelpCont 'folder if needed and writes the documented PSModulePath')
            (Get-PSMMHelpCont 'override to your user powershell.config.json. Caveat:')
            (Get-PSMMHelpCont 'Install-PSResource still installs to the Documents default.')
            (Get-PSMMHelpRow 'r' 'remove that override again')
            (Get-PSMMHelpRow 'left/right' 'back out / show what a location holds')
            ''
            (Get-PSMMHelpText1 'psmm also checks for cloud-only files before loading a module (with')
            (Get-PSMMHelpText1 'a prompt in the module menu / apply, silently with a status line in')
            (Get-PSMMHelpText1 'grid bulk loads).')
        ) }
        'gallery' { @(
            (Get-PSMMHelpHead 'GALLERY SEARCH')
            (Get-PSMMHelpText1 'Searches the PowerShell Gallery (read-only). Wildcards work:')
            (Get-PSMMHelpText1 'Az.*, Microsoft.Graph*.')
            ''
            (Get-PSMMHelpRow 'enter' 'add the highlighted module to one of your config files -')
            (Get-PSMMHelpCont 'pick install policy and mode, done')
            (Get-PSMMHelpRow '/' 'start a new search')
            (Get-PSMMHelpRow 'left/right' 'back out / add')
        ) }
        'cleanup' { @(
            (Get-PSMMHelpHead 'VERSION CLEANUP')
            (Get-PSMMHelpText1 'Update-Module and Update-PSResource never delete old versions -')
            (Get-PSMMHelpText1 'they accumulate on disk forever. This screen lists every module')
            (Get-PSMMHelpText1 'with more than one installed version.')
            ''
            (Get-PSMMHelpRow 'enter' 'prune one module to its newest version')
            (Get-PSMMHelpRow '^a' 'prune all of them (^ means ctrl)')
            (Get-PSMMHelpRow 'r' 'rescan')
            (Get-PSMMHelpRow 'left/right' 'back out / prune the row')
            ''
            (Get-PSMMHelpText1 'Without elevation, AllUsers copies are skipped automatically.')
        ) }
        'tasks' { @(
            (Get-PSMMHelpHead 'BACKGROUND TASKS')
            (Get-PSMMHelpText1 'Everything psmm runs in the background lands here: install batches')
            (Get-PSMMHelpText1 '(i), update batches (u), update checks (k), the unmanaged-module')
            (Get-PSMMHelpText1 'scan, and Update-Help.')
            ''
            (Get-PSMMHelpRow 'enter' "show a task's full output")
            (Get-PSMMHelpRow 'u' 'start a background Update-Help')
            (Get-PSMMHelpRow 'c' 'clear finished tasks')
            (Get-PSMMHelpRow 'left/right' 'back out / open the output')
            ''
            (Get-PSMMHelpText1 'The grid keeps working while tasks run; a one-line overlay shows')
            (Get-PSMMHelpText1 'progress.')
        ) }
        default { @() }
    }
}

# keys tab: the grouped two-column capsule layout (mock 2c).
function script:Get-PSMMHelpKeysLines {
    $col1 = @(
        "[$script:PSMM_ColAccent]navigate[/]"
        (Get-PSMMHint -NoLegend -Pairs @('up/dn=move', 'pgup/pgdn=page'))
        (Get-PSMMHint -NoLegend -Pairs @('home/end=top / bottom'))
        (Get-PSMMHint -NoLegend -Pairs @('/=filter (enter keeps, esc clears)'))
        # spelled out, never as arrow glyphs - one notation everywhere (gh#7)
        (Get-PSMMHint -NoLegend -Pairs @('left/right=back out / drill in'))
        ''
        "[$script:PSMM_ColAccent]go places[/]"
        (Get-PSMMHint -NoLegend -Pairs @('g=goto: h g f p t c x ?'))
        (Get-PSMMHint -NoLegend -Pairs @('esc=back one level'))
        (Get-PSMMHint -NoLegend -Pairs @('^q=quit from anywhere'))
        "[$script:PSMM_ColDim]^ = ctrl[/]"
    )
    $col2 = @(
        "[$script:PSMM_ColAccent]act on modules[/]"
        (Get-PSMMHint -NoLegend -Pairs @('space=select', 'enter=actions'))
        (Get-PSMMHint -NoLegend -Pairs @('i=install missing (background)'))
        (Get-PSMMHint -NoLegend -Pairs @('u=update installed (background)'))
        (Get-PSMMHint -NoLegend -Pairs @('k=check the gallery for updates'))
        (Get-PSMMHint -NoLegend -Pairs @('^l=load', '^u=unload'))
        (Get-PSMMHint -NoLegend -Pairs @('a=add entry', 'r=reload from disk'))
        (Get-PSMMHint -NoLegend -Pairs @('m=show/hide unmanaged'))
        ''
        "[$script:PSMM_ColAccent]everywhere[/]"
        (Get-PSMMHint -NoLegend -Pairs @('?=help', 'c=copy (text screens)'))
        "[$script:PSMM_ColDim]bulk verbs target the selection, or[/]"
        "[$script:PSMM_ColDim]the cursor row when none is selected[/]"
    )
    $w = 0
    foreach ($l in $col1) {
        $len = [Spectre.Console.Markup]::Remove($l).Length
        if ($len -gt $w) { $w = $len }
    }
    $w += 4
    $rows = for ($i = 0; $i -lt [Math]::Max($col1.Count, $col2.Count); $i++) {
        $l = if ($i -lt $col1.Count) { $col1[$i] } else { '' }
        $r = if ($i -lt $col2.Count) { $col2[$i] } else { '' }
        $pad = ' ' * [Math]::Max(0, $w - [Spectre.Console.Markup]::Remove($l).Length)
        "$l$pad$r"
    }
    @($rows)
}

# config tab: discovery order, file format, rules. The JSON sample is
# syntax-highlighted (gh#9) - it is the densest block of text in the whole UI.
function script:Get-PSMMHelpConfigLines {
    $out = [System.Collections.Generic.List[string]]::new()
    foreach ($l in (Get-PSMMHelpHead 'WHERE PSMM LOOKS (in load order)')) { $out.Add($l) }
    $out.Add((Get-PSMMHelpText1 ' 1. inline JSON in $PSMM_InlineJson (set in $PROFILE; read-only)'))
    $out.Add((Get-PSMMHelpText1 " 2. MAIN config: $(Get-PSMMMainConfigPath)"))
    $out.Add((Get-PSMMHelpText1 ' 3. files listed in the MAIN config''s "Includes" (one level, main only)'))
    $profileCfg = Get-PSMMProfileConfigPath
    if ($profileCfg) { $out.Add((Get-PSMMHelpText1 " 4. profile-dir: $profileCfg")) }
    $out.Add((Get-PSMMHelpText1 ' 5. legacy globs in $PSMM_JsonPath (default: psmodules.d next to $PROFILE)'))
    $out.Add('')
    foreach ($l in (Get-PSMMHelpHead 'FILE FORMAT (psmm-config.json)')) { $out.Add($l) }
    $json = @(
        ' {'
        ' "Enabled": true, // false = file parsed but nothing actioned'
        ' "Includes": ["C:\\path\\more.json"], // MAIN config only'
        ' "Modules": ['
        ' {'
        ' "Name": "ImportExcel", // required: gallery name'
        ' "FriendlyName": "Import Excel", // optional display name'
        ' "Description": "what/why", // optional'
        ' "Install": "IfMissing", // CheckOnly | IfMissing | Latest'
        ' "Mode": "Load", // Load | InstallOnly | Ignore'
        ' "Version": "1.2.3", // optional pin (or "[1.0,2.0)")'
        ' "Prerelease": true // optional: allow prerelease versions'
        ' }'
        ' ]'
        ' }'
    )
    foreach ($l in (Format-PSMMCode -Text $json -Language json)) { $out.Add($l) }
    $out.Add('')
    $out.Add((Get-PSMMHelpText1 ' The UI shows Mode as the startup column (load / install / off) and'))
    $out.Add((Get-PSMMHelpText1 ' Install as the upkeep column (if-missing / check-only / latest);'))
    $out.Add((Get-PSMMHelpText1 ' Prerelease shows there too, as "+pre".'))
    $out.Add('')
    foreach ($l in (Get-PSMMHelpHead 'RULES')) { $out.Add($l) }
    $out.Add((Get-PSMMHelpText1 ' - Only the MAIN config may include other files (one level deep, so'))
    $out.Add((Get-PSMMHelpText1 ' circular references are impossible). Includes anywhere else are'))
    $out.Add((Get-PSMMHelpText1 ' ignored with a warning.'))
    $out.Add((Get-PSMMHelpText1 ' - Same module in several files: the MAIN config wins (warning);'))
    $out.Add((Get-PSMMHelpText1 ' otherwise the first-loaded file wins (error-style warning).'))
    $out.Add((Get-PSMMHelpText1 ' - "Enabled": false switches a whole file off without losing entries.'))
    @($out)
}

# startup tab: the $PROFILE bootstrap and its knobs. The code is highlighted
# (gh#9); the prose around it is not.
function script:Get-PSMMHelpStartupLines {
    $out = [System.Collections.Generic.List[string]]::new()
    foreach ($l in (Get-PSMMHelpHead '$PROFILE BOOTSTRAP')) { $out.Add($l) }
    foreach ($l in (Format-PSMMCode -Text @(' Import-Module psmm; Invoke-PSMMStartup'))) { $out.Add($l) }
    $out.Add('')
    $out.Add((Get-PSMMHelpText1 ' Knobs, set before Import-Module:'))
    $knobs = @(
        ' $PSMM_StartupReport = $false # no per-module report at startup'
        ' $PSMM_BackgroundStartup = $false # run InstallOnly work inline'
        ' $PSMM_UpdateCheck = $false # no self-update check'
        ' $PSMM_InlineJson = ''{ ... }'' # config in the profile itself'
        ' $PSMM_JsonPath = ''~/psmodules.d/*.json'''
        ' $PSMM_Theme = ''glacier'' # glacier (default) | ember | moss'
    )
    foreach ($l in (Format-PSMMCode -Text $knobs)) { $out.Add($l) }
    $out.Add('')
    $out.Add((Get-PSMMHelpText1 ' Install and Mode are independent: Mode decides load / install-only /'))
    $out.Add((Get-PSMMHelpText1 ' ignore (and foreground vs background at startup); Install decides the'))
    $out.Add((Get-PSMMHelpText1 ' disk/gallery policy (never install / install when missing / update).'))
    $out.Add('')
    $out.Add((Get-PSMMHelpTerm ' Load' (Get-PSMMHelpText1 'imported into this session, in the foreground.')))
    $out.Add((Get-PSMMHelpTerm ' InstallOnly' (Get-PSMMHelpText1 'disk/gallery work only - deferred to a')))
    $out.Add((Get-PSMMHelpTerm '' (Get-PSMMHelpText1 'background job so your prompt appears sooner.')))
    $out.Add((Get-PSMMHelpTerm ' Ignore' (Get-PSMMHelpText1 'parsed but not actioned.')))
    $out.Add('')
    $out.Add((Get-PSMMHelpText1 ' Modules are imported into YOUR session (Import-Module -Global), so'))
    $out.Add((Get-PSMMHelpText1 ' their commands work at the prompt and Get-Module lists them.'))
    $out.Add('')
    $out.Add((Get-PSMMHelpText1 ' Each imported module''s import time is measured and reported, so you'))
    $out.Add((Get-PSMMHelpText1 ' always know which module is slowing your shell down.'))
    @($out)
}

# about tab: version, engine, self-update detail (the header bar shows only
# the compact flag - the exact command lives here).
function script:Get-PSMMHelpAboutLines {
    $ui = $script:PSMM_UI
    $lines = [System.Collections.Generic.List[string]]::new()
    foreach ($l in (Get-PSMMHelpHead 'psmm - PS Session Module Manager')) { $lines.Add($l) }
    $ver = if ($ui -and $ui.Version) { "v$($ui.Version)" } else { '' }
    $eng = if ($ui -and $ui.Engine) { "$($ui.Engine)" } else { '' }
    $lines.Add((Get-PSMMHelpText1 ("$ver $([char]0x00B7) install engine $eng$(if ($ui -and $ui.Elevated) { " $([char]0x00B7) elevated" })").Trim()))
    # a real terminal hyperlink - ctrl+click it (gh#10)
    $lines.Add((Get-PSMMLinkMarkup -Url 'https://github.com/PBNZ/psmm' -Text 'github.com/PBNZ/psmm'))
    $lines.Add('')
    if ($ui -and $ui.SelfUpdate) {
        $u = $ui.SelfUpdate
        $lines.Add("[$script:PSMM_ColWarn]$([char]0x21E1) psmm v$($u.Latest) is available (you have v$($u.Current))[/]")
        $lines.Add((Get-PSMMHelpText1 'update with:'))
        foreach ($l in (Format-PSMMCode -Text @(" $($u.Command)"))) { $lines.Add($l) }
        $lines.Add((Get-PSMMHelpText1 'then restart pwsh.'))
    } else {
        $lines.Add((Get-PSMMHelpText1 'psmm checks the gallery for its own updates once a day (cached,'))
        # the string must be built BEFORE the call: passed as bare arguments,
        # 'a' + $glyph + ',' binds as five positional args and a non-advanced
        # function silently drops the surplus - the glyph vanished
        $lines.Add((Get-PSMMHelpText1 ('never in the profile hot path); the header bar flags one with ' + [char]0x21E1 + ',')))
        $lines.Add((Get-PSMMHelpText1 'and this tab then shows the exact update command.'))
    }
    $lines.Add('')
    $lines.Add((Get-PSMMHelpText1 'While psmm is in prerelease, beta-to-beta updates need a forced'))
    $lines.Add((Get-PSMMHelpText1 'reinstall:'))
    foreach ($l in (Format-PSMMCode -Text @(' Install-PSResource psmm -Prerelease -Reinstall'))) { $lines.Add($l) }
    $lines.Add((Get-PSMMHelpText1 'then restart pwsh. u on psmm''s own grid row handles this correctly.'))
    @($lines)
}

# The five tabs for a topic. Values are arrays of MARKUP lines - including
# 'this screen', which used to be escaped plain text (gh#8).
function script:Get-PSMMHelpTabs {
    param([string]$Topic = 'grid')
    [ordered]@{
        'this screen' = @(Get-PSMMHelpSection -Topic $Topic)
        'keys'        = @(Get-PSMMHelpKeysLines)
        'config'      = @(Get-PSMMHelpConfigLines)
        'startup'     = @(Get-PSMMHelpStartupLines)
        'about'       = @(Get-PSMMHelpAboutLines)
    }
}

# Flattened plain-text help (all tabs) - used by tests and as a plain doc.
function script:Get-PSMMHelpText {
    param([string]$Topic = 'grid')
    $tabs = Get-PSMMHelpTabs -Topic $Topic
    $lines = [System.Collections.Generic.List[string]]::new()
    $lines.Add('psmm - PS Session Module Manager')
    $lines.Add('================================')
    foreach ($name in $tabs.get_Keys()) {
        $lines.Add('')
        $lines.Add("== $($name.ToUpperInvariant()) ==")
        $lines.Add('')
        foreach ($l in $tabs[$name]) { $lines.Add([Spectre.Console.Markup]::Remove($l)) }
    }
    $lines
}

# One frame of the tabbed help. $State: @{ Tab; Scroll; Filter; FilterMode;
# Status }.
function script:Build-PSMMHelpView {
    param(
        [Parameter(Mandatory)] $State,
        [Parameter(Mandatory)] $Tabs
    )
    $names = @($Tabs.get_Keys())
    $State.Tab = [Math]::Max(0, [Math]::Min($State.Tab, $names.Count - 1))
    $lines = @($Tabs[$names[$State.Tab]])
    if ($State.Filter) {
        $needle = $State.Filter
        $lines = @($lines | Where-Object { Test-PSMMFilterMatch -Text ([Spectre.Console.Markup]::Remove($_)) -Filter $needle })
    }
    $win = Get-PSMMWinSize
    $page = [Math]::Max(5, $win.Height - 9)
    $State.Scroll = [Math]::Max(0, [Math]::Min($State.Scroll, [Math]::Max(0, $lines.Count - $page)))
    $visible = @($lines | Select-Object -Skip $State.Scroll -First $page)
    $tabBar = (0..($names.Count - 1) | ForEach-Object {
        if ($_ -eq $State.Tab) { "[$script:PSMM_ColAccent underline]$($names[$_])[/]" } else { "[$script:PSMM_ColDim]$($names[$_])[/]" }
    }) -join ' '
    $pos = if ($lines.Count -gt $page) {
        " [$script:PSMM_ColMute]lines $($State.Scroll + 1)-$([Math]::Min($lines.Count, $State.Scroll + $page))/$($lines.Count)[/]"
    } else { '' }
    $flt = Get-PSMMFilterMarkup -State $State
    $items = [System.Collections.Generic.List[Spectre.Console.Rendering.IRenderable]]::new()
    $items.Add([Spectre.Console.Markup]::new((Get-PSMMHeaderBar -Breadcrumb @('home', 'help') -CountsMarkup "$pos$flt")))
    $items.Add([Spectre.Console.Markup]::new($tabBar))
    $panel = [Spectre.Console.Panel]::new([Spectre.Console.Markup]::new(($visible -join "`n")))
    $panel.Border = [Spectre.Console.BoxBorder]::Rounded
    $panel.BorderStyle = Get-PSMMBorderStyle
    $items.Add($panel)
    if ($State.FilterMode) {
        $items.Add([Spectre.Console.Markup]::new((Get-PSMMHint -Pairs @('type=filter', 'enter=apply', 'esc=clear & exit filter'))))
    } else {
        $items.Add([Spectre.Console.Markup]::new((Get-PSMMHint -Pairs @('left/right=switch tab', 'up/dn=scroll', 'c=copy tab') -NoLegend)))
        $items.Add([Spectre.Console.Markup]::new((Get-PSMMPersistentHint -Pairs @("g=goto$([char]0x2026)", '/=filter', 'esc=back', '^q=quit'))))
    }
    if ($State.Status) { $items.Add([Spectre.Console.Markup]::new($State.Status)) }
    [Spectre.Console.Rows]::new($items)
}

function script:Show-PSMMHelpScreen {
    param(
        [string]$Topic = 'grid',
        [string]$InitialTab = 'this screen'
    )
    $tabs = Get-PSMMHelpTabs -Topic $Topic
    $names = @($tabs.get_Keys())
    $start = [Array]::IndexOf($names, $InitialTab)
    $st = @{ Tab = [Math]::Max(0, $start); Scroll = 0; Filter = ''; FilterMode = $false; Status = '' }
    Clear-PSMMScreen
    Invoke-PSMMLive -Body {
        param($ctx)
        while ($true) {
            if ($script:PSMM_UI.HardQuit) { return }
            $ctx.UpdateTarget((Build-PSMMHelpView -State $st -Tabs $tabs))
            $ctx.Refresh()
            $k = Read-PSMMKeyResize
            if ($null -eq $k) { continue }
            if (Test-PSMMHardQuitKey $k) { $script:PSMM_UI.HardQuit = $true; return }
            if ($st.FilterMode) {
                $r = Invoke-PSMMFilterKey -State $st -KeyInfo $k
                if ($r) { $st.Scroll = 0; continue }
                $null = Invoke-PSMMPagerNav -State $st -KeyInfo $k
                continue
            }
            if ($k.KeyChar -eq 'g') {
                $dest = Read-PSMMGotoKey -BaseRenderable (Build-PSMMHelpView -State $st -Tabs $tabs)
                if ($dest) { $script:PSMM_UI.Goto = $dest; return }
                continue
            }
            if (Test-PSMMHomeKey $k) { $script:PSMM_UI.Goto = 'home'; return }
            $st.Status = ''
            switch ($k.Key) {
                ([ConsoleKey]::LeftArrow)  { $st.Tab = ($st.Tab + $names.Count - 1) % $names.Count; $st.Scroll = 0; continue }
                ([ConsoleKey]::RightArrow) { $st.Tab = ($st.Tab + 1) % $names.Count; $st.Scroll = 0; continue }
                ([ConsoleKey]::Escape)     {
                    if ($st.Filter) { $st.Filter = ''; $st.Scroll = 0; continue }
                    return
                }
                default {
                    if ($k.KeyChar -eq '/') { $st.FilterMode = $true; continue }
                    if ($k.KeyChar -eq 'c') {
                        $plain = @($tabs[$names[$st.Tab]] | ForEach-Object { [Spectre.Console.Markup]::Remove($_) })
                        $st.Status = Copy-PSMMText -Text ($plain -join [Environment]::NewLine)
                        continue
                    }
                    $null = Invoke-PSMMPagerNav -State $st -KeyInfo $k
                }
            }
        }
    }
    Clear-PSMMScreen
}