src/UI/10-Grid.ps1

# 10-Grid.ps1 — the main module grid: the psmm home screen.
# Direct Spectre.Console construction in the hot path (~1-2 ms/frame; the
# cmdlet wrappers cost ~60 ms/call - unacceptable per keypress).

# Targets for a bulk action: selected rows, else the cursor row.
function script:Get-PSMMTargets {
    if ($script:PSMM_UI.Sel.Count) { return @($script:PSMM_UI.Sel | Sort-Object) }
    if ($script:PSMM_UI.View.Count) { return @($script:PSMM_UI.View[$script:PSMM_UI.Cursor]) }
    @()
}

# Build the grid renderable (table + header + hints + overlays) from cached
# state only - no disk or network access here.
function script:Build-PSMMGrid {
    $ui = $script:PSMM_UI
    $entries = $ui.Entries
    # filtered view: array of entry indices currently visible
    $ui.View = if ($ui.Filter) {
        @(0..([Math]::Max(0, $entries.Count - 1)) | Where-Object {
            (Test-PSMMFilterMatch -Text $entries[$_].Name -Filter $ui.Filter) -or
            (Test-PSMMFilterMatch -Text $entries[$_].FriendlyName -Filter $ui.Filter)
        })
    } elseif ($entries.Count) { @(0..($entries.Count - 1)) } else { @() }
    $n = $ui.View.Count
    $win = Get-PSMMWinSize
    $vp = Get-PSMMViewport -State $ui -Count $n -Rows ($win.Height - 12)   # heading + footer + overlay rows

    # Build EVERY row of the filtered view (not just the viewport) so column
    # widths come from ALL content: scrolling never resizes the table
    # (2026-07-05 live-run feedback: width jitter). ponytail: O(rows) markup
    # strip per frame - fine to a few hundred entries.
    # The Name cell stays RAW here; it is capped, escaped and styled below,
    # once the other columns' widths are known - the old fixed "width - 84"
    # budget under-reserved and let the table outgrow the terminal, which
    # Spectre renders as a bare '...'.
    $rows = [System.Collections.Generic.List[string[]]]::new()
    $issueFlags = [System.Collections.Generic.List[bool]]::new()
    $verCol = 5
    $verGhost = 0   # widest version cell WITH its ⇡ target: targets render on
                    # the cursor row only, but the column must reserve their
                    # width so moving the cursor never resizes the table
    for ($v = 0; $v -lt $n; $v++) {
        $idx = $ui.View[$v]
        $e = $entries[$idx]
        $isCur = ($v -eq $ui.Cursor)
        $isUnmanaged = [bool]$e.PSObject.Properties['Unmanaged']
        $state = Get-PSMMStateMarkup -Entry $e
        $src = switch ($e.Source) {
            '<profile inline>' { 'profile' }
            '<unmanaged>'      { '-' }
            default            { [System.IO.Path]::GetFileNameWithoutExtension($e.Source) }
        }
        # read-only is the exception worth marking; rw is the silent default
        $ro = if (-not $isUnmanaged -and -not $e.Writable) { " [$script:PSMM_ColDim]ro[/]" } else { '' }
        $file = "[$script:PSMM_ColDim]$(ConvertTo-PSMMSafe (Get-PSMMTrunc $src 16))[/]$ro"
        $name = "$($e.Name)"
        $scope = switch ($e.InstallScope) {
            'CurrentUser' { "[$script:PSMM_ColDim]user[/]" }
            'AllUsers'    { if ($ui.Elevated) { "[$script:PSMM_ColDim]all[/]" } else { "[$script:PSMM_ColDim]all ro[/]" } }
            'mixed'       { "[$script:PSMM_ColWarn]mixed[/]" }
            default       { "[$script:PSMM_ColDim]-[/]" }
        }
        $startupWord = Get-PSMMStartupWord $e.Mode
        $startup = if ($startupWord -in 'off', '-') { "[$script:PSMM_ColDim]$startupWord[/]" } else { $startupWord }
        $upkeepWord = Get-PSMMUpkeepWord $e.Install
        # prerelease opt-in is part of the upkeep policy, so it reads there
        if ($e.AllowPrerelease) { $upkeepWord += '+pre' }
        $upkeep = "[$script:PSMM_ColDim]$upkeepWord[/]"
        # the version shown always carries its prerelease label (gh#6):
        # 0.1.0-beta8 and 0.1.0 share one [version] and must not look alike
        $verBase = if ($e.LoadedVersion) { Get-PSMMVersionMarkup -Version $e.LoadedVersion -Prerelease $e.LoadedPrerelease }
                   else { Get-PSMMVersionMarkup -Version $e.InstalledVersion -Prerelease $e.InstalledPrerelease }
        $verPlain = if ($e.LoadedVersion) { Get-PSMMVersionText -Version $e.LoadedVersion -Prerelease $e.LoadedPrerelease }
                    else { Get-PSMMVersionText -Version $e.InstalledVersion -Prerelease $e.InstalledPrerelease }
        if (-not $verPlain) { $verPlain = '-' }
        $pin = if ($e.PinnedExact) { " [$script:PSMM_ColDim]pin[/]" } else { '' }
        # '⇡', not '^': the design system reserves '^' for the ctrl legend
        $ver = $verBase
        if ($e.UpdateAvailable) {
            $latestTxt = Get-PSMMVersionText -Version $e.LatestVersion -Prerelease $e.LatestPrerelease
            $ver = if ($isCur -and $e.LatestVersion) { "$verBase [$script:PSMM_ColWarn]$([char]0x21E1) $(ConvertTo-PSMMSafe $latestTxt)[/]" }
                   else { "$verBase [$script:PSMM_ColWarn]$([char]0x21E1)[/]" }
            if ($e.LatestVersion) {
                $wide = $verPlain.Length + 3 + $latestTxt.Length + $(if ($pin) { 4 } else { 0 })
                if ($wide -gt $verGhost) { $verGhost = $wide }
            }
        }
        $ver += $pin
        # the selection dot; the cursor bar is prepended into the same mark
        # slot at render time, one char to its LEFT (mockup 2a) - the bar
        # must never cover the dot (live-run feedback 2026-07-20)
        $mark = if ($ui.Sel.Contains($idx)) { "[$script:PSMM_ColOk]$([char]0x25AA)[/]" } else { ' ' }
        $issueFlags.Add([bool]$e.Issues.Count)
        $rows.Add([string[]]@($mark, $name, $state, $startup, $upkeep, $ver, $scope, $file))
    }

    # lowercase + dim headers, plain-word column names (design §5); plain text
    # here for width maths, dim markup applied when the columns are created
    $headers = @(' ', 'module', 'state', 'startup', 'upkeep', 'version', 'scope', 'file')
    $nameCol = 1
    $widths = @(foreach ($h in $headers) { $h.Length })
    for ($ci = 0; $ci -lt $headers.Count; $ci++) {
        for ($ri = 0; $ri -lt $rows.Count; $ri++) {
            # the module column is still raw text (styled after capping);
            # issues add a trailing ' ⚠' the width must account for
            $len = if ($ci -eq $nameCol) { $rows[$ri][$nameCol].Length + $(if ($issueFlags[$ri]) { 2 } else { 0 }) }
                   else { Get-PSMMCellWidth $rows[$ri][$ci] }
            if ($len -gt $widths[$ci]) { $widths[$ci] = $len }
        }
    }
    if ($verGhost -gt $widths[$verCol]) { $widths[$verCol] = $verGhost }
    # Exact fit: content + 2 padding per column + border verticals. The
    # module column flexes down to 14 chars; below the resulting minimum,
    # Spectre would collapse the table to a bare '...' - render a clear
    # too-small message instead (design system: too-small terminal).
    # column 0 is the mark slot: cursor bar ▌ + selection dot ▪ side by side
    $widths[0] = 2
    # per-column padding (2) + the panel's outer border (2); no inner
    # verticals any more (mockup 2a)
    $overhead = (2 * $headers.Count) + 2
    $fixed = ($widths | Measure-Object -Sum).Sum - $widths[$nameCol]
    $minName = [Math]::Min($widths[$nameCol], 14)
    if ($win.Width -lt ($fixed + $overhead + $minName) -or $win.Height -lt 14) {
        return (Get-PSMMTooSmallView -MinWidth ($fixed + $overhead + $minName) -MinHeight 14)
    }
    $nameCap = [Math]::Min($widths[$nameCol], [Math]::Max(14, $win.Width - $overhead - $fixed))
    $widths[$nameCol] = $nameCap
    for ($v = 0; $v -lt $n; $v++) {
        $cap = if ($issueFlags[$v]) { $nameCap - 2 } else { $nameCap }
        $nm = ConvertTo-PSMMSafe (Get-PSMMTrunc $rows[$v][$nameCol] $cap)
        if ($v -eq $ui.Cursor) { $nm = "[bold $script:PSMM_ColAccent]$nm[/]" }
        if ($issueFlags[$v]) { $nm += " [$script:PSMM_ColErr]$([char]0x26A0)[/]" }
        $rows[$v][$nameCol] = $nm
    }

    # borderless grid in a rounded panel (mockup 2a): outer frame only, no
    # column separators, no header rule. Padding lives INSIDE the cells so
    # the cursor row's background paints edge to edge. In the mark slot the
    # cursor bar sits immediately LEFT of the selection dot - it must never
    # cover it (live-run fix 4).
    $G = [Spectre.Console.Grid]::new()
    for ($ci = 0; $ci -lt $headers.Count; $ci++) {
        $col = [Spectre.Console.GridColumn]::new()
        $col.Padding = [Spectre.Console.Padding]::new(0, 0, 0, 0)
        $col.NoWrap = $true
        [void]$G.AddColumn($col)
    }
    $headerCells = [string[]]@(for ($ci = 0; $ci -lt $headers.Count; $ci++) {
        $h = $headers[$ci].Trim()
        if ($h) { " [$script:PSMM_ColDim]$h[/]" + (' ' * ([Math]::Max(0, $widths[$ci] - $h.Length) + 1)) }
        else { ' ' * ($widths[$ci] + 2) }
    })
    [void][Spectre.Console.GridExtensions]::AddRow($G, $headerCells)
    for ($v = $vp.First; $v -le $vp.Last; $v++) {
        $isCur = ($v -eq $ui.Cursor)
        $cells = [string[]]@(for ($ci = 0; $ci -lt $headers.Count; $ci++) {
            $cell = if ($ci -eq 0) {
                $bar = if ($isCur) { "[$script:PSMM_ColAccent]$([char]0x258C)[/]" } else { ' ' }
                $bar + $rows[$v][0]
            } else { $rows[$v][$ci] }
            $len = Get-PSMMCellWidth $cell
            $padded = ' ' + $cell + (' ' * ([Math]::Max(0, $widths[$ci] - $len) + 1))
            if ($isCur) { "[default on $script:PSMM_ColRowBg]$padded[/]" } else { $padded }
        })
        [void][Spectre.Console.GridExtensions]::AddRow($G, $cells)
    }
    # pad very short lists with blank rows so a fresh one-entry grid doesn't
    # look collapsed (2026-07-05 feedback)
    for ($pad = $n; $pad -lt 5; $pad++) {
        [void][Spectre.Console.GridExtensions]::AddRow($G, [string[]]@(foreach ($w in $widths) { ' ' * ($w + 2) }))
    }
    $T = [Spectre.Console.Panel]::new($G)
    $T.Border = [Spectre.Console.BoxBorder]::Rounded
    $T.BorderStyle = Get-PSMMBorderStyle
    $T.Padding = [Spectre.Console.Padding]::new(0, 0, 0, 0)

    $sel = $ui.Sel.Count
    $pos = Get-PSMMPositionMarkup -State $ui -Count $n -Viewport $vp
    $flt = Get-PSMMFilterMarkup -State $ui
    $head = if ($sel) { "[$script:PSMM_ColOk]$([char]0x25AA) $sel selected[/]$pos$flt" } else { "[$script:PSMM_ColMute]none selected[/]$pos$flt" }

    # two tiers (design v2 §3): contextual verb rows, then the persistent
    # strip - navigation letters live in the g goto overlay, not here
    $hintRows = if ($ui.FilterMode) {
        @(
            (Get-PSMMHint -Pairs @('type=filter', 'enter=apply', 'esc=clear & exit filter')),
            (Get-PSMMHint -Pairs @('up/dn=move'))
        )
    } else {
        @(
            (Get-PSMMHint -Pairs @('i=install', 'u=update', 'k=check updates', '^l=load', '^u=unload')),
            (Get-PSMMHint -Pairs @('space=select', 'enter=actions', 'left/right=back / open', 'a=add', 'r=reload', 'm=unmanaged')),
            (Get-PSMMPersistentHint)
        )
    }

    # psmm and its UI engine are infrastructure: counting them as "loaded"
    # overstates what YOUR session is carrying (gh#16)
    $loaded = @($entries | Where-Object { $_.Loaded -and -not (Test-PSMMOwnModule -Name $_.Name) }).Count
    $updates = @($entries | Where-Object { $_.UpdateAvailable }).Count
    $counts = "$($entries.Count) modules $([char]0x00B7) $loaded loaded$(if ($updates) { " $([char]0x00B7) $updates updates" })"
    $items = [System.Collections.Generic.List[Spectre.Console.Rendering.IRenderable]]::new()
    $items.Add([Spectre.Console.Markup]::new((Get-PSMMHeaderBar -Breadcrumb @('home') -CountsMarkup "[$script:PSMM_ColDim]$counts[/]")))
    $items.Add($T)
    $items.Add([Spectre.Console.Markup]::new($head))
    # context sentence for the cursor row (§5): the verbose explanation the
    # plain-word columns don't have to carry
    if ($n) { $items.Add([Spectre.Console.Markup]::new((Get-PSMMContextMarkup -Entry $entries[$ui.View[$ui.Cursor]]))) }
    foreach ($hr in $hintRows) { $items.Add([Spectre.Console.Markup]::new($hr)) }

    # deferred-startup job status (from Invoke-PSMMStartup)
    $jline = Get-PSMMStartupJobMarkup
    if ($jline) { $items.Add([Spectre.Console.Markup]::new($jline)) }

    # background tasks side overlay (#25) - one unobtrusive line
    $ts = Get-PSMMTaskSummary
    if ($ts) {
        $spin = if ($ts.RunningCount) { "[$script:PSMM_ColInfo]~[/] " } else { '' }
        $items.Add([Spectre.Console.Markup]::new("$spin[$script:PSMM_ColMute]tasks: $(ConvertTo-PSMMSafe $ts.Text) (g t=details)[/]"))
    }

    # unmanaged notice, once the scan is in and the rows are hidden
    if ($ui.Unmanaged -and -not $ui.ShowUnmanaged -and @($ui.Unmanaged).Count) {
        $items.Add([Spectre.Console.Markup]::new("[$script:PSMM_ColMute]$(@($ui.Unmanaged).Count) installed module(s) not in your config - m shows them[/]"))
    }

    # newer psmm available: the header bar carries the compact ⇡ flag; the
    # exact update command lives in help › about (v2 §2)

    # standing OneDrive diagnosis (cached at init - no per-frame path checks)
    if ($ui.OneDrivePrimary) {
        $items.Add([Spectre.Console.Markup]::new("[$script:PSMM_ColWarn]primary module location is inside OneDrive - cloud-only files can break loading (g p=details)[/]"))
    }

    $warnings = Get-PSMMWarning
    if ($warnings.Count) { $items.Add([Spectre.Console.Markup]::new("[$script:PSMM_ColWarn]$($warnings.Count) config warning(s) - g f / g c show details[/]")) }
    if ($ui.Status) { $items.Add([Spectre.Console.Markup]::new($ui.Status)) }
    [Spectre.Console.Rows]::new($items)
}

