WinTop.psm1
|
#Requires -Version 5.1 # Compile Win32 native helpers once per session for ultra-fast (<20ms) process, memory, and window queries if (-not ('WinTop.NativeHelper' -as [type])) { Add-Type -TypeDefinition @' using System; using System.Collections.Generic; using System.Runtime.InteropServices; namespace WinTop { public struct ProcSample { public int Pid; public string Name; public long CpuTicks100ns; public long WorkingSetBytes; public uint Threads; } public struct MemInfo { public ulong TotalPhysBytes; public ulong AvailPhysBytes; public uint MemoryLoadPct; } public static class NativeHelper { [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left; public int Top; public int Right; public int Bottom; } [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] private class MEMORYSTATUSEX { public uint dwLength = (uint)Marshal.SizeOf(typeof(MEMORYSTATUSEX)); public uint dwMemoryLoad; public ulong ullTotalPhys; public ulong ullAvailPhys; public ulong ullTotalPageFile; public ulong ullAvailPageFile; public ulong ullTotalVirtual; public ulong ullAvailVirtual; public ulong ullAvailExtendedVirtual; } [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] private struct PROCESSENTRY32W { public uint dwSize; public uint cntUsage; public uint th32ProcessID; public IntPtr th32DefaultHeapID; public uint th32ModuleID; public uint cntThreads; public uint th32ParentProcessID; public int pcPriClassBase; public uint dwFlags; [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string szExeFile; } [StructLayout(LayoutKind.Sequential)] private struct PROCESS_MEMORY_COUNTERS { public uint cb; public uint PageFaultCount; public UIntPtr PeakWorkingSetSize; public UIntPtr WorkingSetSize; public UIntPtr QuotaPeakPagedPoolUsage; public UIntPtr QuotaPagedPoolUsage; public UIntPtr QuotaPeakNonPagedPoolUsage; public UIntPtr QuotaNonPagedPoolUsage; public UIntPtr PagefileUsage; public UIntPtr PeakPagefileUsage; } [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); [DllImport("user32.dll")] public static extern bool IsZoomed(IntPtr hWnd); [DllImport("user32.dll")] public static extern bool GetClientRect(IntPtr hWnd, out RECT lpRect); [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)] private static extern bool GlobalMemoryStatusEx([In, Out] MEMORYSTATUSEX lpBuffer); [DllImport("kernel32.dll", SetLastError = true)] private static extern IntPtr CreateToolhelp32Snapshot(uint dwFlags, uint th32ProcessID); [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] private static extern bool Process32FirstW(IntPtr hSnapshot, ref PROCESSENTRY32W lppe); [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] private static extern bool Process32NextW(IntPtr hSnapshot, ref PROCESSENTRY32W lppe); [DllImport("kernel32.dll")] private static extern IntPtr OpenProcess(uint access, bool inherit, uint pid); [DllImport("kernel32.dll")] private static extern bool CloseHandle(IntPtr h); [DllImport("kernel32.dll")] private static extern bool GetProcessTimes(IntPtr hProcess, out long creation, out long exit, out long kernel, out long user); [DllImport("psapi.dll")] private static extern bool GetProcessMemoryInfo(IntPtr hProcess, out PROCESS_MEMORY_COUNTERS counters, uint size); public static MemInfo GetMemoryStatus() { var m = new MEMORYSTATUSEX(); if (GlobalMemoryStatusEx(m)) { return new MemInfo { TotalPhysBytes = m.ullTotalPhys, AvailPhysBytes = m.ullAvailPhys, MemoryLoadPct = m.dwMemoryLoad }; } return new MemInfo(); } public static List<ProcSample> GetProcessSnapshot() { var list = new List<ProcSample>(256); IntPtr snap = CreateToolhelp32Snapshot(0x00000002, 0); if (snap == IntPtr.Zero || snap == new IntPtr(-1)) return list; try { var pe = new PROCESSENTRY32W(); pe.dwSize = (uint)Marshal.SizeOf(typeof(PROCESSENTRY32W)); if (!Process32FirstW(snap, ref pe)) return list; uint pmcSize = (uint)Marshal.SizeOf(typeof(PROCESS_MEMORY_COUNTERS)); do { if (pe.th32ProcessID == 0) continue; // Skip System Idle Process string exeName = pe.szExeFile ?? ""; if (exeName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) { exeName = exeName.Substring(0, exeName.Length - 4); } var s = new ProcSample { Pid = (int)pe.th32ProcessID, Name = exeName, Threads = pe.cntThreads }; IntPtr hp = OpenProcess(0x1000, false, pe.th32ProcessID); // PROCESS_QUERY_LIMITED_INFORMATION if (hp != IntPtr.Zero) { long c, e, k, u; if (GetProcessTimes(hp, out c, out e, out k, out u)) { s.CpuTicks100ns = k + u; } PROCESS_MEMORY_COUNTERS pmc; pmc.cb = pmcSize; if (GetProcessMemoryInfo(hp, out pmc, pmcSize)) { s.WorkingSetBytes = (long)pmc.WorkingSetSize.ToUInt64(); } CloseHandle(hp); } list.Add(s); } while (Process32NextW(snap, ref pe)); } finally { CloseHandle(snap); } return list; } } } '@ -ErrorAction SilentlyContinue } function Get-WinTopStrings { param([string]$Lang) if ($Lang -eq 'zh-TW') { return @{ Title = 'WinTop 系統即時監控儀表板' Time = '時間' Sample = '取樣' Window = '視窗' CPU = '處理器' BaseClock = '基本頻率' Cores = '核' Threads = '緒' UsageLbl = 'CPU 使用率:' ClockLbl = 'CPU 頻 率:' MemLbl = '記憶體使用:' TempLbl = 'CPU 溫 度:' LimitLbl = '散熱降頻限:' NormalTag = '[正常] ' ThrottleTag = '[降頻!]' ChartSection = '最近 {0} 分鐘趨勢圖 ({1}s 視窗, 寬度 {2} 格, 每格 = {3}s)' ChartUsage = 'CPU 使用率 (%)' ChartClock = 'CPU 運作頻率 (MHz)' ChartMem = '記憶體使用率 (%)' ChartTemp = 'CPU 溫度 ({0})' ChartLimit = '散熱被動降頻限制 (%)' TurboHint = '紫=Turbo > {0}MHz' ProcSection = '熱門處理程序 (共 {0} 個程序 | 排序: {1})' ProcHeader = ' PID 程序名稱 CPU% CPU 負載條 記憶體 (MB) MEM% 執行緒' Hotkeys = '快捷鍵: [Q/Esc]離開 [1/A]綜合檢視 [2/C]全圖表 [3/P]全程序表 [S]切換排序(CPU/MEM) [L]中/英切換' ViewSplit = '綜合' ViewCharts = '圖表' ViewProcs = '程序' } } else { return @{ Title = 'WinTop System Monitor' Time = 'Time' Sample = 'Tick' Window = 'Span' CPU = 'CPU' BaseClock = 'Base' Cores = 'C' Threads = 'T' UsageLbl = 'CPU Usage :' ClockLbl = 'CPU Clock :' MemLbl = 'Memory :' TempLbl = 'CPU Temp :' LimitLbl = 'Therm Lim :' NormalTag = '[OK] ' ThrottleTag = '[THROT]' ChartSection = 'Recent {0}-Min Trend Charts ({1}s window, {2} cols, {3}s/col)' ChartUsage = 'CPU Usage (%)' ChartClock = 'CPU Clock Speed (MHz)' ChartMem = 'Memory Usage (%)' ChartTemp = 'CPU Temperature ({0})' ChartLimit = 'Thermal Passive Limit (%)' TurboHint = 'Magenta=Turbo > {0}MHz' ProcSection = 'Top Processes ({0} total | Sort by: {1})' ProcHeader = ' PID Process Name CPU% CPU Bar Memory (MB) MEM% Threads' Hotkeys = 'Keys: [Q/Esc]Quit [1/A]Split View [2/C]Charts Only [3/P]Processes Only [S]Sort(CPU/MEM) [L]Lang' ViewSplit = 'Split' ViewCharts = 'Charts' ViewProcs = 'Procs' } } } function Get-DisplayWidth { param([string]$Text) $clean = $Text -replace '\x1b\[[0-9;]*[a-zA-Z]', '' $w = 0 foreach ($ch in $clean.ToCharArray()) { $code = [int]$ch if (($code -ge 0x2E80 -and $code -le 0x9FFF) -or ($code -ge 0xF900 -and $code -le 0xFAFF) -or ($code -ge 0xFF01 -and $code -le 0xFF60)) { $w += 2 } else { $w += 1 } } return $w } <# .SYNOPSIS Interactive full-screen TUI system & CPU monitor ("top" for Windows) with 5-minute trend charts, thermal telemetry, memory usage, and live top processes. .DESCRIPTION Start-WinTop (aliased to `top` and `wintop`) displays a flicker-free, responsive Terminal User Interface (TUI) on Windows. It monitors real-time CPU utility, clock frequency (including Turbo Boost), system memory, ACPI thermal zone temperature, passive thermal throttling limits, 5-minute historical multi-row charts, and top resource-consuming processes. .PARAMETER Interval Refresh interval in seconds (default: 1.0). Hotkeys remain responsive (<50ms) regardless of Interval. .PARAMETER Count Number of samples to collect before exiting automatically. Default is 0 (run continuously until 'q', Esc, or Ctrl+C). .PARAMETER Minutes Time window in minutes for historical trend charts (default: 5). .PARAMETER View Initial layout view: 'Split' (Charts + Processes, default), 'Charts' (Charts only), or 'Processes' (Processes only). .PARAMETER SortBy Initial sort column for Top Processes: 'CPU' (default) or 'MEM'. .PARAMETER Language UI display language: 'Auto' (detects zh-TW vs English automatically), 'en', or 'zh-TW'. .PARAMETER NoMaximize Do not maximize the terminal window automatically on startup. .EXAMPLE top Launches WinTop in interactive full-screen mode. .EXAMPLE Start-WinTop -Interval 2 -Minutes 10 -Language en -NoMaximize Launches WinTop with a 2-second refresh interval, 10-minute chart history, English UI, without maximizing the window. #> function Start-WinTop { [CmdletBinding()] [Alias('top', 'wintop')] param( [ValidateRange(0.25, 3600)] [double]$Interval = 1.0, [ValidateRange(0, [int]::MaxValue)] [int]$Count = 0, [ValidateRange(1, 120)] [int]$Minutes = 5, [ValidateSet('Split', 'Charts', 'Processes')] [string]$View = 'Split', [ValidateSet('CPU', 'MEM')] [string]$SortBy = 'CPU', [ValidateSet('Auto', 'en', 'zh-TW')] [string]$Language = 'Auto', [switch]$NoMaximize ) if (-not $IsWindows -and $PSVersionTable.PSVersion.Major -ge 6) { throw "WinTop requires Windows (uses Windows Performance Counters and Win32 Process APIs)." } [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 $activeLang = if ($Language -eq 'Auto') { if ([System.Globalization.CultureInfo]::CurrentUICulture.Name -like 'zh*') { 'zh-TW' } else { 'en' } } else { $Language } $cpu = Get-CimInstance Win32_Processor -ErrorAction SilentlyContinue | Select-Object -First 1 $baseMHz = if ($cpu -and $cpu.MaxClockSpeed -gt 0) { [double]$cpu.MaxClockSpeed } else { 2500.0 } $cpuName = if ($cpu -and $cpu.Name) { ($cpu.Name -replace '\s+', ' ').Trim() } else { 'Windows Processor' } $numCores = if ($cpu -and $cpu.NumberOfCores) { [int]$cpu.NumberOfCores } else { [Environment]::ProcessorCount } $numThreads = if ($cpu -and $cpu.NumberOfLogicalProcessors) { [int]$cpu.NumberOfLogicalProcessors } else { [Environment]::ProcessorCount } # Initialize low-overhead .NET PerformanceCounters $cntUtil = $null; $cntPerf = $null; $cntTemp = $null; $cntLimit = $null try { if ([System.Diagnostics.PerformanceCounterCategory]::Exists('Processor Information')) { $cntUtil = New-Object System.Diagnostics.PerformanceCounter('Processor Information', '% Processor Utility', '_Total', $true) $cntPerf = New-Object System.Diagnostics.PerformanceCounter('Processor Information', '% Processor Performance', '_Total', $true) } else { $cntUtil = New-Object System.Diagnostics.PerformanceCounter('Processor', '% Processor Time', '_Total', $true) } $null = $cntUtil.NextValue() if ($cntPerf) { $null = $cntPerf.NextValue() } } catch {} $hasThermal = $false try { if ([System.Diagnostics.PerformanceCounterCategory]::Exists('Thermal Zone Information')) { $tzCat = New-Object System.Diagnostics.PerformanceCounterCategory('Thermal Zone Information') $tzInst = $tzCat.GetInstanceNames() | Select-Object -First 1 if ($tzInst) { $cntTemp = New-Object System.Diagnostics.PerformanceCounter('Thermal Zone Information', 'Temperature', $tzInst, $true) $cntLimit = New-Object System.Diagnostics.PerformanceCounter('Thermal Zone Information', '% Passive Limit', $tzInst, $true) $null = $cntTemp.NextValue() $null = $cntLimit.NextValue() $hasThermal = $true } } } catch {} # Seed initial process snapshot so first frame has valid CPU% delta $prevProcMap = @{} $prevProcTime = [System.Diagnostics.Stopwatch]::StartNew() foreach ($ps in [WinTop.NativeHelper]::GetProcessSnapshot()) { $prevProcMap[$ps.Pid] = $ps.CpuTicks100ns } Start-Sleep -Milliseconds 250 $history = New-Object System.Collections.Generic.List[PSCustomObject] $windowSeconds = [math]::Max(60, $Minutes * 60) # Box & block drawing characters $sparkChars = @( [char]0x2581, [char]0x2582, [char]0x2583, [char]0x2584, [char]0x2585, [char]0x2586, [char]0x2587, [char]0x2588 ) $blockFull = [char]0x2588 $blockLight = [char]0x2591 $arrowUp = [char]0x25B2 $arrowDown = [char]0x25BC $arrowFlat = [char]0x25AC $dotGrid = [char]0x00B7 $boxVLine = [char]0x2502 $boxHLine = [char]0x2500 $boxBLCorner = [char]0x2514 $boxBRCorner = [char]0x2518 $boxBottomT = [char]0x2534 $boxTL = [char]0x2554 $boxTR = [char]0x2557 $boxBL = [char]0x255A $boxBR = [char]0x255D $boxV = [char]0x2551 $boxDblH = [char]0x2550 $degC = "$([char]0x00B0)C" # ANSI colors $esc = [char]27 $cReset = "$esc[0m" $cBold = "$esc[1m" $cCyan = "$esc[96m" $cGreen = "$esc[92m" $cYellow = "$esc[93m" $cRed = "$esc[91m" $cMagenta = "$esc[95m" $cWhite = "$esc[97m" $cGray = "$esc[90m" $cBgBar = "$esc[48;5;236m" $getMetricColor = { param([double]$Val, [double]$Warn, [double]$Crit, [bool]$Invert = $false) if ($Invert) { if ($Val -le $Crit) { return $cRed } if ($Val -le $Warn) { return $cYellow } return $cGreen } else { if ($Val -ge $Crit) { return $cRed } if ($Val -ge $Warn) { return $cYellow } return $cGreen } } $newGaugeBar = { param([double]$Val, [double]$Min, [double]$Max, [int]$Width = 20, [string]$Color = $cGreen) $Width = [math]::Max(6, $Width) $ratio = if ($Max -gt $Min) { ($Val - $Min) / ($Max - $Min) } else { 0.0 } $ratio = [math]::Max(0.0, [math]::Min(1.0, $ratio)) $filled = [int][math]::Round($ratio * $Width) $empty = $Width - $filled return "${Color}$([string]::new($blockFull, $filled))${cGray}$([string]::new($blockLight, $empty))${cReset}" } $formatDelta = { param([double]$Cur, [double]$Prev, [string]$Fmt = '0.0', [string]$Unit = '', [bool]$HasPrev = $false) if (-not $HasPrev) { $raw = " -- " return [PSCustomObject]@{ Text = "${cGray}${raw}${cReset}"; Width = $raw.Length } } $diff = $Cur - $Prev if ([math]::Abs($diff) -lt 0.05) { $raw = "${arrowFlat} 0.0${Unit}" return [PSCustomObject]@{ Text = "${cGray}${raw}${cReset}"; Width = $raw.Length } } elseif ($diff -gt 0) { $raw = "${arrowUp} +$($diff.ToString($Fmt))${Unit}" return [PSCustomObject]@{ Text = "${cYellow}${raw}${cReset}"; Width = $raw.Length } } else { $raw = "${arrowDown} $($diff.ToString($Fmt))${Unit}" return [PSCustomObject]@{ Text = "${cCyan}${raw}${cReset}"; Width = $raw.Length } } } $getStatsText = { param([double[]]$Vals, [string]$Fmt = '0.0', [string]$Unit = '') if (-not $Vals -or $Vals.Count -eq 0) { return "Min: -- | Avg: -- | Max: --" } $m = $Vals | Measure-Object -Minimum -Maximum -Average return ("Min:{0}${Unit} Avg:{1}${Unit} Max:{2}${Unit}" -f $m.Minimum.ToString($Fmt), $m.Average.ToString($Fmt), $m.Maximum.ToString($Fmt)) } $getTimeBuckets = { param($HistList, [datetime]$NowTs, [string]$Prop, [int]$W, [double]$BktSec) $sums = New-Object double[] $W $counts = New-Object int[] $W foreach ($item in $HistList) { $age = ($NowTs - $item.Timestamp).TotalSeconds $idx = $W - 1 - [int][math]::Floor($age / $BktSec) if ($idx -ge 0 -and $idx -lt $W) { $sums[$idx] += [double]$item.$Prop $counts[$idx]++ } } $res = New-Object object[] $W for ($k = 0; $k -lt $W; $k++) { $res[$k] = if ($counts[$k] -gt 0) { $sums[$k] / $counts[$k] } else { $null } } return $res } $newMultiRowChart = { param([object[]]$Buckets, [double]$MinVal, [double]$MaxVal, [int]$Rows, [string]$LblFmt, [string]$LblUnit, [scriptblock]$ColSel, [string]$ClrLine) $w = $Buckets.Count $totalLvls = $Rows * 8 $span = if ($MaxVal -gt $MinVal) { $MaxVal - $MinVal } else { 1.0 } $lvls = New-Object int[] $w $cols = New-Object string[] $w for ($c = 0; $c -lt $w; $c++) { $v = $Buckets[$c] if ($null -eq $v) { $lvls[$c] = -1; $cols[$c] = $cGray } else { $norm = [math]::Max(0.0, [math]::Min(1.0, ([double]$v - $MinVal) / $span)) $lvl = [int][math]::Round($norm * $totalLvls) if ([double]$v -gt $MinVal -and $lvl -eq 0) { $lvl = 1 } $lvls[$c] = $lvl $cols[$c] = & $ColSel ([double]$v) } } $lines = New-Object System.Collections.Generic.List[string] for ($r = $Rows - 1; $r -ge 0; $r--) { $rBase = $r * 8 $rVal = $MinVal + (($r + 1) / [double]$Rows) * $span $lbl = "$($rVal.ToString($LblFmt))${LblUnit}" $sb = New-Object System.Text.StringBuilder [void]$sb.AppendFormat(" {0}{1,8} {2}{3}", $cGray, $lbl, $boxVLine, $cReset) for ($c = 0; $c -lt $w; $c++) { $lvl = $lvls[$c] if ($lvl -lt 0 -or $lvl -le $rBase) { [void]$sb.Append($cGray).Append($dotGrid) } elseif ($lvl -ge ($rBase + 8)) { [void]$sb.Append($cols[$c]).Append($sparkChars[7]) } else { [void]$sb.Append($cols[$c]).Append($sparkChars[$lvl - $rBase - 1]) } } [void]$sb.Append($cReset).Append($ClrLine) $lines.Add($sb.ToString()) } return $lines } $newTimeAxis = { param([int]$Mins, [int]$W, [string]$ClrLine) $ticks = New-Object int[] 6 for ($k = 0; $k -le 5; $k++) { $ticks[$k] = 1 + [int][math]::Round($k * ($W - 1) / 5.0) } $totalAxisLen = $W + 1 $barChars = New-Object char[] $totalAxisLen for ($c = 0; $c -lt $totalAxisLen; $c++) { $barChars[$c] = $boxHLine } $barChars[0] = $boxBLCorner for ($k = 1; $k -lt 5; $k++) { $barChars[$ticks[$k]] = $boxBottomT } $barChars[$totalAxisLen - 1] = $boxBRCorner $axisBar = " ${cGray}" + (-join $barChars) + "${cReset}${ClrLine}" $lblCanvas = New-Object char[] ($totalAxisLen + 4) for ($c = 0; $c -lt $lblCanvas.Length; $c++) { $lblCanvas[$c] = ' ' } for ($k = 0; $k -le 5; $k++) { $txt = if ($k -eq 5) { 'Now' } else { '-{0:0.#}m' -f ($Mins * (5 - $k) / 5.0) } $pos = if ($k -eq 0) { 0 } elseif ($k -eq 5) { [math]::Max(0, $totalAxisLen - $txt.Length) } else { [math]::Max(0, $ticks[$k] - 1) } for ($j = 0; $j -lt $txt.Length; $j++) { if (($pos + $j) -lt $lblCanvas.Length) { $lblCanvas[$pos + $j] = $txt[$j] } } } $axisLbl = " ${cGray}" + ((-join $lblCanvas).TrimEnd()) + "${cReset}${ClrLine}" return @($axisBar, $axisLbl) } # Terminal cursor & safe window maximization setup $canControlCursor = $true $fHwnd = [IntPtr]::Zero try { $null = [Console]::CursorTop [Console]::CursorVisible = $false try { $fHwnd = [WinTop.NativeHelper]::GetForegroundWindow() if ($fHwnd -eq [IntPtr]::Zero) { $fHwnd = [WinTop.NativeHelper]::GetConsoleWindow() } if ([Console]::WindowWidth -gt 350 -or [Console]::BufferWidth -gt 350) { try { [Console]::SetWindowSize(80, 24); [Console]::SetBufferSize(80, 24) } catch {} if ($fHwnd -ne [IntPtr]::Zero) { [void][WinTop.NativeHelper]::ShowWindow($fHwnd, 9) Start-Sleep -Milliseconds 80 [void][WinTop.NativeHelper]::ShowWindow($fHwnd, 3) Start-Sleep -Milliseconds 100 } } elseif (-not $NoMaximize -and $fHwnd -ne [IntPtr]::Zero -and -not [WinTop.NativeHelper]::IsZoomed($fHwnd)) { [void][WinTop.NativeHelper]::ShowWindow($fHwnd, 3) Start-Sleep -Milliseconds 80 } } catch {} Clear-Host } catch { $canControlCursor = $false } $prevUsage = 0.0; $prevMHz = 0.0; $prevMemPct = 0.0; $prevTemp = 0.0 $hasPrev = $false $lastTermW = 0; $lastTermH = 0 $i = 0 $forceRedraw = $false $quitRequested = $false try { while (-not $quitRequested) { $ui = Get-WinTopStrings -Lang $activeLang # 1. Sample CPU, Memory, Thermal counters (<10ms) $usage = if ($cntUtil) { [math]::Max(0.0, [math]::Min(100.0, [double]$cntUtil.NextValue())) } else { 0.0 } $perf = if ($cntPerf) { [double]$cntPerf.NextValue() } else { $usage } $mhz = [math]::Round($baseMHz * $perf / 100.0) $tempC = if ($hasThermal -and $cntTemp) { [math]::Max(0.0, [double]$cntTemp.NextValue() - 273.15) } else { 0.0 } $limit = if ($hasThermal -and $cntLimit) { [double]$cntLimit.NextValue() } else { 100.0 } $memStat = [WinTop.NativeHelper]::GetMemoryStatus() $totalGB = if ($memStat.TotalPhysBytes -gt 0) { $memStat.TotalPhysBytes / 1GB } else { 16.0 } $usedBytes = if ($memStat.TotalPhysBytes -ge $memStat.AvailPhysBytes) { $memStat.TotalPhysBytes - $memStat.AvailPhysBytes } else { 0 } $usedGB = $usedBytes / 1GB $memPct = if ($memStat.TotalPhysBytes -gt 0) { ($usedBytes / [double]$memStat.TotalPhysBytes) * 100.0 } else { [double]$memStat.MemoryLoadPct } # 2. Sample Top Processes (<20ms) $dtSec = [math]::Max(0.05, $prevProcTime.Elapsed.TotalSeconds) $prevProcTime.Restart() $curSnap = [WinTop.NativeHelper]::GetProcessSnapshot() $newProcMap = @{} $procRows = New-Object System.Collections.Generic.List[PSCustomObject] ($curSnap.Count) $totalTickScale = $dtSec * 10000000.0 * $numThreads foreach ($ps in $curSnap) { $newProcMap[$ps.Pid] = $ps.CpuTicks100ns $pCpu = 0.0 if ($prevProcMap.ContainsKey($ps.Pid) -and $ps.CpuTicks100ns -ge $prevProcMap[$ps.Pid]) { $pCpu = (($ps.CpuTicks100ns - $prevProcMap[$ps.Pid]) / $totalTickScale) * 100.0 if ($pCpu -gt 100.0) { $pCpu = 100.0 } } $pMemMB = $ps.WorkingSetBytes / 1MB $pMemPct = if ($memStat.TotalPhysBytes -gt 0) { ($ps.WorkingSetBytes / [double]$memStat.TotalPhysBytes) * 100.0 } else { 0.0 } $procRows.Add([PSCustomObject]@{ Pid = $ps.Pid Name = $ps.Name CpuPct = $pCpu MemMB = $pMemMB MemPct = $pMemPct Threads = $ps.Threads }) } $prevProcMap = $newProcMap $now = Get-Date $nowStr = $now.ToString('HH:mm:ss') if (-not $forceRedraw) { $history.Add([PSCustomObject]@{ Timestamp = $now Usage = $usage MHz = $mhz MemPct = $memPct Temp = $tempC Limit = $limit }) $cutoff = $now.AddSeconds(-$windowSeconds) while ($history.Count -gt 0 -and $history[0].Timestamp -lt $cutoff) { $history.RemoveAt(0) } $i++ } $forceRedraw = $false # 3. Detect terminal viewport dimensions safely $termWidth = 120 $termHeight = 36 if ($canControlCursor) { try { $w = [Console]::WindowWidth $h = [Console]::WindowHeight if ($w -gt 350 -and $fHwnd -ne [IntPtr]::Zero) { $rect = New-Object WinTop.NativeHelper+RECT if ([WinTop.NativeHelper]::GetClientRect($fHwnd, [ref]$rect) -and ($rect.Right - $rect.Left) -gt 400) { $w = [int][math]::Floor(($rect.Right - $rect.Left - 24) / 9.0) $h = [int][math]::Floor(($rect.Bottom - $rect.Top - 48) / 19.0) } else { $w = 180; $h = 46 } } if ($w -gt 0) { $termWidth = [math]::Max(80, [math]::Min(320, $w - 1)) } if ($h -gt 0) { $termHeight = [math]::Max(24, [math]::Min(120, $h - 1)) } } catch {} if ($termWidth -ne $lastTermW -or $termHeight -ne $lastTermH) { Clear-Host $lastTermW = $termWidth $lastTermH = $termHeight } [Console]::SetCursorPosition(0, 0) } $innerWidth = $termWidth - 2 $chartWidth = [math]::Max(40, $termWidth - 11) $bucketSeconds = [double]$windowSeconds / $chartWidth $elapsedSec = [int][math]::Min($windowSeconds, [math]::Round(($now - $history[0].Timestamp).TotalSeconds) + $Interval) # Colors & Deltas $colUsage = & $getMetricColor $usage 60 85 $false $colMHz = if ($perf -gt 100) { $cMagenta } else { $cCyan } $colMem = & $getMetricColor $memPct 70 88 $false $colTemp = & $getMetricColor $tempC 70 85 $false $colLimit = & $getMetricColor $limit 99 80 $true $dltUsage = & $formatDelta $usage $prevUsage '0.0' '%' $hasPrev $dltMHz = & $formatDelta $mhz $prevMHz '0' 'M' $hasPrev $dltMem = & $formatDelta $memPct $prevMemPct '0.0' '%' $hasPrev $dltTemp = & $formatDelta $tempC $prevTemp '0.0' $degC $hasPrev $stsUsage = & $getStatsText ($history | ForEach-Object { $_.Usage }) '0.0' '%' $stsMHz = & $getStatsText ($history | ForEach-Object { $_.MHz }) '0' 'M' $stsMem = & $getStatsText ($history | ForEach-Object { $_.MemPct }) '0.0' '%' $stsTemp = & $getStatsText ($history | ForEach-Object { $_.Temp }) '0.0' $degC $stsLimit = & $getStatsText ($history | ForEach-Object { $_.Limit }) '0' '%' $maxMHzHistory = ($history | ForEach-Object { $_.MHz } | Measure-Object -Maximum).Maximum $maxMHzScale = [math]::Ceiling([math]::Max($baseMHz * 1.30, $maxMHzHistory) / 100.0) * 100.0 $minMHzScale = [math]::Floor(($baseMHz * 0.25) / 100.0) * 100.0 $gaugeWidth = [math]::Max(12, $termWidth - 82) $barUsage = & $newGaugeBar $usage 0 100 $gaugeWidth $colUsage $barMHz = & $newGaugeBar $mhz $minMHzScale $maxMHzScale $gaugeWidth $colMHz $barMem = & $newGaugeBar $memPct 0 100 $gaugeWidth $colMem $barTemp = & $newGaugeBar $tempC 30 100 $gaugeWidth $colTemp $prevUsage = $usage; $prevMHz = $mhz; $prevMemPct = $memPct; $prevTemp = $tempC $hasPrev = $true $hr = [string]::new($boxHLine, $termWidth - 1) $dblHr = [string]::new($boxDblH, $innerWidth) $clr = if ($canControlCursor) { "$esc[K" } else { "" } $viewLabel = switch ($View) { 'Split' { $ui.ViewSplit } 'Charts' { $ui.ViewCharts } 'Processes' { $ui.ViewProcs } } $out = New-Object System.Text.StringBuilder [void]$out.AppendLine("${cCyan}${cBold}${boxTL}${dblHr}${boxTR}${cReset}${clr}") $hdr1 = " ${cWhite}${cBold}$($ui.Title)${cReset} ${cGray}$($ui.Time):${cReset} ${cWhite}${nowStr}${cReset} ${cGray}| $($ui.Sample):${cReset} ${cWhite}#${i}${cReset} ${cGray}| $($ui.Window):${cReset} ${cWhite}${elapsedSec}s/${windowSeconds}s${cReset} ${cGray}| [${viewLabel}] ${termWidth}x${termHeight}${cReset}" $hdr2 = " ${cGray}$($ui.CPU):${cReset} ${cWhite}${cpuName} ($($ui.BaseClock) ${baseMHz} MHz, ${numCores}$($ui.Cores)/${numThreads}$($ui.Threads))${cReset}" foreach ($hLine in @($hdr1, $hdr2)) { $visW = Get-DisplayWidth -Text $hLine $pad = [string]::new(' ', [math]::Max(0, $innerWidth - $visW)) [void]$out.AppendLine("${cCyan}${cBold}${boxV}${cReset}${hLine}${pad}${cCyan}${cBold}${boxV}${cReset}${clr}") } [void]$out.AppendLine("${cCyan}${cBold}${boxBL}${dblHr}${boxBR}${cReset}${clr}") # Top Summary Gauges $turboTag = if ($perf -gt 100) { "${cMagenta}[Turbo]${cReset}" } else { "${cGray}[Base] ${cReset}" } $memTag = ("{0,4:N1}/{1:N0}G" -f $usedGB, $totalGB) $padU = [string]::new(' ', [math]::Max(1, 10 - $dltUsage.Width)) $padM = [string]::new(' ', [math]::Max(1, 10 - $dltMHz.Width)) $padR = [string]::new(' ', [math]::Max(1, 10 - $dltMem.Width)) $padT = [string]::new(' ', [math]::Max(1, 10 - $dltTemp.Width)) [void]$out.AppendLine((" ${cBold}$($ui.UsageLbl)${cReset} ${colUsage}${cBold}{0,6:N1}%${cReset} [{1}] {2}{3}${cGray}({4})${cReset}${clr}" -f $usage, $barUsage, $dltUsage.Text, $padU, $stsUsage)) [void]$out.AppendLine((" ${cBold}$($ui.ClockLbl)${cReset} ${colMHz}${cBold}{0,4} MHz${cReset} {1} [{2}] {3}{4}${cGray}({5})${cReset}${clr}" -f $mhz, $turboTag, $barMHz, $dltMHz.Text, $padM, $stsMHz)) [void]$out.AppendLine((" ${cBold}$($ui.MemLbl)${cReset} ${colMem}${cBold}{0,6:N1}%${cReset} {1,8} [{2}] {3}{4}${cGray}({5})${cReset}${clr}" -f $memPct, $memTag, $barMem, $dltMem.Text, $padR, $stsMem)) if ($hasThermal) { $throtBadge = if ($limit -lt 100) { "${cRed}$($ui.ThrottleTag)${cReset}" } else { "${cGreen}$($ui.NormalTag)${cReset}" } [void]$out.AppendLine((" ${cBold}$($ui.TempLbl)${cReset} ${colTemp}${cBold}{0,5:N1} {1}${cReset} {2} [{3}] {4}{5}${cGray}({6})${cReset}${clr}" -f $tempC, $degC, $throtBadge, $barTemp, $dltTemp.Text, $padT, $stsTemp)) } [void]$out.AppendLine("${cGray} ${hr}${cReset}${clr}") # Calculate remaining vertical lines (reserve 1 bottom line for Hotkey bar) $usedLines = 4 + $(if ($hasThermal) { 4 } else { 3 }) + 1 + 1 $bodyLines = [math]::Max(10, $termHeight - $usedLines) $showCharts = ($View -eq 'Split' -or $View -eq 'Charts') $showProcs = ($View -eq 'Split' -or $View -eq 'Processes') $chartBudget = 0 $procBudget = 0 if ($View -eq 'Charts') { $chartBudget = $bodyLines } elseif ($View -eq 'Processes') { $procBudget = $bodyLines } else { # Split view: ~58% charts, ~42% top processes (at least 6 lines for processes) $procBudget = [math]::Max(6, [int][math]::Floor($bodyLines * 0.38)) $chartBudget = [math]::Max(9, $bodyLines - $procBudget) } if ($showCharts) { $bktUsage = & $getTimeBuckets $history $now 'Usage' $chartWidth $bucketSeconds $bktMHz = & $getTimeBuckets $history $now 'MHz' $chartWidth $bucketSeconds $bktMem = & $getTimeBuckets $history $now 'MemPct' $chartWidth $bucketSeconds $bktTemp = & $getTimeBuckets $history $now 'Temp' $chartWidth $bucketSeconds $secPerColStr = "{0:0.#}" -f $bucketSeconds $chartTitle = $ui.ChartSection -f $Minutes, $windowSeconds, $chartWidth, $secPerColStr [void]$out.AppendLine(" ${cBold}${cCyan}[${chartTitle}]${cReset}${clr}") $numCharts = if ($hasThermal) { 4 } else { 3 } # Overhead inside chart block: 1 section header + $numCharts titles + 2 time axis lines $availPlotRows = [math]::Max($numCharts * 2, $chartBudget - (3 + $numCharts)) $baseRows = [math]::Max(2, [int][math]::Floor($availPlotRows / [double]$numCharts)) $extraRows = [math]::Max(0, $availPlotRows - ($baseRows * $numCharts)) $rUsage = $baseRows + $(if ($extraRows -ge 1) { 1 } else { 0 }) $rMHz = $baseRows + $(if ($extraRows -ge 2) { 1 } else { 0 }) $rMem = $baseRows + $(if ($extraRows -ge 3) { 1 } else { 0 }) $rTemp = $baseRows [void]$out.AppendLine(" ${cBold}1. $($ui.ChartUsage)${cReset} ${cGray}${stsUsage}${cReset}${clr}") foreach ($ln in (& $newMultiRowChart $bktUsage 0 100 $rUsage '0' '%' { param($v) & $getMetricColor $v 60 85 $false } $clr)) { [void]$out.AppendLine($ln) } $turboHintStr = $ui.TurboHint -f $baseMHz [void]$out.AppendLine(" ${cBold}2. $($ui.ChartClock)${cReset} ${cGray}${stsMHz} (${turboHintStr})${cReset}${clr}") foreach ($ln in (& $newMultiRowChart $bktMHz $minMHzScale $maxMHzScale $rMHz '0' 'M' { param($v) if ($v -gt $baseMHz) { $cMagenta } else { $cCyan } } $clr)) { [void]$out.AppendLine($ln) } [void]$out.AppendLine(" ${cBold}3. $($ui.ChartMem)${cReset} ${cGray}${stsMem}${cReset}${clr}") foreach ($ln in (& $newMultiRowChart $bktMem 0 100 $rMem '0' '%' { param($v) & $getMetricColor $v 70 88 $false } $clr)) { [void]$out.AppendLine($ln) } if ($hasThermal) { $tempChartLbl = $ui.ChartTemp -f $degC [void]$out.AppendLine(" ${cBold}4. ${tempChartLbl}${cReset} ${cGray}${stsTemp} | Limit: ${stsLimit}${cReset}${clr}") foreach ($ln in (& $newMultiRowChart $bktTemp 30 95 $rTemp '0' $degC { param($v) & $getMetricColor $v 70 85 $false } $clr)) { [void]$out.AppendLine($ln) } } foreach ($ln in (& $newTimeAxis $Minutes $chartWidth $clr)) { [void]$out.AppendLine($ln) } if ($showProcs) { [void]$out.AppendLine("${cGray} ${hr}${cReset}${clr}") } } if ($showProcs) { $maxProcItems = [math]::Max(3, $procBudget - $(if ($showCharts) { 3 } else { 2 })) $sortedProcs = if ($SortBy -eq 'MEM') { $procRows | Sort-Object MemMB, CpuPct -Descending | Select-Object -First $maxProcItems } else { $procRows | Sort-Object CpuPct, MemMB -Descending | Select-Object -First $maxProcItems } $sortIndicator = if ($SortBy -eq 'MEM') { "MEM $arrowDown" } else { "CPU $arrowDown" } $procSecTitle = $ui.ProcSection -f $curSnap.Count, $sortIndicator [void]$out.AppendLine(" ${cBold}${cCyan}[${procSecTitle}]${cReset}${clr}") [void]$out.AppendLine("${cGray}$($ui.ProcHeader)${cReset}${clr}") foreach ($pr in $sortedProcs) { $pCol = & $getMetricColor $pr.CpuPct 25 60 $false $mCol = & $getMetricColor $pr.MemPct 10 25 $false $pBar = & $newGaugeBar $pr.CpuPct 0 100 14 $pCol $pName = if ($pr.Name.Length -gt 26) { $pr.Name.Substring(0, 25) + '~' } else { $pr.Name } [void]$out.AppendLine((" {0,6} {1,-26} {2}{3,6:N1}%{4} [{5}] {6}{7,9:N1} MB{8} {9,5:N1}% {10,6}${clr}" -f ` $pr.Pid, $pName, $pCol, $pr.CpuPct, $cReset, $pBar, $mCol, $pr.MemMB, $cReset, $pr.MemPct, $pr.Threads)) } } # Bottom status / hotkey bar $hkVis = Get-DisplayWidth -Text " $($ui.Hotkeys)" $hkPad = [string]::new(' ', [math]::Max(0, $termWidth - $hkVis)) [void]$out.Append("${cBgBar}${cWhite} $($ui.Hotkeys)${hkPad}${cReset}${clr}") if ($canControlCursor) { [void]$out.Append("$esc[J") } else { [void]$out.AppendLine() } Write-Host $out.ToString() -NoNewline if ($Count -gt 0 -and $i -ge $Count) { break } # Interactive non-blocking sleep loop (~40ms polling for instant hotkeys) $waitSw = [System.Diagnostics.Stopwatch]::StartNew() $targetMs = [int]($Interval * 1000) while ($waitSw.ElapsedMilliseconds -lt $targetMs) { if ($canControlCursor -and [Console]::KeyAvailable) { $keyInfo = [Console]::ReadKey($true) $kChar = [char]::ToLowerInvariant($keyInfo.KeyChar) if ($keyInfo.Key -eq [ConsoleKey]::Escape -or $kChar -eq 'q') { $quitRequested = $true break } elseif ($kChar -eq '1' -or $kChar -eq 'a') { $View = 'Split'; $forceRedraw = $true; Clear-Host; break } elseif ($kChar -eq '2' -or $kChar -eq 'c') { $View = 'Charts'; $forceRedraw = $true; Clear-Host; break } elseif ($kChar -eq '3' -or $kChar -eq 'p') { $View = 'Processes'; $forceRedraw = $true; Clear-Host; break } elseif ($kChar -eq 's') { $SortBy = if ($SortBy -eq 'CPU') { 'MEM' } else { 'CPU' } $forceRedraw = $true; break } elseif ($kChar -eq 'l') { $activeLang = if ($activeLang -eq 'zh-TW') { 'en' } else { 'zh-TW' } $forceRedraw = $true; Clear-Host; break } } Start-Sleep -Milliseconds 40 } } } finally { if ($cntUtil) { $cntUtil.Dispose() } if ($cntPerf) { $cntPerf.Dispose() } if ($cntTemp) { $cntTemp.Dispose() } if ($cntLimit) { $cntLimit.Dispose() } if ($canControlCursor) { [Console]::CursorVisible = $true } Write-Host "$cReset" } } Set-Alias -Name top -Value Start-WinTop Set-Alias -Name wintop -Value Start-WinTop Export-ModuleMember -Function 'Start-WinTop' -Alias @('top', 'wintop') |