Public/Dashboard.ps1
|
# --------------------------------------------------------------------------- # Formatted display -- ANSI-enhanced dashboard with interactive TUI mode # --------------------------------------------------------------------------- # ANSI escape sequences (PS5.1 compatible via [char]27) $script:ESC = [char]27 $script:RESET = "$($script:ESC)[0m" $script:BOLD = "$($script:ESC)[1m" $script:REVERSE = "$($script:ESC)[7m" $script:DIM = "$($script:ESC)[90m" # Status colors per UI-SPEC $script:COLOR_LIVE = "$($script:ESC)[92m" # Bright Green $script:COLOR_SUSP = "$($script:ESC)[93m" # Bright Yellow $script:COLOR_DEAD = "$($script:ESC)[91m" # Bright Red $script:COLOR_HIDDEN = "$($script:ESC)[90m" # Dark Gray $script:COLOR_HEADER = "$($script:ESC)[1;97m" # Bold White $script:COLOR_PATH = "$($script:ESC)[36m" # Cyan $script:COLOR_ID = "$($script:ESC)[37m" # White $script:COLOR_NOTES = "$($script:ESC)[35m" # Magenta $script:COLOR_WARN = "$($script:ESC)[93m" # Bright Yellow $script:COLOR_ERROR = "$($script:ESC)[91m" # Bright Red # Cursor control sequences for buffered rendering $script:HIDE_CURSOR = "$($script:ESC)[?25l" $script:SHOW_CURSOR = "$($script:ESC)[?25h" function Format-TTStatusBadge { <# .SYNOPSIS Returns an 8-char ANSI-colored status badge for a session. #> param( [PSCustomObject]$Session, [bool]$IsSuspended = $false ) if ($IsSuspended) { return "$($script:COLOR_SUSP)[SUSP ]$($script:RESET)" } if ($Session.Hidden -eq $true) { return "$($script:COLOR_HIDDEN)[HIDDEN]$($script:RESET)" } if ($Session.IsAlive) { return "$($script:COLOR_LIVE)[LIVE ]$($script:RESET)" } return "$($script:COLOR_DEAD)[DEAD ]$($script:RESET)" } function Format-TTSessionRow { <# .SYNOPSIS Formats a single session as a colored row string. #> param( [PSCustomObject]$Session, [bool]$IsSuspended = $false ) $badge = Format-TTStatusBadge -Session $Session -IsSuspended $IsSuspended $id = "$($script:COLOR_ID)$($Session.Id)$($script:RESET)" $pid_str = if ($IsSuspended) { ' - ' } else { '{0,5}' -f $Session.Pid } $shell = "$($script:DIM)$($Session.Shell)$($script:RESET)" $cwd = "$($script:COLOR_PATH)$($Session.WorkingDirectory)$($script:RESET)" $updated = "$($script:DIM)$(if ($Session.LastUpdated) { ([datetime]$Session.LastUpdated).ToString('HH:mm:ss') } elseif ($Session.SuspendedTime) { ([datetime]$Session.SuspendedTime).ToString('HH:mm:ss') } else { '' })$($script:RESET)" " $id $badge $pid_str $shell $cwd $updated" } function ConvertTo-DeduplicatedSessionList { <# .SYNOPSIS Deduplicate sessions by PID, keeping the most recently updated entry per PID. #> param($Sessions) $byPid = @{} foreach ($s in $Sessions) { $key = $s.Pid if ($byPid.ContainsKey($key)) { $existing = $byPid[$key] $existingTime = if ($existing.LastUpdated) { [datetime]$existing.LastUpdated } else { [datetime]::MinValue } $newTime = if ($s.LastUpdated) { [datetime]$s.LastUpdated } else { [datetime]::MinValue } if ($newTime -gt $existingTime) { $byPid[$key] = $s } } else { $byPid[$key] = $s } } @($byPid.Values) } function Show-TTActionMenu { <# .SYNOPSIS Displays an action menu for the selected session in interactive TUI mode. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSAvoidUsingWriteHost', '', Justification = 'Interactive TUI display function; Write-Host is intentional.' )] param([PSCustomObject]$Session) $isSuspended = ($Session.Type -eq 'suspended') # Detect if this session shares a WT host with the current terminal $isOwnWTHost = $false if (-not $isSuspended -and $Session.IsAlive) { try { $sessionCim = Get-CimInstance Win32_Process -Filter "ProcessId=$($Session.Pid)" -ErrorAction SilentlyContinue $selfCim = Get-CimInstance Win32_Process -Filter "ProcessId=$PID" -ErrorAction SilentlyContinue if ($sessionCim -and $selfCim) { # Both are children of the same WT? Walk up to find WT parent $sessionParent = $sessionCim.ParentProcessId $selfParent = $selfCim.ParentProcessId if ($sessionParent -eq $selfParent) { $isOwnWTHost = $true } } } catch { } } Write-Host "" Write-Host " $($script:COLOR_HEADER)Actions for session $($Session.Id):$($script:RESET)" Write-Host " $($script:DIM)$('-' * 40)$($script:RESET)" Write-Host " [O] Open terminal here" if (-not $isSuspended) { Write-Host " [S] Suspend session" } if ($isSuspended) { Write-Host " [R] Resume session" } if (-not $isSuspended -and $Session.IsAlive) { if ($Session.Hidden) { Write-Host " [W] Show window" } elseif ($isOwnWTHost) { Write-Host " $($script:DIM)[H] Hide window (same WT host -- would hide this terminal too)$($script:RESET)" } else { Write-Host " [H] Hide window" } } Write-Host " [N] View / edit notes" Write-Host " [Q] Back" Write-Host "" $actionKey = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') $char = [char]$actionKey.Character try { switch ($char.ToString().ToUpper()) { 'O' { Open-TerminalAt -Directory $Session.WorkingDirectory } 'S' { if (-not $isSuspended) { Suspend-TTSession -Id $Session.Id } } 'R' { if ($isSuspended) { Resume-TTSession -Id $Session.Id } } 'W' { if ($Session.Hidden) { Show-TTWindow -Id $Session.Id } } 'H' { if (-not $Session.Hidden -and $Session.IsAlive) { if ($isOwnWTHost) { Write-Host " $($script:COLOR_WARN)Skipped: hiding this session would hide your current terminal.$($script:RESET)" Start-Sleep -Milliseconds 1500 } else { Hide-TTWindow -Id $Session.Id } } } 'N' { Write-Host " Current notes: $($script:COLOR_NOTES)$($Session.Notes)$($script:RESET)" [Console]::CursorVisible = $true $newNote = Read-Host " Enter new note (blank to keep)" [Console]::CursorVisible = $false if ($newNote) { Set-TTNote -Id $Session.Id -Note $newNote } } 'Q' { return } default { return } } } catch { Write-Host " $($script:COLOR_ERROR)Error: action failed - $($_.Exception.Message). Press any key to continue.$($script:RESET)" $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') } } function Get-TTSessionVisibility { <# .SYNOPSIS Classify sessions as 'tab', 'standalone', or 'background'. .DESCRIPTION Uses session metadata (WTHost, AutoDiscovered) and a fast batch Get-Process check for MainWindowHandle. No CIM/WMI queries. #> param($Sessions) $visibility = @{} # Build WT PID set for fallback parent check $wtPids = @{} Get-Process -Name WindowsTerminal -ErrorAction SilentlyContinue | ForEach-Object { $wtPids[$_.Id] = $true } # Collect PIDs that need further checks $checkPids = [System.Collections.Generic.List[int]]::new() foreach ($s in $Sessions) { $pid = $s.Pid if ($s.WTHost) { # Session has explicit WT host recorded — it's a real tab $visibility[$pid] = 'tab' } elseif (-not $s.AutoDiscovered) { # Registered by the prompt hook — definitely a real interactive terminal $visibility[$pid] = 'standalone' } else { # Auto-discovered without WTHost — check parent or window $checkPids.Add($pid) } } # For unknowns: fast parent PID check via Get-Process (PS7 has .Parent) # then fall back to MainWindowHandle check if ($checkPids.Count -gt 0) { $procIndex = @{} $shellNames = @('pwsh', 'powershell', 'cmd', 'bash') Get-Process -Name $shellNames -ErrorAction SilentlyContinue | ForEach-Object { $procIndex[$_.Id] = $_ } foreach ($pid in $checkPids) { $proc = if ($procIndex.ContainsKey($pid)) { $procIndex[$pid] } else { $null } if (-not $proc) { $visibility[$pid] = 'background' continue } # PS7: check if parent is a WT process $parentId = try { $proc.Parent.Id } catch { $null } if ($parentId -and $wtPids.ContainsKey($parentId)) { $visibility[$pid] = 'tab' } elseif ($proc.MainWindowHandle -ne [IntPtr]::Zero) { $visibility[$pid] = 'standalone' } else { $visibility[$pid] = 'background' } } } return $visibility } function Build-TTUnifiedSessionList { <# .SYNOPSIS Build a unified, deduplicated session list for the interactive TUI. .PARAMETER ShowAll If true, include background processes. If false, only tabs and standalone windows. #> param($Sessions, $Suspended, [bool]$ShowAll = $false) # Deduplicate active sessions by PID $deduped = ConvertTo-DeduplicatedSessionList $Sessions # Classify visibility $visibility = Get-TTSessionVisibility $deduped $bgCount = 0 $list = [System.Collections.Generic.List[object]]::new() foreach ($s in $deduped) { $vis = if ($visibility.ContainsKey($s.Pid)) { $visibility[$s.Pid] } else { 'background' } if (-not $ShowAll -and $vis -eq 'background') { $bgCount++ continue } $item = [PSCustomObject]@{ Id = $s.Id Pid = $s.Pid ProcessName = $s.ProcessName Shell = $s.Shell WorkingDirectory = $s.WorkingDirectory LastUpdated = $s.LastUpdated IsAlive = $s.IsAlive Hidden = $s.Hidden Notes = $s.Notes Tags = $s.Tags Type = if ($vis -eq 'background') { 'background' } else { 'active' } SuspendedTime = $null Visibility = $vis } $list.Add($item) } foreach ($s in $Suspended) { $item = [PSCustomObject]@{ Id = $s.Id Pid = 0 ProcessName = $s.ProcessName Shell = $s.Shell WorkingDirectory = $s.WorkingDirectory LastUpdated = $null IsAlive = $false Hidden = $false Notes = $s.Notes Tags = $s.Tags Type = 'suspended' SuspendedTime = $s.SuspendedTime Visibility = 'suspended' } $list.Add($item) } # Attach background count as a note property on the list for the footer $list | Add-Member -NotePropertyName BackgroundCount -NotePropertyValue $bgCount -Force -ErrorAction SilentlyContinue return $list } function Enter-TTInteractiveMode { <# .SYNOPSIS Enters interactive TUI mode with arrow key navigation and action menu. .DESCRIPTION Provides cursor-based session selection, auto-refresh at MonitorIntervalSeconds, and an action menu for session management operations. Uses buffered rendering to eliminate flicker, viewport scrolling for long lists, and PID deduplication. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSAvoidUsingWriteHost', '', Justification = 'Interactive TUI display function; Write-Host is intentional.' )] param($Sessions, $Suspended) # Input redirection guard if ([Console]::IsInputRedirected) { Write-Warning 'Interactive TUI requires a real console. Run Show-TTDashboard from an interactive terminal.' return } # Build unified, deduplicated session list (tabs + standalone windows only by default) $showAll = $false $allSessions = Build-TTUnifiedSessionList -Sessions $Sessions -Suspended $Suspended -ShowAll $showAll if ($allSessions.Count -eq 0) { Write-Host " $($script:COLOR_WARN)No sessions to display in interactive mode.$($script:RESET)" return } # Hide cursor and clear screen [Console]::Write($script:HIDE_CURSOR) try { [Console]::Clear() } catch { Clear-Host } $headerText = @( "" " $($script:COLOR_HEADER)TerminalTracker -- Session Dashboard$($script:RESET)" " $($script:DIM)$('-' * 70)$($script:RESET)" " $($script:DIM)ID STATUS PID SHELL CWD UPDATED$($script:RESET)" " $($script:DIM)$('-' * 70)$($script:RESET)" ) $headerLines = $headerText.Count # Write header once foreach ($line in $headerText) { Write-Host $line } $selectedIndex = 0 $viewportTop = 0 $action = $null $lastRefresh = [DateTime]::Now $config = Get-TTConfig $refreshMs = $config.MonitorIntervalSeconds * 1000 # Footer uses: scroll-up indicator (1) + scroll-down indicator (1) + separator (1) + help (1) $footerReserve = 4 while ($action -ne 'Quit') { # Refresh data if interval elapsed $elapsed = ([DateTime]::Now - $lastRefresh).TotalMilliseconds if ($elapsed -ge $refreshMs) { $sessions = ConvertTo-FlatArray (Get-TTSession -IncludeHidden) $suspended = ConvertTo-FlatArray (Get-TTSuspended) $suspended = @($suspended | Where-Object { $_.Id -and $_.WorkingDirectory }) $allSessions = Build-TTUnifiedSessionList -Sessions $sessions -Suspended $suspended -ShowAll $showAll $lastRefresh = [DateTime]::Now } # Dynamic viewport sizing $winHeight = 24 try { $winHeight = [Console]::WindowHeight } catch { } $winWidth = 80 try { $winWidth = [Console]::WindowWidth } catch { } $visibleRows = [Math]::Max(3, $winHeight - $headerLines - $footerReserve) # Clamp selectedIndex if ($allSessions.Count -eq 0) { $selectedIndex = 0 } elseif ($selectedIndex -ge $allSessions.Count) { $selectedIndex = $allSessions.Count - 1 } # Scroll viewport to keep selection visible if ($selectedIndex -lt $viewportTop) { $viewportTop = $selectedIndex } elseif ($selectedIndex -ge ($viewportTop + $visibleRows)) { $viewportTop = $selectedIndex - $visibleRows + 1 } $maxTop = [Math]::Max(0, $allSessions.Count - $visibleRows) $viewportTop = [Math]::Max(0, [Math]::Min($viewportTop, $maxTop)) $viewportEnd = [Math]::Min($viewportTop + $visibleRows, $allSessions.Count) # --- Build entire frame into a single buffer --- $buf = [System.Text.StringBuilder]::new(4096) # Position cursor at start of data area $null = $buf.Append("$($script:ESC)[$($headerLines + 1);1H") # Scroll-up indicator if ($viewportTop -gt 0) { $upText = " $($script:DIM)... $($viewportTop) more above ...$($script:RESET)" $null = $buf.AppendLine($upText.PadRight($winWidth)) } else { $null = $buf.AppendLine(''.PadRight($winWidth)) } # Visible session rows for ($i = $viewportTop; $i -lt $viewportEnd; $i++) { $s = $allSessions[$i] $isSusp = ($s.Type -eq 'suspended') $row = Format-TTSessionRow -Session $s -IsSuspended $isSusp if ($i -eq $selectedIndex) { $null = $buf.AppendLine("$($script:REVERSE)$row$($script:RESET)".PadRight($winWidth)) } else { $null = $buf.AppendLine("$row".PadRight($winWidth)) } } # Fill remaining rows if list shorter than viewport $rendered = $viewportEnd - $viewportTop for ($j = $rendered; $j -lt $visibleRows; $j++) { $null = $buf.AppendLine(''.PadRight($winWidth)) } # Scroll-down indicator $remaining = $allSessions.Count - $viewportEnd if ($remaining -gt 0) { $downText = " $($script:DIM)... $remaining more below ...$($script:RESET)" $null = $buf.AppendLine($downText.PadRight($winWidth)) } else { $null = $buf.AppendLine(''.PadRight($winWidth)) } # Footer $refreshInterval = $config.MonitorIntervalSeconds $null = $buf.AppendLine(" $($script:DIM)$('-' * 70)$($script:RESET)".PadRight($winWidth)) $pos = $selectedIndex + 1 $total = $allSessions.Count $bgCount = 0 if ($allSessions.PSObject.Properties['BackgroundCount']) { $bgCount = $allSessions.BackgroundCount } $bgHint = if ($bgCount -gt 0 -and -not $showAll) { " * A=show $bgCount bg" } elseif ($showAll) { ' * A=hide bg' } else { '' } $footerText = " $($script:DIM)Up/Dn/PgUp/PgDn * Enter=act * Q=quit * ${pos}/${total}${bgHint} * ${refreshInterval}s$($script:RESET)" $null = $buf.Append($footerText.PadRight($winWidth)) # Write entire frame in one call -- eliminates flicker [Console]::Write($buf.ToString()) # Wait for key with polling for auto-refresh while (-not [Console]::KeyAvailable) { Start-Sleep -Milliseconds 100 $elapsed = ([DateTime]::Now - $lastRefresh).TotalMilliseconds if ($elapsed -ge $refreshMs) { break } } if (-not [Console]::KeyAvailable) { continue } $key = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') switch ($key.VirtualKeyCode) { 38 { $selectedIndex = [Math]::Max(0, $selectedIndex - 1) } # Up 40 { $selectedIndex = [Math]::Min($allSessions.Count - 1, $selectedIndex + 1) } # Down 33 { $selectedIndex = [Math]::Max(0, $selectedIndex - $visibleRows) } # PageUp 34 { $selectedIndex = [Math]::Min($allSessions.Count - 1, $selectedIndex + $visibleRows) } # PageDown 36 { $selectedIndex = 0 } # Home 35 { $selectedIndex = $allSessions.Count - 1 } # End 13 { # Enter if ($allSessions.Count -gt 0) { Show-TTActionMenu -Session $allSessions[$selectedIndex] $lastRefresh = [DateTime]::MinValue # Redraw header after action menu try { [Console]::Clear() } catch { Clear-Host } foreach ($line in $headerText) { Write-Host $line } } } 65 { # A - toggle background $showAll = -not $showAll $selectedIndex = 0 $viewportTop = 0 $lastRefresh = [DateTime]::MinValue # Force rebuild } { $_ -eq 81 -or $_ -eq 27 } { $action = 'Quit' } # Q or Escape } } [Console]::Write($script:SHOW_CURSOR) try { [Console]::Clear() } catch { Clear-Host } } function Show-TTDashboard { <# .SYNOPSIS Display a formatted dashboard of all terminal sessions. .DESCRIPTION Renders session status with ANSI color-coded badges. Use -Interactive for cursor-navigable session selection with action menu. .PARAMETER Scan Force a discovery scan for new terminals before displaying. .PARAMETER Interactive Enter interactive TUI mode with arrow key navigation and action menu. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSAvoidUsingWriteHost', '', Justification = 'Show-TTDashboard is a display function; Write-Host colored output is intentional.' )] [CmdletBinding()] param( [switch]$Scan, [switch]$Interactive ) Initialize-TTDataStore # Run a discovery cycle to pick up new terminals and clean dead ones if ($Scan) { Invoke-TTMonitorCycle } $sessions = ConvertTo-FlatArray (Get-TTSession -IncludeHidden) $suspended = ConvertTo-FlatArray (Get-TTSuspended) $suspended = @($suspended | Where-Object { $_.Id -and $_.WorkingDirectory }) $config = Get-TTConfig # Deduplicate sessions by PID for display $sessions = ConvertTo-DeduplicatedSessionList $sessions # Filter out background processes (not WT tabs and no visible window) $visibility = Get-TTSessionVisibility $sessions $bgSessions = @($sessions | Where-Object { $visibility[$_.Pid] -eq 'background' }) $sessions = @($sessions | Where-Object { $visibility[$_.Pid] -ne 'background' }) # If interactive mode requested, delegate to interactive handler if ($Interactive) { Enter-TTInteractiveMode -Sessions $sessions -Suspended $suspended return } # --- Non-interactive ANSI rendering --- Write-Host "" Write-Host " $($script:COLOR_HEADER)TerminalTracker -- Session Dashboard$($script:RESET)" Write-Host " $($script:DIM)$('-' * 60)$($script:RESET)" if ($sessions.Count -eq 0 -and $suspended.Count -eq 0) { Write-Host " $($script:COLOR_WARN)No sessions tracked$($script:RESET)" Write-Host " $($script:DIM)No terminals found. Run 'tt scan' or open a new terminal.$($script:RESET)" Write-Host "" return } Write-Host " $($script:DIM)ID STATUS PID SHELL CWD UPDATED$($script:RESET)" Write-Host " $($script:DIM)$('-' * 60)$($script:RESET)" if ($sessions.Count -gt 0) { foreach ($s in $sessions) { $row = Format-TTSessionRow -Session $s -IsSuspended $false Write-Host $row # Show notes and tags on sub-lines if present if (-not [string]::IsNullOrWhiteSpace($s.Notes)) { Write-Host " $($script:COLOR_NOTES)Notes: $($s.Notes)$($script:RESET)" } $tagList = @($s.Tags | Where-Object { $_ }) if ($tagList.Count -gt 0) { Write-Host " $($script:COLOR_NOTES)Tags: $($tagList -join ', ')$($script:RESET)" } } } if ($suspended.Count -gt 0) { Write-Host "" Write-Host " $($script:COLOR_SUSP)Suspended Sessions ($($suspended.Count)):$($script:RESET)" foreach ($s in $suspended) { $row = Format-TTSessionRow -Session $s -IsSuspended $true Write-Host $row if (-not [string]::IsNullOrWhiteSpace($s.Notes)) { Write-Host " $($script:COLOR_NOTES)Notes: $($s.Notes)$($script:RESET)" } } } Write-Host "" Write-Host " $($script:DIM)$('-' * 60)$($script:RESET)" # Monitor status $monitorJob = Get-Job -Name 'TerminalTracker-Monitor' -ErrorAction SilentlyContinue if ($monitorJob -and $monitorJob.State -eq 'Running') { Write-Host " Monitor: $($script:COLOR_LIVE)Running$($script:RESET)" } else { Write-Host " Monitor: $($script:COLOR_DEAD)Stopped$($script:RESET)" } # Config status $autoStartText = if ($config.AutoStart) { 'Enabled' } else { 'Disabled' } $autoReloadText = if ($config.AutoReload) { 'Enabled' } else { 'Disabled' } Write-Host " $($script:DIM)Auto-start: $autoStartText$($script:RESET)" Write-Host " $($script:DIM)Auto-reload: $autoReloadText$($script:RESET)" if ($config.SyncPath) { Write-Host " $($script:DIM)Sync path: $($config.SyncPath)$($script:RESET)" } if ($bgSessions.Count -gt 0) { Write-Host " $($script:DIM)($($bgSessions.Count) background processes hidden)$($script:RESET)" } Write-Host "" $footerHint = "Use 'Show-TTDashboard -Interactive' or double-click tray icon for interactive mode." Write-Host " $($script:DIM)$footerHint$($script:RESET)" Write-Host "" } |