# One muted sentence explaining the cursor row in full words (§5): what the
# entry does at shell start, whether it is imported now, and what is on disk.
function script:Get-PSMMContextMarkup {
    param([Parameter(Mandatory)] $Entry)
    $isUnmanaged = [bool]$Entry.PSObject.Properties['Unmanaged']
    # the columns say "psmm's own"; the sentence is where that gets explained
    if (-not $isUnmanaged -and (Test-PSMMOwnModule -Name $Entry.Name) -and ($Entry.Loaded -or $Entry.Installed)) {
        # .Loaded is the honest answer for the USER's session: psmm's own copy
        # is imported privately and deliberately does not count (gh#16)
        $what = if ($Entry.Loaded) { 'also imported in your own session' }
                else { 'psmm imports it privately - not part of your session' }
        $ver = Get-PSMMVersionText -Version $Entry.InstalledVersion -Prerelease $Entry.InstalledPrerelease
        $upd = if ($Entry.UpdateAvailable -and $Entry.LatestVersion) {
            ", v$(Get-PSMMVersionText -Version $Entry.LatestVersion -Prerelease $Entry.LatestPrerelease) available (u updates)"
        } else { '' }
        return "[$script:PSMM_ColAccent]$(ConvertTo-PSMMSafe $Entry.Name)[/] [$script:PSMM_ColMute]$([char]0x2014) psmm's own $([char]0x00B7) $what $([char]0x00B7) v$(ConvertTo-PSMMSafe $ver) on disk$(ConvertTo-PSMMSafe $upd)[/]"
    }
    $startup = if ($isUnmanaged) { 'installed but not in any config file' }
               else {
                   switch ("$($Entry.Mode)") {
                       'Load' {
                           switch ("$($Entry.Install)") {
                               'Latest'    { 'imports at shell start, updating to latest first' }
                               'CheckOnly' { 'imports at shell start, never auto-installed' }
                               default     { 'imports at shell start, installing first when missing' }
                           }
                       }
                       'InstallOnly' {
                           switch ("$($Entry.Install)") {
                               'Latest'    { 'background-updates to latest at shell start' }
                               'CheckOnly' { 'checked at shell start, never installed' }
                               default     { 'background-installs at shell start when missing' }
                           }
                       }
                       'Ignore' { 'off - nothing happens at shell start' }
                       default  { 'no startup action' }
                   }
               }
    $session = if ($Entry.Loaded) { "imported this session (v$(Get-PSMMVersionText -Version $Entry.LoadedVersion -Prerelease $Entry.LoadedPrerelease))" }
               else { 'not imported this session' }
    $disk = if ($Entry.Installed) {
        $d = "v$(Get-PSMMVersionText -Version $Entry.InstalledVersion -Prerelease $Entry.InstalledPrerelease) on disk"
        if ($Entry.UpdateAvailable -and $Entry.LatestVersion) {
            $d += ", v$(Get-PSMMVersionText -Version $Entry.LatestVersion -Prerelease $Entry.LatestPrerelease) available (u updates)"
        }
        if ($Entry.Version) { $d += " - pinned to $($Entry.Version)" }
        if ($Entry.AllowPrerelease) { $d += ' - prereleases allowed' }
        $d
    } else { 'not installed' }
    "[$script:PSMM_ColAccent]$(ConvertTo-PSMMSafe $Entry.Name)[/] [$script:PSMM_ColMute]$([char]0x2014) $startup $([char]0x00B7) $session $([char]0x00B7) $(ConvertTo-PSMMSafe $disk)[/]"
}

