Prompt55.psm1
|
<# Module built with ModuleForge ModuleForge Version: 1.3.1 ModuleForge psd1 SHA256: 14d6d96df78aa67c9020c6bb97ac5993c1d4fb44488d6dbb3efc62606001cc61 ModuleForge psm1 SHA256: 2a88fcfae73087886ccade3a4b8090b68d2aca021520a13b2166bab818922c60 BuildDate: 2026-09-20T14:51:57 #> function Hide-TerminalPath { <# .SYNOPSIS [Experimental] Toggles whether the custom Prompt shows your real location or drops the path entirely. .DESCRIPTION Sometimes you want to copy a command, or screenshot/share a terminal, without also handing over your directory structure - usernames, project names, internal paths and the like. When enabled, the path is dropped outright rather than replaced with a placeholder - you're left with a bare '>' (the normal two-line layout) or no path line at all (the Set-PromptMultiLine layout). Sets $global:promptParams.Settings.HideTerminalPath (see Initialize-PromptParams). Off by default - every new shell starts showing the real path. Like $global:thisTerminalName, this does not persist between terminal sessions. .PARAMETER Off Shows the real path again. Omit to hide it. .EXAMPLE Hide-TerminalPath Drops the path segment of the prompt for the rest of this session. .EXAMPLE Hide-TerminalPath -Off Shows the real path again. .NOTES Author: Adrian Andersson #> [CmdletBinding()] PARAM( #Shows the real path again. Omit to hide it. [Parameter()] [switch]$Off ) process { Initialize-PromptParams $global:promptParams.Settings.HideTerminalPath = -not $Off.IsPresent Write-Verbose "Terminal path is now $(if ($global:promptParams.Settings.HideTerminalPath) { 'hidden' } else { 'shown' })" } } function Prompt { <# .SYNOPSIS Custom PowerShell prompt showing location, terminal name, a colour-graded time, last command duration, and git status. .DESCRIPTION Builds a two-line prompt: [terminalName | HH:mm | duration | status | H:historyId][branch|U:n M:n S:n C:+n] path> [Experimental] Once Set-PromptMultiLine has been called, it spreads across up to four lines instead - main segment, git segment (when there is one), the path in its own '[...]' (when it isn't hidden - see Hide-TerminalPath), then a bare '>' - see Set-PromptMultiLine. The terminal name segment only appears once $global:thisTerminalName has been set (see Set-TerminalName). The duration/history segment only appears once at least one command has run in the session. The git segment only appears when the current directory is inside a git repository (detected via a filesystem-only check, so directories outside a repo cost essentially nothing) and the git executable is available on PATH. The git segment costs one 'git status' process spawn per prompt draw while inside a repository (roughly 20-30ms in testing). It's enabled by default; use Set-PromptGitStatus -Off to disable it for the session if that cost isn't worth it to you, or Set-PromptGitStatus with no parameters to turn it back on. [Experimental] The path line normally shows your real location. Once Hide-TerminalPath has been called, the path is dropped entirely rather than replaced with a placeholder - you're left with a bare '>' (two-line layout) or no path line at all (Set-PromptMultiLine) - useful when copying commands or sharing a screenshot without also handing over your directory structure. Off by default; call Hide-TerminalPath -Off to show the real path again. Every colour and threshold mentioned below, plus both toggles above, lives in $global:promptParams (see Initialize-PromptParams) and can be customised - either directly, or via Set-PromptParam, e.g. Set-PromptParam -Name 'Colors.Untracked' -Value @(0, 255, 0). When the host supports ANSI virtual terminal sequences: - The hour and minute are each colour-graded on a rainbow sweep from blue (start of the range) to red (end of the range). - The duration is colour-graded from green (fast) to red (60 seconds or slower) on a logarithmic scale, so it moves away from green quickly - by ~10 seconds it's already amber, not still green - rather than lingering there for most of the range the way a linear scale would. Followed by a single-letter status symbol for the last command's success/failure: green 'P' (pass) or red 'F' (fail). - The branch name is orange for main/master, or one of LightBlue/LightPurple/Green - always the same one for a given branch name - for anything else. - U: (untracked file count) is red, M: (modified but unstaged file count) is cyan, S: (staged change count) is blue, and C: (commits ahead/behind the upstream, e.g. +2, -3, +2/-3 if diverged) is yellow. C: is omitted entirely when the branch has no upstream configured. - [Experimental] The path line's drive (or PSDrive prefix, e.g. 'C:' or 'HKLM:') is blue, each path divider ('\' or '/') is purple, and the trailing '>' is cyan (the same shade as the M: git segment, for contrast against the path text). Hosts that don't support virtual terminal sequences get plain, uncoloured text instead. .EXAMPLE Prompt Returns the prompt string for the current location, command history, and git status. .NOTES Author: Adrian Andersson #> #Capture the outcome of the last user command as the very first thing this function does. #$? reflects whatever statement most recently ran before Prompt was invoked - any cmdlet call #we make first (even Get-History) would overwrite it with that call's own result instead. $lastCommandSucceeded = $? #Cheap hashtable-key checks - ensures $global:promptParams exists and is fully populated, #backfilling anything missing without touching whatever the user has already customised Initialize-PromptParams $lastCommand = Get-History -Count 1 $now = Get-Date #Some hosts (e.g. output redirected to a file, or a host without VT support) can't render #ANSI escape sequences - fall back to plain text rather than printing raw escape codes $supportsColor = $Host.UI.SupportsVirtualTerminal if ($supportsColor) { $reset = $PSStyle.Reset $timeColors = $global:promptParams.Colors.Time $hourColor = Get-PromptGradientColor -Value $now.Hour -Minimum 0 -Maximum 23 -StartHue $timeColors.StartHue -EndHue $timeColors.EndHue $minuteColor = Get-PromptGradientColor -Value $now.Minute -Minimum 0 -Maximum 59 -StartHue $timeColors.StartHue -EndHue $timeColors.EndHue $timeString = "$hourColor$($now.ToString('HH'))$reset`:$minuteColor$($now.ToString('mm'))$reset" } else { $timeString = $now.ToString('HH:mm') } #[Experimental] Off by default ($global:promptParams.Settings.HideTerminalPath defaults to #$false, e.g. a fresh session, means "show the real path") - see Hide-TerminalPath. When #hidden, the path is dropped outright (an empty $pathText) rather than replaced with a #placeholder - $location below collapses to just the arrow, and the MultiLine layout skips #the path line entirely. if ($global:promptParams.Settings.HideTerminalPath -eq $true) { $pathText = '' } else { $rawPath = "$($executionContext.SessionState.Path.CurrentLocation)" $pathText = if ($supportsColor) { Get-PromptColoredPath -Path $rawPath -DriveColor $global:promptParams.Colors.PathDrive -DividerColor $global:promptParams.Colors.PathDivider } else { $rawPath } } $arrowColor = $global:promptParams.Colors.PathArrow $arrowText = if ($supportsColor) { "$($PSStyle.Foreground.FromRgb($arrowColor[0], $arrowColor[1], $arrowColor[2]))>$reset" } else { '>' } $location = "$pathText$arrowText " if ($global:thisTerminalName) { $promptString = "$global:thisTerminalName | $timeString" } else { $promptString = $timeString } $mainSegment = "[$promptString]" if ($lastCommand) { $statusSymbol = if ($lastCommandSucceeded) { 'P' } else { 'F' } $durationSeconds = [math]::Round($($lastCommand.EndExecutionTime.Subtract($lastCommand.StartExecutionTime).TotalSeconds), 2) if ($supportsColor) { $durationParams = $global:promptParams.Colors.Duration $durationColor = Get-PromptGradientColor -Value $durationSeconds -Minimum $durationParams.Minimum -Maximum $durationParams.Maximum -StartHue $durationParams.StartHue -EndHue $durationParams.EndHue -Logarithmic $statusColor = if ($lastCommandSucceeded) { $global:promptParams.Colors.Success } else { $global:promptParams.Colors.Failure } $statusText = "$($PSStyle.Foreground.FromRgb($statusColor[0], $statusColor[1], $statusColor[2]))$statusSymbol$reset" $durationString = "$durationColor$durationSeconds$reset | $statusText" } else { $durationString = "$durationSeconds | $statusSymbol" } $mainSegment = "[$promptString | $durationString | H:$($lastCommand.Id)]" } $gitSegment = '' #Enabled by default - $global:promptParams.Settings.GitStatusEnabled defaults to $true, only #ever $false after an explicit Set-PromptGitStatus -Off. Checked before anything else here so #a disabled segment skips even the cheap filesystem check below. if ($global:promptParams.Settings.GitStatusEnabled -ne $false) { #Resolve the git executable once per session (cached on the module's script scope) rather #than re-searching PATH on every prompt draw. $false is a deliberate "resolved: not found" #sentinel, distinct from $null ("not resolved yet"), so a missing git is only looked for once. if ($null -eq $script:promptGitExe) { $foundGit = Get-Command -Name git -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 $script:promptGitExe = if ($foundGit) { $foundGit.Source } else { $false } } } if ($script:promptGitExe -and $global:promptParams.Settings.GitStatusEnabled -ne $false) { #Pure filesystem check - no process spawn - so directories outside a repo (likely the #common case) cost almost nothing. Only found repos pay for the git invocation below. $repoRoot = Find-GitRepositoryRoot -Path $executionContext.SessionState.Path.CurrentLocation.Path if ($repoRoot) { $statusLines = Get-GitStatusPorcelainText -GitPath $script:promptGitExe -RepositoryRoot $repoRoot $gitStatus = $statusLines | ConvertFrom-GitStatusPorcelain if ($gitStatus.Branch) { if ($supportsColor) { $gitColors = $global:promptParams.Colors $branchColor = Get-PromptBranchColor -BranchName $gitStatus.Branch -MainColor $gitColors.BranchMain -Palette $gitColors.BranchPalette $branchText = "$branchColor$($gitStatus.Branch)$reset" $untrackedText = "$($PSStyle.Foreground.FromRgb($gitColors.Untracked[0], $gitColors.Untracked[1], $gitColors.Untracked[2]))U:$($gitStatus.UntrackedCount)$reset" $modifiedText = "$($PSStyle.Foreground.FromRgb($gitColors.Modified[0], $gitColors.Modified[1], $gitColors.Modified[2]))M:$($gitStatus.ModifiedCount)$reset" $stagedText = "$($PSStyle.Foreground.FromRgb($gitColors.Staged[0], $gitColors.Staged[1], $gitColors.Staged[2]))S:$($gitStatus.StagedCount)$reset" } else { $branchText = $gitStatus.Branch $untrackedText = "U:$($gitStatus.UntrackedCount)" $modifiedText = "M:$($gitStatus.ModifiedCount)" $stagedText = "S:$($gitStatus.StagedCount)" } $gitSegment = "[$branchText|$untrackedText $modifiedText $stagedText" if ($gitStatus.HasUpstream) { $aheadBehind = if ($gitStatus.Ahead -gt 0 -and $gitStatus.Behind -gt 0) { "+$($gitStatus.Ahead)/-$($gitStatus.Behind)" } elseif ($gitStatus.Ahead -gt 0) { "+$($gitStatus.Ahead)" } elseif ($gitStatus.Behind -gt 0) { "-$($gitStatus.Behind)" } else { '0' } $commitText = if ($supportsColor) { $commitColor = $global:promptParams.Colors.Commits "$($PSStyle.Foreground.FromRgb($commitColor[0], $commitColor[1], $commitColor[2]))C:$aheadBehind$reset" } else { "C:$aheadBehind" } $gitSegment = "$gitSegment $commitText" } $gitSegment = "$gitSegment]" } } } #[Experimental] Off by default - see Set-PromptMultiLine if ($global:promptParams.Settings.MultiLine -eq $true) { #Array subexpression instead of building $lines up with += - each 'if' contributes zero #elements when the segment isn't present, one when it is, rather than needing separate #conditional appends $lines = @( $mainSegment if ($gitSegment) { $gitSegment } if ($pathText) { "[$pathText]" } "$arrowText " ) $lines -join "`n" } else { "$mainSegment$gitSegment`n$location" } } function Set-PromptGitStatus { <# .SYNOPSIS Turns the git status segment of the custom Prompt on or off for this session. .DESCRIPTION The git segment (branch, untracked/staged counts, ahead/behind) costs one 'git status' process spawn per prompt draw while inside a repository - roughly 20-30ms in testing, versus under 2ms for everywhere else. It's on by default; use -Off if you'd rather not pay that cost, and call Set-PromptGitStatus again with no parameters to turn it back on. Sets $global:promptParams.Settings.GitStatusEnabled (see Initialize-PromptParams). Like $global:thisTerminalName, this does not persist between terminal sessions - every new shell starts with the git segment enabled again. .PARAMETER Off Disables the git status segment. Omit to (re-)enable it. .EXAMPLE Set-PromptGitStatus -Off Turns the git status segment off for the rest of this session. .EXAMPLE Set-PromptGitStatus Turns the git status segment back on. .NOTES Author: Adrian Andersson #> [CmdletBinding()] PARAM( #Disables the git status segment. Omit to (re-)enable it. [Parameter()] [switch]$Off ) process { Initialize-PromptParams $global:promptParams.Settings.GitStatusEnabled = -not $Off.IsPresent Write-Verbose "Git status segment is now $(if ($global:promptParams.Settings.GitStatusEnabled) { 'enabled' } else { 'disabled' })" } } function Set-PromptMultiLine { <# .SYNOPSIS [Experimental] Toggles whether the custom Prompt renders as two lines or four. .DESCRIPTION The prompt normally renders on two lines: the main/git segments on the first, the path and '>' together on the second. When enabled, it spreads across up to four lines instead: mainSegment gitSegment (only present when there is one) [path] > Sets $global:promptParams.Settings.MultiLine (see Initialize-PromptParams). Off by default - every new shell starts on the two-line layout, and resets every new session like the rest of the prompt's state. .PARAMETER Off Returns to the two-line layout. Omit to enable the four-line layout. .EXAMPLE Set-PromptMultiLine Switches the prompt to the multi-line layout for the rest of this session. .EXAMPLE Set-PromptMultiLine -Off Returns to the two-line layout. .NOTES Author: Adrian Andersson #> [CmdletBinding()] PARAM( #Returns to the two-line layout. Omit to enable the four-line layout. [Parameter()] [switch]$Off ) process { Initialize-PromptParams $global:promptParams.Settings.MultiLine = -not $Off.IsPresent Write-Verbose "Prompt layout is now $(if ($global:promptParams.Settings.MultiLine) { 'multi-line' } else { 'two-line' })" } } function Set-PromptParam { <# .SYNOPSIS Customises a colour or threshold used by the custom Prompt. .DESCRIPTION Every colour and threshold the custom Prompt uses lives in one global hashtable, $global:promptParams (see Initialize-PromptParams for its shape and defaults). This is a guided way to change a value in it without reaching into the hashtable by hand - though doing that directly (e.g. $global:promptParams.Colors.Untracked = @(0,255,0)) works exactly the same, since it's the same table underneath. Name is a dot-separated path into the table (e.g. 'Colors.Untracked' or 'Colors.Duration.Maximum'). Initialize-PromptParams runs first, so this works even in a fresh session where $global:promptParams doesn't exist yet, and any segment of the path that doesn't already exist is created as a nested hashtable along the way. Like the rest of the prompt's global state, this does not persist between terminal sessions - every new shell starts back on the defaults. .PARAMETER Name A dot-separated path into $global:promptParams, e.g. 'Colors.Untracked' or 'Settings.GitStatusEnabled'. .PARAMETER Value The value to set at that path. .EXAMPLE Set-PromptParam -Name 'Colors.Untracked' -Value @(0, 255, 0) Changes the U: (untracked file count) colour to green. .EXAMPLE Set-PromptParam -Name 'Colors.Duration.Maximum' -Value 45 Changes the duration gradient so it reaches full red at 45 seconds instead of 60. .NOTES Author: Adrian Andersson #> [CmdletBinding()] PARAM( #A dot-separated path into $global:promptParams, e.g. 'Colors.Untracked' [Parameter(Mandatory, Position = 0)] [string]$Name, #The value to set at that path [Parameter(Mandatory, Position = 1)] [AllowNull()] $Value ) process { Initialize-PromptParams $segments = $Name -split '\.' $node = $global:promptParams for ($i = 0; $i -lt $segments.Count - 1; $i++) { $segment = $segments[$i] if ($node[$segment] -isnot [hashtable]) { $node[$segment] = @{} } $node = $node[$segment] } $node[$segments[-1]] = $Value } } function Set-TerminalName { <# .SYNOPSIS Used with the Custom Prompt. A simple little helper function to help name the terminal window .DESCRIPTION Sometimes it makes sense to label your terminal window for easier identification. Stores the name in $global:thisTerminalName (used by the Custom Prompt) and, where supported, also sets the console window title to match. Control characters (escape sequences, tabs, newlines, etc.) are stripped from the name before it's stored or displayed. Setting the window title is skipped when console output is redirected or piped, such as during a CI run. .PARAMETER terminalName The label to use for the terminal window. .PARAMETER clear Clears the currently set terminal name and resets the window title to its default. .EXAMPLE Set-TerminalName "It's important to have goals 🥅" Sets the terminal name, and the window title where supported, to "It's important to have goals 🥅" .EXAMPLE Set-TerminalName -clear Clears the currently set terminal name and resets the window title to its default. .NOTES Author: Adrian Andersson #> [CmdletBinding(DefaultParameterSetName='Default')] PARAM( #What label do you want to use for your terminal [Parameter(ParameterSetName='Default',Mandatory,Position=0,ValueFromPipeline,ValueFromPipelineByPropertyName)] [string]$terminalName, #Use this to clear the currently set Terminal Name [Parameter(ParameterSetName='Clear')] [switch]$clear ) begin{ #Return the script name when running verbose, makes it tidier write-verbose "===========Executing $($MyInvocation.InvocationName)===========" #Return the sent variables when running debug Write-Debug "BoundParams: $($MyInvocation.BoundParameters|Out-String)" } process{ #If the Parameter Set is Clear, then remove the existing Terminal Name if($PSCmdlet.ParameterSetName -eq 'Clear') { Write-Verbose 'Clear triggered. Wiping existing parameter set' Remove-Variable -Scope Global -Name thisTerminalName -ErrorAction Ignore $defaultTitle = if($PSVersionTable.PSEdition -eq 'Desktop') { 'Windows PowerShell' } else { 'PowerShell' } #Skip touching the title when stdout is redirected/piped (e.g. CI runs) - #on non-Windows hosts the title is set by writing a raw ANSI escape sequence #to stdout, which would otherwise land in captured output/logs. if([Console]::IsOutputRedirected){ Write-Verbose 'Output is redirected. Skipping Window Title clear.' }else{ try{ $host.ui.rawui.WindowTitle = $defaultTitle Write-Verbose 'Successfully cleared the Window Title' }catch{ Write-Verbose 'Missed on clearing Window Title' } } #Otherwise, set the terminal }else{ #Strip control characters (ESC, BEL, newlines, tabs, etc.) so a stray one #can't inject an unintended escape sequence when the title is written out $terminalName = $terminalName -replace '[\x00-\x1F\x7F]', '' Write-Verbose "Setting Parameter set name to: $($terminalName)" #One of the rare occasions that using a global var makes sense and is justified $global:thisTerminalName = $terminalName #We should also try and inject it into the Window Title. #Just in case the Terminal doesn't support this specifically, we should wrap it #Skip touching the title when stdout is redirected/piped (e.g. CI runs) - #on non-Windows hosts the title is set by writing a raw ANSI escape sequence #to stdout, which would otherwise land in captured output/logs. if([Console]::IsOutputRedirected){ Write-Verbose 'Output is redirected. Skipping Window Title set.' }else{ try{ $host.ui.rawui.WindowTitle = $terminalName Write-Verbose 'Successfully set the Window Title' }catch{ Write-Verbose 'Missed on setting Window Title.' } } } } } function ConvertFrom-GitStatusPorcelain { <# .SYNOPSIS Parses the output of 'git status --porcelain=v2 --branch' into a summary object. .DESCRIPTION A pure text parser - it never runs git itself, so it's fully testable with canned sample output. Understands the porcelain v2 header lines (branch name, ahead/behind vs. upstream) plus the '1'/'2' (ordinary/renamed) tracked-entry lines and '?' untracked-entry lines. Each tracked-entry line carries two status characters, X (index) and Y (worktree). Staged count is the number of entries whose X status is not '.' - i.e. "changes to be committed". Modified count is the number of entries whose Y status is not '.' - i.e. changes made to a tracked file that haven't been staged yet. A file that's been modified and then staged (or modified again after staging) counts toward both. Ahead/Behind are only meaningful when the branch has an upstream configured, which is reflected in HasUpstream. .PARAMETER InputObject Lines of 'git status --porcelain=v2 --branch' output. .EXAMPLE git status --porcelain=v2 --branch | ConvertFrom-GitStatusPorcelain Parses the current repository's status into a summary object. .NOTES Author: Adrian Andersson #> [CmdletBinding()] [OutputType([pscustomobject])] PARAM( #Lines of 'git status --porcelain=v2 --branch' output [Parameter(Mandatory, ValueFromPipeline)] [AllowEmptyCollection()] [AllowEmptyString()] [string[]]$InputObject ) begin { $branch = $null $hasUpstream = $false $ahead = 0 $behind = 0 $stagedCount = 0 $modifiedCount = 0 $untrackedCount = 0 } process { foreach ($rawLine in $InputObject) { if ([string]::IsNullOrEmpty($rawLine)) { continue } #Defensive only - git's porcelain output is documented as LF-terminated even on #Windows, and PowerShell's native-command line capture already strips line endings, #but a stray trailing \r would otherwise end up inside the '(.+)$' capture groups below $line = $rawLine.TrimEnd("`r") switch -Regex ($line) { '^# branch\.head (.+)$' { $branch = $Matches[1] continue } '^# branch\.ab \+(\d+) -(\d+)$' { $ahead = [int]$Matches[1] $behind = [int]$Matches[2] $hasUpstream = $true continue } '^[12] (\S)(\S) ' { if ($Matches[1] -ne '.') { $stagedCount++ } if ($Matches[2] -ne '.') { $modifiedCount++ } continue } '^\? ' { $untrackedCount++ continue } } } } end { [pscustomobject]@{ Branch = $branch HasUpstream = $hasUpstream Ahead = $ahead Behind = $behind StagedCount = $stagedCount ModifiedCount = $modifiedCount UntrackedCount = $untrackedCount } } } function Find-GitRepositoryRoot { <# .SYNOPSIS Walks up from a starting directory looking for a .git entry, to answer "is this directory a git repository or a child of one" without spawning a git process. .DESCRIPTION Repeatedly checks for a '.git' entry - a directory for a normal repository, or a file for a worktree/submodule, both of which are valid "this is a repo" markers - in the given path and each of its parents, stopping at the filesystem root. This is a pure filesystem check - no external process is started - so it's cheap to call on every prompt draw even for the (likely common) case of not being inside a repository at all. .PARAMETER Path The directory to start searching from. Defaults to the current location. .EXAMPLE Find-GitRepositoryRoot -Path 'C:\repos\MyProject\src\deep\folder' Returns 'C:\repos\MyProject' if a .git entry exists there, or in any parent up to the filesystem root. Returns $null if none is found. .NOTES Author: Adrian Andersson #> [CmdletBinding()] [OutputType([string])] PARAM( #Directory to start searching from [Parameter()] [string]$Path = $(Get-Location).Path ) process { $current = $Path while ($current) { $gitEntry = Join-Path -Path $current -ChildPath '.git' if (Test-Path -LiteralPath $gitEntry) { return $current } $parent = Split-Path -Path $current -Parent if ([string]::IsNullOrEmpty($parent) -or $parent -eq $current) { return $null } $current = $parent } } } function Get-GitStatusPorcelainText { <# .SYNOPSIS Runs 'git status --porcelain=v2 --branch' against a repository and returns its raw output lines. .DESCRIPTION A thin process-invocation wrapper - deliberately does nothing but shell out and hand back text. Keeping it this small means callers (like Prompt) can substitute this one seam in tests instead of depending on a real git binary and repository. Any failure (git errors out, path is bad, etc.) is swallowed and returns nothing rather than throwing - this feeds decorative prompt output, not something that should ever be allowed to break a shell. .PARAMETER GitPath Path to the git executable. .PARAMETER RepositoryRoot Repository root to run the status command against (passed to git's -C). .EXAMPLE Get-GitStatusPorcelainText -GitPath 'C:\Program Files\Git\cmd\git.exe' -RepositoryRoot 'C:\repos\MyProject' Returns the raw 'git status --porcelain=v2 --branch' output lines for that repository. .NOTES Author: Adrian Andersson #> [CmdletBinding()] [OutputType([string[]])] PARAM( #Path to the git executable [Parameter(Mandatory)] [string]$GitPath, #Repository root to run the status command against [Parameter(Mandatory)] [string]$RepositoryRoot ) process { try { & $GitPath -C $RepositoryRoot status --porcelain=v2 --branch 2>$null } catch { Write-Verbose "Failed to read git status: $_" } } } function Get-PromptBranchColor { <# .SYNOPSIS Returns an ANSI truecolor foreground escape sequence for a git branch name. .DESCRIPTION 'main' and 'master' (case-insensitive) always get orange. Any other branch name gets one of three colours - LightBlue, LightPurple, Green - chosen by a stable hash of the branch name, so the same branch always gets the same colour across sessions. A stable hash is used deliberately rather than .NET's default string hashing: string .GetHashCode() is randomised per process in modern .NET, so it would give a different colour to the same branch every time a new shell is opened. .PARAMETER BranchName The git branch name to pick a colour for. .PARAMETER MainColor RGB triple used for 'main'/'master'. Defaults to Orange (255, 165, 0) - the caller (Prompt) normally passes $global:promptParams.Colors.BranchMain instead. .PARAMETER Palette Array of RGB triples to hash any other branch name into. Defaults to LightBlue/LightPurple/Green - the caller (Prompt) normally passes $global:promptParams.Colors.BranchPalette instead. .EXAMPLE Get-PromptBranchColor -BranchName 'main' Returns the ANSI escape sequence for orange. .EXAMPLE Get-PromptBranchColor -BranchName 'feature/widgets' Returns one of LightBlue/LightPurple/Green, always the same one for this exact name. .NOTES Author: Adrian Andersson #> [CmdletBinding()] [OutputType([string])] PARAM( #The git branch name to pick a colour for [Parameter(Mandatory)] [AllowEmptyString()] [string]$BranchName, #RGB triple used for 'main'/'master' [Parameter()] [int[]]$MainColor = @(255, 165, 0), #Array of RGB triples to hash any other branch name into [Parameter()] [object[]]$Palette = @( @(173, 216, 230), #LightBlue @(238, 130, 238), #LightPurple @(50, 205, 50) #Green ) ) process { if ($BranchName -in @('main', 'master')) { return $PSStyle.Foreground.FromRgb($MainColor[0], $MainColor[1], $MainColor[2]) } #FNV-1a, 32-bit - simple, deterministic, and stable across sessions/processes (unlike #the runtime-randomised default .GetHashCode()). Uses modulo rather than -band to stay #within 32 bits, since -band against the (UInt32-typed) 0xFFFFFFFF literal fights with #the Int64 accumulator and silently fails to mask, letting the value overflow. [int64]$hash = 2166136261 foreach ($character in $BranchName.ToCharArray()) { $hash = ($hash -bxor [int64][char]$character) % 4294967296 $hash = ($hash * 16777619) % 4294967296 } $selected = $Palette[$hash % $Palette.Count] $PSStyle.Foreground.FromRgb($selected[0], $selected[1], $selected[2]) } } function Get-PromptColoredPath { <# .SYNOPSIS Colours a PowerShell location string for the prompt: drive/PSDrive prefix blue, path dividers purple. .DESCRIPTION Splits a location string (e.g. 'C:\repos\Prompt55' or 'HKLM:\SOFTWARE') into its drive prefix - anything up to and including the first ':' - and the remainder. The drive prefix is coloured blue; every path divider ('\' or '/') found in the remainder is coloured purple. Everything else (the actual segment names) is left in the host's default foreground colour. A path with no drive prefix (e.g. a UNC path, or a root-relative path on a non-Windows host) is coloured on dividers alone - there's no drive segment to highlight. A pure string function - no console/host access - so it's cheap to call from a hot path like a custom prompt and easy to unit test in isolation. Only meant to be called when the host supports ANSI virtual terminal sequences; the caller is expected to fall back to the plain path otherwise. .PARAMETER Path The location string to colour, e.g. as returned by $executionContext.SessionState.Path.CurrentLocation. .PARAMETER DriveColor RGB triple for the drive/PSDrive prefix. Defaults to DodgerBlue (30, 144, 255) - the caller (Prompt) normally passes $global:promptParams.Colors.PathDrive instead. .PARAMETER DividerColor RGB triple for each path divider ('\' or '/'). Defaults to Purple (128, 0, 128) - the caller (Prompt) normally passes $global:promptParams.Colors.PathDivider instead. .EXAMPLE Get-PromptColoredPath -Path 'C:\repos\Prompt55' Returns 'C:' in blue, followed by '\repos\Prompt55' with each '\' coloured purple. .EXAMPLE Get-PromptColoredPath -Path 'HKLM:\SOFTWARE' Colours the 'HKLM:' drive prefix the same way as a filesystem drive. .NOTES Author: Adrian Andersson #> [CmdletBinding()] [OutputType([string])] PARAM( #The location string to colour [Parameter(Mandatory)] [AllowEmptyString()] [string]$Path, #RGB triple for the drive/PSDrive prefix [Parameter()] [int[]]$DriveColor = @(30, 144, 255), #RGB triple for each path divider ('\' or '/') [Parameter()] [int[]]$DividerColor = @(128, 0, 128) ) process { $reset = $PSStyle.Reset $driveColorCode = $PSStyle.Foreground.FromRgb($DriveColor[0], $DriveColor[1], $DriveColor[2]) $dividerColorCode = $PSStyle.Foreground.FromRgb($DividerColor[0], $DividerColor[1], $DividerColor[2]) if ($Path -match '^([^\\/]+:)(.*)$') { $driveText = "$driveColorCode$($Matches[1])$reset" $remainder = $Matches[2] } else { $driveText = '' $remainder = $Path } #$0 re-inserts the whole match (the divider character itself) wrapped in colour codes, #so this works whether the provider uses '\' (Windows) or '/' (everywhere else) without #having to special-case either $dividerReplacement = $dividerColorCode + '$0' + $reset $remainderText = [regex]::Replace($remainder, '[\\/]', $dividerReplacement) "$driveText$remainderText" } } function Get-PromptGradientColor { <# .SYNOPSIS Returns an ANSI truecolor foreground escape sequence for a value's position within a hue gradient. .DESCRIPTION Clamps Value into the Minimum/Maximum range, then interpolates a hue between StartHue and EndHue based on where the (clamped) value falls in that range - linearly by default, or logarithmically with -Logarithmic. The resulting hue, at full saturation and brightness, is converted to RGB and returned as an ANSI truecolor foreground escape sequence via $PSStyle.Foreground.FromRgb(). This is a pure function - no console/host access, no cmdlet calls beyond the final FromRgb() lookup - so it's cheap enough to call repeatedly from a hot path like a custom prompt, and easy to unit test in isolation. Used to build gradients such as blue-to-red (a "rainbow" sweep, hue 240 -> 0) for a time-of-day scale, or green-to-red (hue 120 -> 0) for a duration/heat scale. .PARAMETER Value The value to place within the gradient. .PARAMETER Minimum The value that maps to StartHue. Values below this clamp to Minimum. With -Logarithmic, must be -1 or greater (it feeds log(Minimum + 1)). .PARAMETER Maximum The value that maps to EndHue. Values above this clamp to Maximum. .PARAMETER StartHue Hue, in degrees (0-360), used when Value is at or below Minimum. .PARAMETER EndHue Hue, in degrees (0-360), used when Value is at or above Maximum. .PARAMETER Logarithmic Interpolates on a log(Value + 1) scale instead of linearly, so the hue moves away from StartHue much faster for small values and levels off as Value approaches Maximum. Matches how duration "feels" - the difference between 1s and 2s is far more noticeable than the difference between 50s and 51s. The '+ 1' shift keeps the log defined (and the ratio well-behaved) even when Minimum is 0. .EXAMPLE Get-PromptGradientColor -Value 0 -Minimum 0 -Maximum 23 -StartHue 240 -EndHue 0 Returns the ANSI escape sequence for pure blue (hue 240), since Value sits at Minimum. .EXAMPLE Get-PromptGradientColor -Value 90 -Minimum 1 -Maximum 60 -StartHue 120 -EndHue 0 Value is clamped to Maximum (60), so this returns the ANSI escape sequence for pure red (hue 0). .EXAMPLE Get-PromptGradientColor -Value 10 -Minimum 0 -Maximum 60 -StartHue 120 -EndHue 0 -Logarithmic Returns an amber-ish colour - on this log scale, 10 out of a 0-60 range has already moved well past green, unlike the ~80% still-green result a linear interpolation would give. .NOTES Author: Adrian Andersson #> [CmdletBinding()] [OutputType([string])] PARAM( #The value to place within the gradient [Parameter(Mandatory)] [double]$Value, #The value that maps to StartHue - anything below this clamps to it [Parameter(Mandatory)] [double]$Minimum, #The value that maps to EndHue - anything above this clamps to it [Parameter(Mandatory)] [double]$Maximum, #Hue (0-360) used when Value is at or below Minimum [Parameter(Mandatory)] [ValidateRange(0, 360)] [double]$StartHue, #Hue (0-360) used when Value is at or above Maximum [Parameter(Mandatory)] [ValidateRange(0, 360)] [double]$EndHue, #Interpolates on a log(Value + 1) scale instead of linearly - see .PARAMETER Logarithmic [Parameter()] [switch]$Logarithmic ) process { #Clamp the value into range, then normalize it to a 0..1 ratio across that range $clampedValue = [math]::Min([math]::Max($Value, $Minimum), $Maximum) if ($Logarithmic) { #'+ 1' shift so log() stays defined (and the ratio well-behaved) even when Minimum is 0 $logMinimum = [math]::Log($Minimum + 1) $logMaximum = [math]::Log($Maximum + 1) $logValue = [math]::Log($clampedValue + 1) $ratio = if ($logMaximum -eq $logMinimum) { 0 } else { ($logValue - $logMinimum) / ($logMaximum - $logMinimum) } } else { $ratio = if ($Maximum -eq $Minimum) { 0 } else { ($clampedValue - $Minimum) / ($Maximum - $Minimum) } } $hue = $StartHue + (($EndHue - $StartHue) * $ratio) #Standard HSV (hue, saturation=1, value=1) to RGB sector conversion $chroma = 1 $huePrime = $hue / 60 $secondLargest = $chroma * (1 - [math]::Abs(($huePrime % 2) - 1)) switch ([math]::Floor($huePrime) % 6) { 0 { $red, $green, $blue = $chroma, $secondLargest, 0 } 1 { $red, $green, $blue = $secondLargest, $chroma, 0 } 2 { $red, $green, $blue = 0, $chroma, $secondLargest } 3 { $red, $green, $blue = 0, $secondLargest, $chroma } 4 { $red, $green, $blue = $secondLargest, 0, $chroma } default { $red, $green, $blue = $chroma, 0, $secondLargest } } $PSStyle.Foreground.FromRgb([int][math]::Round($red * 255), [int][math]::Round($green * 255), [int][math]::Round($blue * 255)) } } function Initialize-PromptParams { <# .SYNOPSIS Ensures $global:promptParams exists and has every default key, without clobbering anything the user (or Set-PromptParam) has already customised. .DESCRIPTION $global:promptParams is the single hashtable that holds every tunable colour and threshold used by the custom Prompt, plus its on/off toggles (Settings.GitStatusEnabled, Settings.HideTerminalPath, Settings.MultiLine). This function is the "check for and load defaults" step: - If $global:promptParams doesn't exist yet (or isn't a hashtable), it's set to a fresh copy of the defaults outright. - Otherwise, the defaults are recursively merged in: any key present in the defaults but missing from $global:promptParams (at any nesting level) is filled in; any key the user has already set is left completely untouched. Called unconditionally - and cheaply, it's just hashtable key lookups - at the start of every function that reads or writes $global:promptParams (Prompt, Set-PromptGitStatus, Hide-TerminalPath, Set-PromptMultiLine, Set-PromptParam), so the table is always safe to read from and self-heals if something clears a key mid-session. .EXAMPLE Initialize-PromptParams Creates $global:promptParams from defaults if it doesn't exist, or backfills any missing keys if it does. .NOTES Author: Adrian Andersson #> [CmdletBinding()] PARAM() process { #Recursively fills in any key missing from $Target that exists in $Defaults, at any #nesting level, without touching a key $Target already has. Scoped inside this function #(rather than living as its own private function/file) since it's purely an #implementation detail of the merge below. function Merge-PromptParamDefaults { PARAM( [Parameter(Mandatory)] [hashtable]$Target, [Parameter(Mandatory)] [hashtable]$Defaults ) foreach ($key in $Defaults.Keys) { if (-not $Target.ContainsKey($key)) { $Target[$key] = $Defaults[$key] } elseif ($Defaults[$key] -is [hashtable] -and $Target[$key] -is [hashtable]) { Merge-PromptParamDefaults -Target $Target[$key] -Defaults $Defaults[$key] } } } $defaults = @{ Colors = @{ Time = @{ StartHue = 240 EndHue = 0 } Duration = @{ StartHue = 120 EndHue = 0 Minimum = 0 Maximum = 60 } Untracked = @(255, 0, 0) #Red - U: Modified = @(0, 255, 255) #Cyan - M: Staged = @(30, 144, 255) #DodgerBlue - S: Commits = @(255, 255, 0) #Yellow - C: BranchMain = @(255, 165, 0) #Orange - main/master BranchPalette = @( @(173, 216, 230), #LightBlue @(238, 130, 238), #LightPurple @(50, 205, 50) #Green ) PathDrive = @(30, 144, 255) #DodgerBlue PathDivider = @(128, 0, 128) #Purple PathArrow = @(0, 255, 255) #Cyan Success = @(0, 255, 0) #Green - the last command status symbol, on success Failure = @(255, 0, 0) #Red - the last command status symbol, on failure } Settings = @{ GitStatusEnabled = $true HideTerminalPath = $false MultiLine = $false } } if ($global:promptParams -isnot [hashtable]) { $global:promptParams = $defaults } else { Merge-PromptParamDefaults -Target $global:promptParams -Defaults $defaults } } } |