Private/UI/Show-ModuleSearchUI.ps1

function Show-ModuleSearchUI {
    param(
        [string]$InitialQuery = '',
        [string]$Repository = 'PSGallery'
    )

    $state = @{
        Query = $InitialQuery
        Results = @()
        SelectedIndex = 0
        IsSearching = $false
        ExactSearch = $false
        AuthorSearch = $false
        SortBy = 'Relevance'
        LastSearchQuery = $null
        Repository = $Repository
        StatusMessage = 'Type to search, then press Enter...'
        DetailMessage = 'No module selected.'
        NeedsRedraw = $true
        SpinnerFrame = 0
        SearchPS = $null
        SearchAsync = $null
        SearchStartTime = $null
        TagSearch = $false
        PageSize = 99
        CurrentPage = 0
        HasMoreResults = $true
        AllResults = @()
        IsLoadingMore = $false
    }

    $palette = Get-PSMBColorPalette
    $esc = [char]27
    $rst = "${esc}[0m"

    # Palette shortcuts
    $pAmber = $palette.Amber
    $pRose = $palette.Rose
    $pPeach = $palette.Peach
    $pText = $palette.Text
    $pSubtext = $palette.Subtext
    $pDim = $palette.Dim
    $pSurface = $palette.Surface
    $pBold = $palette.Bold
    $pGreen = $palette.Green
    $pYellow = $palette.Yellow
    $pBgSel = $palette.BgSelect

    function Remove-AnsiEscape {
        param([string]$Text)
        return [regex]::Replace($Text, '\x1b\[[0-9;]*m', '')
    }

    function Build-Chip {
        param(
            [string]$Label,
            [string]$Value,
            [string]$Color
        )
        return "${pDim} [${rst}${pAmber}$Label${rst}${pDim}]${rst} ${Color}$Value ${rst}"
    }

    function Write-At {
        param([int]$Row, [int]$Col, [string]$Text)
        try {
            [Console]::SetCursorPosition($Col, $Row)
            [Console]::Write($Text)
        } catch { }
    }

    function Pad-Right {
        param([string]$Text, [int]$Width)
        $plain = Remove-AnsiEscape -Text $Text
        $ansiExtra = $Text.Length - $plain.Length
        return $Text.PadRight($Width + $ansiExtra)
    }

    function Truncate-Plain {
        param([string]$Text, [int]$MaxLen)
        if ($Text.Length -gt $MaxLen) {
            return $Text.Substring(0, $MaxLen - 1) + ([char]0x2026)
        }
        return $Text
    }

    function Draw-HLine {
        param([int]$Row, [int]$Col, [int]$Width, [string]$Color = '')
        Write-At -Row $Row -Col $Col -Text ($Color + ([string]([char]0x2500) * $Width) + $rst)
    }

    function Draw-SearchBar {
        param([hashtable]$Size, [hashtable]$St)
        $w = $Size.Width
        $queryDisplay = $St.Query
        $maxQ = $w - 62
        if ($maxQ -lt 10) { $maxQ = 10 }
        if ($queryDisplay.Length -gt $maxQ) {
            $queryDisplay = $queryDisplay.Substring($queryDisplay.Length - $maxQ)
        }

        $exactChip = if ($St.ExactSearch) {
            Build-Chip -Label 'EXACT' -Value 'ON' -Color $pGreen
        } else {
            Build-Chip -Label 'EXACT' -Value 'OFF' -Color $pYellow
        }
        $authorChip = if ($St.AuthorSearch) {
            Build-Chip -Label 'AUTHOR' -Value 'ON' -Color $pGreen
        } else {
            Build-Chip -Label 'AUTHOR' -Value 'OFF' -Color $pYellow
        }
        $tagChip = if ($St.TagSearch) {
            Build-Chip -Label 'TAG' -Value 'ON' -Color $pGreen
        } else {
            Build-Chip -Label 'TAG' -Value 'OFF' -Color $pYellow
        }
        $sortChip = Build-Chip -Label 'SORT' -Value $St.SortBy.ToUpper() -Color $pPeach

        $title = "${pBold}${pAmber}PSModuleBrowser${rst} ${pDim}//${rst} ${pRose}BROWSE${rst}"
        $searchLabel = if ($St.AuthorSearch) { 'Author' } elseif ($St.TagSearch) { 'Tag' } else { 'Search' }
        $search = "${pBold}${pAmber}$searchLabel${rst}${pDim}:${rst} ${pText}$queryDisplay${rst}${pAmber}$([char]0x2588)${rst}"
        $hint = "${pDim}Enter=Search${rst}"

        $line = " $title $search $exactChip $authorChip $tagChip $sortChip $hint"
        $padding = [Math]::Max(0, $w - (Remove-AnsiEscape $line).Length - 2)

        Write-At -Row 0 -Col 0 -Text (' ' * $w)
        Write-At -Row 0 -Col 0 -Text ($line + (' ' * $padding))
        Draw-HLine -Row 1 -Col 0 -Width $w -Color $pDim
    }

    function Draw-ResultsList {
        param([hashtable]$Size, [hashtable]$St, [int]$PaneLeft, [int]$PaneWidth, [int]$PaneTop, [int]$PaneHeight)
        $results = $St.Results
        $selIdx = $St.SelectedIndex
        $count = $results.Count

        $headerText = if ($St.IsSearching) {
            $spinnerChars = @([char]0x280B, [char]0x2819, [char]0x2839, [char]0x2838, [char]0x283C, [char]0x2834, [char]0x2826, [char]0x2827, [char]0x2807, [char]0x280F)
            $spinner = $spinnerChars[$St.SpinnerFrame % $spinnerChars.Count]
            $elapsed = if ($St.SearchStartTime) {
                ' ({0:F0}s)' -f ([datetime]::UtcNow - $St.SearchStartTime).TotalSeconds
            } else { '' }
            "${pPeach}$spinner Searching...$elapsed${rst}"
        } elseif ($count -eq 0 -and $null -ne $St.LastSearchQuery) {
            "${pDim}No results${rst}"
        } else {
            $countLabel = if ($St.HasMoreResults -and $count -gt 0) { "${count}+" } else { "$count" }
            "${pBold}Results${rst} ${pDim}($countLabel)${rst}"
        }

        Write-At -Row $PaneTop -Col $PaneLeft -Text (' ' * $PaneWidth)
        Write-At -Row $PaneTop -Col $PaneLeft -Text (" ${pAmber}$([char]0x25A0)${rst} $headerText" + (' ' * 4))

        $listTop = $PaneTop + 1
        $listHeight = $PaneHeight - 1
        $startIdx = [Math]::Max(0, $selIdx - [Math]::Floor($listHeight / 2))
        $endIdx = [Math]::Min($count - 1, $startIdx + $listHeight - 1)
        if ($count -gt 0 -and ($endIdx - $startIdx + 1) -lt $listHeight) {
            $startIdx = [Math]::Max(0, $endIdx - $listHeight + 1)
        }

        for ($rowOff = 0; $rowOff -lt $listHeight; $rowOff++) {
            $row = $listTop + $rowOff
            $idx = $startIdx + $rowOff
            $blank = ' ' * $PaneWidth

            if ($idx -le $endIdx -and $idx -lt $count) {
                $m = $results[$idx]
                $isSelected = ($idx -eq $selIdx)

                $statusIcon = switch ($m.InstallStatus) {
                    'Installed' { "${pGreen}$([char]0x2714)${rst}" }
                    'UpdateAvailable' { "${pYellow}^${rst}" }
                    default { ' ' }
                }

                $nameStr = Truncate-Plain -Text $m.Name    -MaxLen ([Math]::Floor($PaneWidth * 0.45))
                $verStr = Truncate-Plain -Text "v$($m.Version)" -MaxLen 10
                $authorStr = Truncate-Plain -Text ($m.Author ?? '') -MaxLen ([Math]::Floor($PaneWidth * 0.25))

                $suffix = " ${pDim}$verStr${rst} ${pSubtext}$authorStr${rst}"
                if ($isSelected) {
                    $lineText = " ${pAmber}$([char]0x276F)${rst} $statusIcon ${pBold}${pAmber}$nameStr${rst}$suffix"
                } else {
                    $lineText = " $statusIcon ${pBold}$nameStr${rst}$suffix"
                }
                $plainLen = (Remove-AnsiEscape $lineText).Length
                $padding = [Math]::Max(0, $PaneWidth - $plainLen)
                if ($isSelected) {
                    Write-At -Row $row -Col $PaneLeft -Text ($pBgSel + $lineText + (' ' * $padding) + $rst)
                } else {
                    Write-At -Row $row -Col $PaneLeft -Text ($lineText + (' ' * $padding))
                }
            } else {
                Write-At -Row $row -Col $PaneLeft -Text $blank
            }
        }
    }

    function Draw-DetailsPane {
        param([hashtable]$Size, [hashtable]$St, [int]$PaneLeft, [int]$PaneWidth, [int]$PaneTop, [int]$PaneHeight)
        $m = if ($St.Results.Count -gt 0 -and $St.SelectedIndex -lt $St.Results.Count) {
            $St.Results[$St.SelectedIndex]
        } else { $null }

        Write-At -Row $PaneTop -Col $PaneLeft -Text (' ' * $PaneWidth)
        Write-At -Row $PaneTop -Col $PaneLeft -Text (" ${pRose}$([char]0x25C6)${rst} ${pBold}${pAmber}Module Details${rst}" + (' ' * 4))

        $row = $PaneTop + 1

        if ($null -eq $m) {
            Write-At -Row $row -Col $PaneLeft -Text (" ${pDim}No module selected.${rst}" + (' ' * $PaneWidth))
            $row++
            for (; $row -lt $PaneTop + $PaneHeight; $row++) {
                Write-At -Row $row -Col $PaneLeft -Text (' ' * $PaneWidth)
            }
            return
        }

        $statusColor = switch ($m.InstallStatus) {
            'Installed' { $pGreen }
            'UpdateAvailable' { $pYellow }
            default { $pDim }
        }

        $lines = @()
        $lines += " ${pBold}${pAmber}$(Truncate-Plain $m.Name ($PaneWidth - 2))${rst}"
        $lines += " ${pDim}v$($m.Version)${rst} ${statusColor}$($m.InstallStatus)${rst}"
        $lines += ""

        if ($m.Author) {
            $lines += " ${pBold}Author:${rst} $(Truncate-Plain $m.Author ($PaneWidth - 12))"
        }

        if ($m.InstalledVersion) {
            $lines += " ${pBold}Local:${rst} v$($m.InstalledVersion)"
        }

        if ($m.PublishedDate -and $m.PublishedDate -ne [datetime]::MinValue) {
            $lines += " ${pBold}Published:${rst} $($m.PublishedDate.ToString('yyyy-MM-dd'))"
        }

        if ($m.DownloadCount -gt 0) {
            $lines += " ${pBold}Downloads:${rst} $($m.DownloadCount.ToString('N0'))"
        }

        if ($m.Repository) {
            $lines += " ${pBold}Repo:${rst} $($m.Repository)"
        }

        if ($m.Description) {
            $lines += ""
            $desc = $m.Description
            $maxDescWidth = $PaneWidth - 3
            $maxDescriptionLines = 8
            $words = $desc -split '\s+'
            $currentLine = ' '
            foreach ($word in $words) {
                if (($currentLine + $word).Length -gt $maxDescWidth) {
                    $lines += $currentLine
                    $currentLine = " $word"
                } else {
                    if ($currentLine -eq ' ') {
                        $currentLine = " $word"
                    } else {
                        $currentLine += " $word"
                    }
                }
                if ($lines.Count -ge $maxDescriptionLines) { break }
            }
            if ($currentLine -ne ' ' -and $lines.Count -lt ($maxDescriptionLines + 2)) {
                $lines += $currentLine
            }
        }

        if ($m.Tags -and $m.Tags.Count -gt 0) {
            $lines += ""
            $tagLine = " ${pBold}Tags:${rst} " + (($m.Tags | Select-Object -First 5) -join ', ')
            $lines += Truncate-Plain $tagLine ($PaneWidth - 2)
        }

        if ($m.Dependencies -and $m.Dependencies.Count -gt 0) {
            $lines += ""
            $lines += " ${pBold}Deps:${rst} $($m.Dependencies.Count) module(s)"
        }

        if ($m.ExportedCommands -and $m.ExportedCommands.Count -gt 0) {
            $lines += ""
            $lines += " ${pBold}Commands:${rst} $($m.ExportedCommands.Count)"
        }

        foreach ($line in $lines) {
            if ($row -ge $PaneTop + $PaneHeight) { break }
            $plain = Remove-AnsiEscape -Text $line
            $padding = [Math]::Max(0, $PaneWidth - $plain.Length)
            Write-At -Row $row -Col $PaneLeft -Text ($line + (' ' * $padding))
            $row++
        }

        for (; $row -lt $PaneTop + $PaneHeight; $row++) {
            Write-At -Row $row -Col $PaneLeft -Text (' ' * $PaneWidth)
        }
    }

    function Draw-Divider {
        param([int]$Row, [int]$Col, [int]$Height, [string]$Color = '')
        for ($r = $Row; $r -lt $Row + $Height; $r++) {
            Write-At -Row $r -Col $Col -Text ($Color + ([char]0x2502) + $rst)
        }
    }

    function Draw-Footer {
        param([hashtable]$Size, [hashtable]$St)
        $w = $Size.Width
        $row = $Size.Height - 2

        Draw-HLine -Row $row -Col 0 -Width $w -Color $pDim
        $row++

        $moreHint = if ($St.HasMoreResults -and $St.Results.Count -gt 0 -and -not $St.IsSearching) {
            " ${pAmber}0${rst} More"
        } else { '' }
        $footer = " ${pAmber}$([char]0x2191)$([char]0x2193)${rst} Nav ${pAmber}Enter${rst} Search ${pAmber}1${rst} Details ${pAmber}2${rst} Install ${pAmber}3${rst} Versions ${pAmber}4${rst} Deps ${pAmber}5${rst} Commands ${pAmber}6${rst} Exact ${pAmber}7${rst} Sort ${pAmber}8${rst} Author ${pAmber}9${rst} Tag${moreHint} ${pRose}Esc${rst} Quit"
        $padding = [Math]::Max(0, $w - (Remove-AnsiEscape $footer).Length)
        Write-At -Row $row -Col 0 -Text ($footer + (' ' * $padding))
    }

    function Draw-StatusBar {
        param([hashtable]$Size, [hashtable]$St, [int]$Row)
        $w = $Size.Width
        $msg = "${pPeach}$([char]0x25CF)${rst} ${pAmber}$($St.StatusMessage)${rst}"
        $padding = [Math]::Max(0, $w - (Remove-AnsiEscape $msg).Length - 2)
        Write-At -Row $Row -Col 0 -Text (' ' + $msg + (' ' * $padding) + ' ')
    }

    function Draw-Layout {
        param([hashtable]$Size, [hashtable]$St)
        $w = $Size.Width
        $h = $Size.Height

        $headerRows = 2
        $footerRows = 2
        $statusRows = 1
        $availableRows = $h - $headerRows - $footerRows - $statusRows
        $contentRows = [Math]::Max(1, $availableRows)

        $leftWidth = [Math]::Floor($w * 0.4)
        $rightWidth = $w - $leftWidth - 1

        $contentTop = $headerRows

        Draw-SearchBar -Size $Size -St $St
        Draw-ResultsList -Size $Size -St $St -PaneLeft 0 -PaneWidth $leftWidth -PaneTop $contentTop -PaneHeight $contentRows
        Draw-Divider -Row $contentTop -Col $leftWidth -Height $contentRows -Color $pDim
        Draw-DetailsPane -Size $Size -St $St -PaneLeft ($leftWidth + 1) -PaneWidth $rightWidth -PaneTop $contentTop -PaneHeight $contentRows
        Draw-StatusBar -Size $Size -St $St -Row ($contentTop + $contentRows)
        Draw-Footer -Size $Size -St $St
    }

    function Perform-Search {
        param([hashtable]$St, [switch]$LoadMore)

        # Cancel any in-flight search
        if ($St.SearchPS) {
            try { $St.SearchPS.Stop() } catch { }
            try { $St.SearchPS.Dispose() } catch { }
            $St.SearchPS = $null
            $St.SearchAsync = $null
        }

        if ([string]::IsNullOrWhiteSpace($St.Query)) {
            $St.Results = @()
            $St.AllResults = @()
            $St.LastSearchQuery = $St.Query
            $St.StatusMessage = 'Type to search, then press Enter...'
            $St.SelectedIndex = 0
            $St.IsSearching = $false
            $St.CurrentPage = 0
            $St.HasMoreResults = $true
            return
        }

        if ($LoadMore) {
            $St.CurrentPage++
            $St.IsLoadingMore = $true
        } else {
            $St.CurrentPage = 0
            $St.AllResults = @()
            $St.HasMoreResults = $true
            $St.IsLoadingMore = $false
        }

        $St.IsSearching = $true
        $St.SpinnerFrame = 0
        $St.SearchStartTime = [datetime]::UtcNow
        $skipCount = $St.CurrentPage * $St.PageSize
        $St.StatusMessage = if ($LoadMore) {
            "Loading more results for '$($St.Query)'..."
        } else {
            "Searching for '$($St.Query)'..."
        }

        Write-PSMBLog -Message "Search started: Query='$($St.Query)' Exact=$($St.ExactSearch) Author=$($St.AuthorSearch) Tag=$($St.TagSearch) Sort=$($St.SortBy) Page=$($St.CurrentPage) Skip=$skipCount" -Level 'INFO'

        $manifestPath = [System.IO.Path]::Combine($script:PSMBModuleRoot, 'PSModuleBrowser.psd1')
        $ps = [PowerShell]::Create()
        [void]$ps.AddScript({
            param($ModulePath, $Query, $ExactSearch, $AuthorSearch, $TagSearch, $SortBy, $Repository, $Skip)
            Import-Module $ModulePath -Force
            Search-PSModule -Query $Query -ExactSearch:$ExactSearch -AuthorSearch:$AuthorSearch -TagSearch:$TagSearch -SortBy $SortBy -Repository $Repository -Skip $Skip
        })
        [void]$ps.AddParameters(@{
            ModulePath   = $manifestPath
            Query        = $St.Query
            ExactSearch  = [bool]$St.ExactSearch
            AuthorSearch = [bool]$St.AuthorSearch
            TagSearch    = [bool]$St.TagSearch
            SortBy       = $St.SortBy
            Repository   = $St.Repository
            Skip         = $skipCount
        })

        $St.SearchPS = $ps
        $St.SearchAsync = $ps.BeginInvoke()
    }

    function Complete-Search {
        param([hashtable]$St)
        if (-not $St.SearchPS -or -not $St.SearchAsync) { return $false }

        if (-not $St.SearchAsync.IsCompleted) {
            $timeoutSec = 120
            if ($St.SearchStartTime -and ([datetime]::UtcNow - $St.SearchStartTime).TotalSeconds -gt $timeoutSec) {
                try { $St.SearchPS.Stop() } catch { }
                try { $St.SearchPS.Dispose() } catch { }
                $St.SearchPS = $null
                $St.SearchAsync = $null
                $St.IsSearching = $false
                $St.IsLoadingMore = $false
                $St.StatusMessage = "Search timed out after ${timeoutSec}s. Try a more specific query."
                Write-PSMBLog -Message "Search timed out after ${timeoutSec}s for query='$($St.Query)'" -Level 'WARN'
                $St.SearchStartTime = $null
            }
            return $false
        }

        $elapsed = if ($St.SearchStartTime) {
            ([datetime]::UtcNow - $St.SearchStartTime).TotalSeconds.ToString('F1')
        } else { '?' }

        try {
            $found = $St.SearchPS.EndInvoke($St.SearchAsync)
            if ($St.SearchPS.Streams.Error.Count -gt 0) {
                $errMsg = $St.SearchPS.Streams.Error[0].ToString()
                Write-PSMBLog -Message "Search completed with errors after ${elapsed}s: $errMsg" -Level 'WARN'
            }

            $newResults = @($found)
            $pageSize = $St.PageSize
            $St.HasMoreResults = $newResults.Count -ge $pageSize

            if ($St.IsLoadingMore) {
                # Exclude modules already loaded from previous pages
                $existingNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
                foreach ($r in $St.AllResults) { [void]$existingNames.Add($r.Name) }
                $uniqueNew = @($newResults | Where-Object { -not $existingNames.Contains($_.Name) })
                $St.AllResults = @($St.AllResults) + $uniqueNew
                $St.Results = $St.AllResults
            } else {
                $St.AllResults = $newResults
                $St.Results = $newResults
                $St.SelectedIndex = 0
            }

            $St.LastSearchQuery = $St.Query
            $totalCount = $St.Results.Count
            $countDisplay = if ($St.HasMoreResults) { "${totalCount}+" } else { "$totalCount" }
            $St.StatusMessage = if ($totalCount -eq 0) {
                "No results found for '$($St.LastSearchQuery)'."
            } else {
                "Found $countDisplay module(s) for '$($St.LastSearchQuery)' (${elapsed}s)"
            }
            Write-PSMBLog -Message "Search completed: $($newResults.Count) new results, $totalCount total in ${elapsed}s (page $($St.CurrentPage))" -Level 'INFO'
        } catch {
            if (-not $St.IsLoadingMore) {
                $St.Results = @()
                $St.AllResults = @()
            }
            $St.StatusMessage = "Search error: $_"
            Write-PSMBLog -Message "Search failed after ${elapsed}s: $_" -Level 'ERROR'
        } finally {
            $St.IsSearching = $false
            $St.IsLoadingMore = $false
            $St.SearchPS.Dispose()
            $St.SearchPS = $null
            $St.SearchAsync = $null
            $St.SearchStartTime = $null
        }
        return $true
    }

    function Handle-NavigationKey {
        param([System.ConsoleKeyInfo]$Key, [hashtable]$St)
        switch ($Key.Key) {
            'UpArrow' { if ($St.SelectedIndex -gt 0) { $St.SelectedIndex-- } }
            'DownArrow' { if ($St.SelectedIndex -lt $St.Results.Count - 1) { $St.SelectedIndex++ } }
            'Backspace' {
                if ($St.Query.Length -gt 0) {
                    $St.Query = $St.Query.Substring(0, $St.Query.Length - 1)
                }
            }
        }
    }

    function Handle-EnterKey {
        param([hashtable]$St)
        Perform-Search -St $St
    }

    function Handle-ViewDetailsKey {
        param([hashtable]$St)
        if ($St.Results.Count -eq 0 -or $St.SelectedIndex -ge $St.Results.Count) { return }
        $selected = $St.Results[$St.SelectedIndex]
        [Console]::CursorVisible = $true
        [Console]::Clear()
        Show-ModuleDetails -ModuleInfo $selected
        Write-Host ''
        Write-Host "${pDim}Press any key to return...${rst}"
        [Console]::ReadKey($true) | Out-Null
        [Console]::CursorVisible = $false
        [Console]::Clear()
    }

    function Handle-InstallKey {
        param([hashtable]$St)
        if ($St.Results.Count -eq 0) { return }
        $selected = $St.Results[$St.SelectedIndex]
        [Console]::CursorVisible = $true
        [Console]::Clear()
        $confirmed = Show-InstallConfirmation -ModuleInfo $selected -Version $selected.Version -Scope 'CurrentUser'
        if ($confirmed) {
            [Console]::Clear()
            Write-Host "${pAmber}Installing $($selected.Name) v$($selected.Version)...${rst}"
            try {
                Invoke-ProviderInstall -Name $selected.Name -Version $selected.Version -Scope 'CurrentUser' -Repository $St.Repository
                Write-Host "${pGreen}$([char]0x2714) Installed successfully.${rst}"
                if ($St.SelectedIndex -lt $St.Results.Count) {
                    $St.Results[$St.SelectedIndex] = Invoke-ProviderGetInfo -Name $selected.Name -Repository $St.Repository
                }
            } catch {
                Write-Host "${pRose}$([char]0x2718) Installation failed: $_${rst}"
            }
            Write-Host ''
            Write-Host "${pDim}Press any key to return...${rst}"
            [Console]::ReadKey($true) | Out-Null
        }
        [Console]::CursorVisible = $false
        [Console]::Clear()
    }

    function Handle-VersionKey {
        param([hashtable]$St)
        if ($St.Results.Count -eq 0) { return }
        $selected = $St.Results[$St.SelectedIndex]
        [Console]::CursorVisible = $true
        $versions = Invoke-ProviderGetVersions -Name $selected.Name -Repository $St.Repository
        $chosenVersion = Show-VersionList -Versions $versions -ModuleName $selected.Name
        if ($chosenVersion) {
            $St.StatusMessage = "Selected version: $chosenVersion for $($selected.Name)"
        }
        [Console]::CursorVisible = $false
        [Console]::Clear()
    }

    function Handle-DependencyKey {
        param([hashtable]$St)
        if ($St.Results.Count -eq 0) { return }
        $selected = $St.Results[$St.SelectedIndex]
        [Console]::CursorVisible = $true
        [Console]::Clear()
        Show-DependencyList -ModuleInfo $selected
        Write-Host "${pDim}Press any key to return...${rst}"
        [Console]::ReadKey($true) | Out-Null
        [Console]::CursorVisible = $false
        [Console]::Clear()
    }

    function Handle-CommandPreviewKey {
        param([hashtable]$St)
        if ($St.Results.Count -eq 0) { return }
        $selected = $St.Results[$St.SelectedIndex]
        $previewInfo = $selected

        $localModule = Get-InstalledModuleInfo -Name $selected.Name
        $hasMetadataCommands = $selected.ExportedCommands -and $selected.ExportedCommands.Count -gt 0

        if (-not $localModule -and -not $hasMetadataCommands) {
            try {
                $detailed = Invoke-ProviderGetInfo -Name $selected.Name -Repository $St.Repository
                if ($detailed) {
                    $previewInfo = $detailed
                    if ($St.SelectedIndex -lt $St.Results.Count) {
                        $St.Results[$St.SelectedIndex] = $detailed
                    }
                }
            } catch { }
        }

        [Console]::CursorVisible = $true
        [Console]::Clear()
        Show-CommandPreview -ModuleInfo $previewInfo
        Write-Host "${pDim}Press any key to return...${rst}"
        [Console]::ReadKey($true) | Out-Null
        [Console]::CursorVisible = $false
        [Console]::Clear()
    }

    function Handle-NumberShortcutKey {
        param([char]$Char, [hashtable]$St)
        switch ($Char.ToString()) {
            '0' {
                if (-not $St.IsSearching -and $St.HasMoreResults -and $St.Results.Count -gt 0) {
                    Perform-Search -St $St -LoadMore
                }
            }
            '1' { Handle-ViewDetailsKey -St $St }
            '2' { Handle-InstallKey -St $St }
            '3' { Handle-VersionKey -St $St }
            '4' { Handle-DependencyKey -St $St }
            '5' { Handle-CommandPreviewKey -St $St }
            '6' {
                $St.ExactSearch = -not $St.ExactSearch
                $St.StatusMessage = "Exact search: $($St.ExactSearch)"
            }
            '7' {
                $sortOrders = @('Relevance', 'Downloads', 'LastUpdated')
                $currentIdx = $sortOrders.IndexOf($St.SortBy)
                $St.SortBy = $sortOrders[($currentIdx + 1) % $sortOrders.Count]
                $St.StatusMessage = "Sort order: $($St.SortBy)"
            }
            '8' {
                $St.AuthorSearch = -not $St.AuthorSearch
                if ($St.AuthorSearch) { $St.TagSearch = $false }
                $St.StatusMessage = "Author search: $($St.AuthorSearch)"
            }
            '9' {
                $St.TagSearch = -not $St.TagSearch
                if ($St.TagSearch) { $St.AuthorSearch = $false }
                $St.StatusMessage = "Tag search: $($St.TagSearch)"
            }
        }
        return $false
    }

    # Main TUI Loop
    $originalCursorVisible = [Console]::CursorVisible

    try {
        [Console]::CursorVisible = $false
        [Console]::Clear()
        Write-PSMBLog -Message 'TUI started' -Level 'INFO'

        if ($state.Query) {
            Perform-Search -St $state
        }

        while ($true) {
            # Check if an async search has completed
            [void](Complete-Search -St $state)

            # Advance spinner animation during search
            if ($state.IsSearching) {
                $state.SpinnerFrame++
            }

            try {
                $size = @{ Width = [Console]::WindowWidth; Height = [Console]::WindowHeight }
                Draw-Layout -Size $size -St $state
            } catch { }

            try { [Console]::SetCursorPosition(0, [Console]::WindowHeight - 1) } catch { }

            # Non-blocking key check keeps the UI responsive during search
            if ([Console]::KeyAvailable) {
                $key = [Console]::ReadKey($true)

                switch ($key.Key) {
                    'UpArrow' { Handle-NavigationKey -Key $key -St $state }
                    'DownArrow' { Handle-NavigationKey -Key $key -St $state }
                    'Backspace' { Handle-NavigationKey -Key $key -St $state }
                    'Enter' { Handle-EnterKey -St $state }
                    'Escape' { return }
                    default {
                        $keyName = $key.Key.ToString()
                        if ($keyName -match '^(?:D|NumPad)(\d)$') {
                            [void](Handle-NumberShortcutKey -Char $Matches[1] -St $state)
                        } elseif (-not [char]::IsControl($key.KeyChar) -and $key.KeyChar -match '[a-zA-Z\-_\. ]') {
                            $state.Query += $key.KeyChar
                            $state.StatusMessage = "Press Enter to search for '$($state.Query)'"
                        }
                    }
                }
            } else {
                $sleepMs = if ($state.IsSearching) { 80 } else { 100 }
                Start-Sleep -Milliseconds $sleepMs
            }
        }

    } finally {
        if ($state.SearchPS) {
            try { $state.SearchPS.Stop() } catch { }
            try { $state.SearchPS.Dispose() } catch { }
        }
        [Console]::CursorVisible = $originalCursorVisible
        [Console]::ResetColor()
        [Console]::Clear()
        Write-PSMBLog -Message 'TUI exited' -Level 'INFO'
    }
}