# Markup line for the deferred startup job ('' when idle/no job).
function script:Get-PSMMStartupJobMarkup {
    $j = Get-PSMMStartupJob
    if (-not $j) { return '' }
    $total = Get-PSMMStartupJobTotal
    if ($j.State -in 'NotStarted', 'Running') {
        $done = 0; try { $done = @(Receive-Job -Job $j -Keep -ErrorAction SilentlyContinue).Count } catch { }
        return "[$script:PSMM_ColMute]background startup: $done/$total module task(s) done...[/]"
    }
    if ($j.State -eq 'Completed') {
        $out = @(); try { $out = @(Receive-Job -Job $j -Keep -ErrorAction SilentlyContinue) } catch { }
        $fails = @($out | Where-Object { "$_" -like 'FAILED *' } | ForEach-Object { if ("$_" -match '^FAILED\s+([^:]+)') { $Matches[1] } })
        if ($fails.Count) { return "[$script:PSMM_ColWarn]background startup: $($fails.Count) of $total FAILED - $(ConvertTo-PSMMSafe ($fails -join ', ')) (i on the row retries)[/]" }
        return "[$script:PSMM_ColOk]background startup: all $total module task(s) ok[/]"
    }
    if ($j.State -in 'Failed', 'Stopped') { return "[$script:PSMM_ColErr]background startup job failed - see t (tasks)[/]" }
    ''
}

# Show/hide the unmanaged rows (grid verb 'm').
function script:Invoke-PSMMUnmanagedToggle {
    $ui = $script:PSMM_UI
    if ($ui.Unmanaged) {
        $ui.ShowUnmanaged = -not $ui.ShowUnmanaged
        Sync-PSMMUIEntries
        $ui.Status = if ($ui.ShowUnmanaged) { "[$script:PSMM_ColOk]showing $(@($ui.Unmanaged).Count) unmanaged module(s)[/]" }
                     else { "[$script:PSMM_ColMute]unmanaged modules hidden[/]" }
    } else {
        $ui.Status = "[$script:PSMM_ColWarn]unmanaged scan still running (g t=details)[/]"
    }
}

# Quiet, fast actions that stay inside the live grid. $Context lets us
# repaint a "working on X..." status BEFORE each module so the user always
# sees the keypress registered (#5).
function script:Invoke-PSMMBulk {
    param([ValidateSet('Load', 'Unload')][string]$Action, $Context)
    $ui = $script:PSMM_UI
    $targets = Get-PSMMTargets
    $ok = 0; $fail = 0; $i = 0; $skippedOwn = 0
    foreach ($t in $targets) {
        $e = $ui.Entries[$t]; $i++
        # psmm never unloads itself or its own UI engine - that pulls the rug
        # out from under the screen you are looking at (gh#16)
        if ($Action -eq 'Unload' -and (Test-PSMMOwnModule -Name $e.Name)) { $skippedOwn++; continue }
        $verbing = if ($Action -eq 'Load') { 'loading' } else { 'unloading' }
        $ui.Status = "[$script:PSMM_ColAccent]$verbing $(ConvertTo-PSMMSafe $e.Name) ($i/$($targets.Count))...[/]"
        if ($Context) { $Context.UpdateTarget((Build-PSMMGrid)); $Context.Refresh() }
        # cloud-only files hydrate silently here (no prompt inside the live
        # grid) - the status line shows what is happening and why it may wait
        if ($Action -eq 'Load') {
            $cloud = @(Get-PSMMModuleCloudOnlyFile -Name $e.Name)
            if ($cloud.Count) {
                # parallel at the default concurrency - no prompt inside the
                # live grid, but no reason to crawl either (gh#14)
                $par = Get-PSMMHydrationDefault
                $ui.Status = "[$script:PSMM_ColWarn]downloading $($cloud.Count) cloud-only file(s) for $(ConvertTo-PSMMSafe $e.Name) from OneDrive ($par at a time)...[/]"
                if ($Context) { $Context.UpdateTarget((Build-PSMMGrid)); $Context.Refresh() }
                $null = Invoke-PSMMFileHydration -Files $cloud -ThrottleLimit $par
            }
        }
        try {
            if ($Action -eq 'Load') {
                # via Import-PSMMModuleTimed, not a bare Import-Module: it is
                # the one place that knows about -Global (gh#2) AND about
                # honouring an exact version pin - the grid's bulk load used to
                # quietly load whatever version came first
                Import-PSMMModuleTimed -Entry $e
            } else {
                Remove-Module -Name $e.Name -Force -ErrorAction Stop
            }
            $ok++
        } catch { $fail++ }
        Update-PSMMLoaded -Entries $ui.Entries
        if ($Context) { $Context.UpdateTarget((Build-PSMMGrid)); $Context.Refresh() }
    }
    $ui.Sel.Clear()
    $verb = if ($Action -eq 'Load') { 'loaded' } else { 'unloaded' }
    $own = if ($skippedOwn) { " [$script:PSMM_ColMute]($skippedOwn skipped - psmm's own)[/]" } else { '' }
    $ui.Status = if ($fail) { "[$script:PSMM_ColWarn]$verb $ok, $fail failed[/]$own" } else { "[$script:PSMM_ColOk]$verb $ok[/]$own" }
}

# Launch install (or update, with -Update) of the targeted rows as a
# BACKGROUND task (#25): the grid stays fully usable; the overlay shows
# progress; state refreshes when the task lands (see Receive-PSMMUITask).
# Install and update are separate actions on separate keys (design system):
# 'i' installs the targets that are missing, 'u' updates the installed ones.
function script:Start-PSMMInstallTask {
    param([switch]$Update)
    $ui = $script:PSMM_UI
    $verb = if ($Update) { 'update' } else { 'install' }
    $targets = @(Get-PSMMTargets | Where-Object {
        $e = $ui.Entries[$_]
        if ($Update) { [bool]$e.Installed } else { -not $e.Installed }
    })
    if (-not $targets.Count) {
        $ui.Status = if ($Update) { "[$script:PSMM_ColWarn]nothing to update - no installed module targeted (i installs missing ones)[/]" }
                     else { "[$script:PSMM_ColWarn]nothing to install - every targeted module is already installed (u updates)[/]" }
        return
    }
    $mods = @(foreach ($t in $targets) {
        $e = $ui.Entries[$t]
        [pscustomobject]@{ Name = $e.Name; Update = [bool]$Update; Version = $e.Version; Prerelease = [bool]$e.AllowPrerelease }
    })
    $names = @($mods.Name)
    $null = Start-PSMMTask -Label "${verb}: $($names -join ', ')" -Kind 'install' -Data $names -ArgumentList (, $mods) -ScriptBlock {
        param($mods)
        foreach ($m in $mods) {
            try {
                $psrg = [bool](Get-Command Install-PSResource -ErrorAction SilentlyContinue)
                if ($psrg) {
                    # prerelease label of the newest installed copy: a
                    # label-only bump needs Install -Prerelease -Reinstall,
                    # Update-PSResource cannot see it (same logic as
                    # Install-PSMMModule; module functions are out of reach
                    # in this thread job)
                    $pre = $null
                    $newest = @(Get-Module -ListAvailable -Name $m.Name -ErrorAction SilentlyContinue | Sort-Object Version -Descending) | Select-Object -First 1
                    if ($newest) { try { $pre = $newest.PrivateData.PSData.Prerelease } catch { } }
                    $wantPre = [bool]$m.Prerelease   # the entry's opt-in (gh#6)
                    if ($m.Version) { Install-PSResource -Name $m.Name -Version $m.Version -Scope CurrentUser -Prerelease:$wantPre -TrustRepository -Reinstall:$m.Update -ErrorAction Stop }
                    elseif ($m.Update -and $newest -and ($wantPre -or $pre)) { Install-PSResource -Name $m.Name -Prerelease -Reinstall -Scope CurrentUser -TrustRepository -ErrorAction Stop }
                    elseif ($m.Update -and (Get-Command Update-PSResource -ErrorAction SilentlyContinue) -and $newest) { Update-PSResource -Name $m.Name -Prerelease:$wantPre -ErrorAction Stop }
                    else { Install-PSResource -Name $m.Name -Scope CurrentUser -Prerelease:$wantPre -TrustRepository -ErrorAction Stop }
                } else {
                    Install-Module -Name $m.Name -Scope CurrentUser -Force -AllowClobber -AllowPrerelease:([bool]$m.Prerelease) -ErrorAction Stop
                }
                "ok $($m.Name)"
            } catch { "FAILED $($m.Name): $($_.Exception.Message)" }
        }
    }
    $ui.Sel.Clear()
    $ui.Status = "[$script:PSMM_ColAccent]$verb of $($names.Count) module(s) started in the background - grid stays usable[/]"
}

# Opt-in update check (key 'k') as a background task - network-bound, so it
# never blocks the grid and never runs automatically.
function script:Start-PSMMUpdateCheckTask {
    $ui = $script:PSMM_UI
    $installed = @($ui.Entries | Where-Object { $_.Installed -and -not $_.PSObject.Properties['Unmanaged'] })
    if (-not $installed.Count) { $ui.Status = "[$script:PSMM_ColWarn]no installed modules to check[/]"; return }
    $payload = @($installed | ForEach-Object {
        [pscustomobject]@{ Name = $_.Name; Installed = "$($_.InstalledVersion)"; Prerelease = [bool]$_.AllowPrerelease }
    })
    $null = Start-PSMMTask -Label "update check ($($payload.Count) modules)" -Kind 'updatecheck' -ArgumentList (, $payload) -ScriptBlock {
        param($mods)
        $psrg = [bool](Get-Command Find-PSResource -ErrorAction SilentlyContinue)
        foreach ($m in $mods) {
            try {
                $pre = [bool]$m.Prerelease
                $hits = if ($psrg) { @(Find-PSResource -Name $m.Name -Prerelease:$pre -ErrorAction Stop) }
                        else { @(Find-Module -Name $m.Name -AllowPrerelease:$pre -ErrorAction Stop) }
                # newest by base version; the label is reported alongside so the
                # UI can order 0.1.0-beta2 / 0.1.0-beta8 / 0.1.0 properly
                $best = $hits | Sort-Object { [version]("$($_.Version)" -replace '-.*$', '') } -Descending | Select-Object -First 1
                if ($best) {
                    $label = "$($best.Prerelease)".TrimStart('-')
                    if (-not $label -and "$($best.Version)" -match '^[^-]+-(.+)$') { $label = $Matches[1] }
                    [pscustomobject]@{
                        Name       = $m.Name
                        Latest     = ("$($best.Version)" -replace '-.*$', '')
                        Prerelease = $label
                    }
                }
            } catch { }
        }
    }
    $ui.Status = "[$script:PSMM_ColAccent]update check started in the background (g t=details)[/]"
}

# The live grid loop. Returns a command hashtable for full-screen actions.
function script:Invoke-PSMMGrid {
    $ui = $script:PSMM_UI
    $result = @{ Cmd = 'quit' }
    Clear-PSMMScreen
    Invoke-PSMMLive -Body {
        param($ctx)
        while ($true) {
            Receive-PSMMUITask
            $ctx.UpdateTarget((Build-PSMMGrid)); $ctx.Refresh()
            # first run ever: float the welcome tips over the freshly painted
            # grid; the next loop pass repaints whatever the erase left behind
            if ($script:PSMM_UI.WelcomeDue) {
                $script:PSMM_UI.WelcomeDue = $false
                Show-PSMMWelcomeOverlay -BaseRenderable (Build-PSMMGrid)
                if ($script:PSMM_UI.HardQuit) { $result.Cmd = 'quit'; return }
                continue
            }
            $k = Read-PSMMKeyResize
            if ($null -eq $k) { continue }   # resize or background activity: re-render
            $ui = $script:PSMM_UI
            $ui.Status = ''
            $n = $ui.View.Count
            $ctrl = ($k.Modifiers -band [ConsoleModifiers]::Control) -ne 0
            if (Test-PSMMHardQuitKey $k) { $ui.HardQuit = $true; $result.Cmd = 'quit'; return }

            # ---- filter (search) mode: typed characters edit the filter ----
            if ($ui.FilterMode) {
                $r = Invoke-PSMMFilterKey -State $ui -KeyInfo $k
                if ($r) { continue }
                $null = Invoke-PSMMListNav -State $ui -KeyInfo $k -Count $n
                continue
            }

            if (Invoke-PSMMListNav -State $ui -KeyInfo $k -Count $n) { continue }

            # the g goto layer (v2 §4): overlay + second key; single letters
            # below are verbs only
            if ($k.KeyChar -eq 'g') {
                $dest = Read-PSMMGotoKey -BaseRenderable (Build-PSMMGrid)
                if ($script:PSMM_UI.HardQuit) { $result.Cmd = 'quit'; return }
                if ($dest -and $dest -ne 'home') {
                    # route through Goto (not Cmd) so the manager knows the
                    # overlay was the source - g ? lands on help - keys
                    $script:PSMM_UI.Goto = $dest
                    $result.Cmd = 'goto'
                    return
                }
                continue
            }

            switch ($k.Key) {
                ([ConsoleKey]::Spacebar) {
                    if ($n) {
                        $idx = $ui.View[$ui.Cursor]
                        if ($ui.Sel.Contains($idx)) { [void]$ui.Sel.Remove($idx) }
                        else { [void]$ui.Sel.Add($idx) }
                    }
                    continue
                }
                ([ConsoleKey]::Enter) {
                    if ($n) { $result.Cmd = 'submenu'; $result.Index = $ui.View[$ui.Cursor]; return }
                    continue
                }
                ([ConsoleKey]::RightArrow) {
                    # "move into" the module (#24) - same as enter
                    if ($n) { $result.Cmd = 'submenu'; $result.Index = $ui.View[$ui.Cursor]; return }
                    continue
                }
                ([ConsoleKey]::LeftArrow) {
                    # left backs out one level everywhere (gh#7). Like esc, it
                    # clears an active filter first; home is the top level, so
                    # beyond that it says so rather than doing nothing silently
                    if ($ui.Filter) { $ui.Filter = ''; $ui.Cursor = 0; continue }
                    $ui.Status = "[$script:PSMM_ColMute]home is the top level $([char]0x00B7) right opens the row, ^q quits[/]"
                    continue
                }
                ([ConsoleKey]::Escape) {
                    if ($ui.Filter) { $ui.Filter = ''; $ui.Cursor = 0; continue }
                    $result.Cmd = 'quit'; return
                }
                ([ConsoleKey]::L) { if ($ctrl) { Invoke-PSMMBulk -Action Load -Context $ctx }; continue }
                ([ConsoleKey]::U) {
                    if ($ctrl) { Invoke-PSMMBulk -Action Unload -Context $ctx } else { Start-PSMMInstallTask -Update }
                    continue
                }
                ([ConsoleKey]::I) { if (-not $ctrl) { Start-PSMMInstallTask }; continue }
                ([ConsoleKey]::K) { if (-not $ctrl) { Start-PSMMUpdateCheckTask }; continue }
                ([ConsoleKey]::A) { if (-not $ctrl) { $result.Cmd = 'add'; return }; continue }
                ([ConsoleKey]::R) { if (-not $ctrl) { $result.Cmd = 'reload'; return }; continue }
                ([ConsoleKey]::M) { if (-not $ctrl) { Invoke-PSMMUnmanagedToggle }; continue }
                ([ConsoleKey]::Oem2) {
                    # '/' and '?' share this key on most layouts - split by char
                    if ($k.KeyChar -eq '?') { $result.Cmd = 'help'; return }
                    $ui.FilterMode = $true
                    continue
                }
                default {
                    if ($k.KeyChar -eq '/') { $ui.FilterMode = $true }
                    elseif ($k.KeyChar -eq '?') { $result.Cmd = 'help'; return }
                }
            }
        }
    }
    $result
}