YorSubs
YorSubs

Reputation: 4070

PowerShell switch between multiple prompt functions and scoping

I have found the following behaviour that I do not understand. I have some functions in my $profile (specifically, that change my prompt, so function prmopt { }) with settings that change my prompt and when I start a console, if I dotsource the function ( . PromptCustom ), it takes full effect and the new prompt takes over. However, I don't want my $profile to be too big so I moved my five or so different prompts into a Module, but when I try to dotsource any of them, nothing happens. They just output what the prompt might look like but do not take over as the default prompt.

The objective is to be able to have multiple functions that switch between prompts as required (i.e. not a single prompt that applies to every console, for which I would just put function prompt in my $profile). When I move functions that follow the below template to a Module, they all break and so I was wondering if that was a scoping issue, and how to achieve the goal of having mutltiple prompt functions in a Module that I can switch between instead of being forced to keep them in my $profile? (Edit: updating this question as @mklement0 pointed out, since really it's about the required objective i.e. having prompts that I can switch between).

Here is one of my prompt functions that dotsources and takes over as the default prompt perfectly if this function is defined in my $profile but does nothing if it is put into a Module:

function PromptShortenPath {
    # https://stackoverflow.com/questions/1338453/custom-powershell-prompts
    function shorten-path([string] $path) {
        $loc = $path.Replace($HOME, '~')
        # remove prefix for UNC paths
        $loc = $loc -replace '^[^:]+::', ''
        # make path shorter like tabs in Vim,
        # handle paths starting with \\ and . correctly
        return ($loc -replace '\\(\.?)([^\\])[^\\]*(?=\\)','\$1$2')
    }
    function prompt {
        # our theme
        $cdelim = [ConsoleColor]::DarkCyan
        $chost = [ConsoleColor]::Green
        $cloc = [ConsoleColor]::Cyan

        write-host "$([char]0x0A7) " -n -f $cloc
        write-host ([net.dns]::GetHostName()) -n -f $chost
        write-host ' {' -n -f $cdelim
        write-host (shorten-path (pwd).Path) -n -f $cloc
        write-host '}' -n -f $cdelim
        return ' '
    }

    if ($MyInvocation.InvocationName -eq "PromptShortenPath") {
        "`nWarning: Must dotsource '$($MyInvocation.MyCommand)' or it will not be applied to this session.`n`n   . $($MyInvocation.MyCommand)`n"
    } else {
        . prompt 
    }
}

Upvotes: 2

Views: 293

Answers (3)

YorSubs
YorSubs

Reputation: 4070

I've finally come down to the following solution. Thanks for helping with this @mklement / @Scepticalist. In the end, I really only needed the global: invocation. I didn't want a dynamic function (though interesting to see that, will probably be useful) and I did not want the prompt to activate upon importing the module (this was explicitly what I wanted to avoid in fact!).

All of these now work by dropping into any personal Module. Importing the Module will not activate the prompt (this was my desired outcome). Each prompt can then be activated on demand simply by invoking the function that sets that prompt (or its alias).

Edit: Please feel free to add any more prompt functions that do interesting things. I'm always very interested in seeing more useful tricks and variations for prompt configurations! :)

function PromptDefault {
    # get-help about_Prompt
    # https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_prompts?view=powershell-7
    function global:prompt {
        "PS $($executionContext.SessionState.Path.CurrentLocation)$('>' * ($nestedPromptLevel + 1)) ";
        # .Link
        # https://go.microsoft.com/fwlink/?LinkID=225750
        # .ExternalHelp System.Management.Automation.dll-help.xml

        $Elevated = ""
        $user = [Security.Principal.WindowsIdentity]::GetCurrent();
        if ((New-Object Security.Principal.WindowsPrincipal $user).IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator)) {$Elevated = "Administrator: "}
        # $TitleVer = "PS v$($PSVersionTable.PSversion.major).$($PSVersionTable.PSversion.minor)"
        $TitleVer = "PowerShell"
        $Host.UI.RawUI.WindowTitle = "$($Elevated)$($TitleVer)"
    }
}

# More simple alternative prompt, need to dotsource this
function PromptTimeUptime {
    function global:prompt {
        # Adds date/time to prompt and uptime to title bar
        $Elevated = "" ; if (Test-Admin) {$Elevated = "Administrator: "}
        $up = Uptime
        $Host.UI.RawUI.WindowTitle = $Elevated + "PowerShell [Uptime: $up]"   # Title bar info
        $path = Get-Location
        Write-Host '[' -NoNewline
        Write-Host (Get-Date -UFormat '%T') -ForegroundColor Green -NoNewline   # $TitleDate = Get-Date -format "dd/MM/yyyy HH:mm:ss"
        Write-Host '] ' -NoNewline
        Write-Host "$path" -NoNewline
        return "> "   # Must have a line like this at end of prompt or you always get " PS>" on the prompt
    }
}

function PromptTruncatedPaths {
    # https://www.johndcook.com/blog/2008/05/12/customizing-the-powershell-command-prompt/
    function global:prompt {
        $cwd = (get-location).Path
        [array]$cwdt=$()
        $cwdi = -1
        do {$cwdi = $cwd.indexofany("\", $cwdi+1) ; [array]$cwdt+=$cwdi} until($cwdi -eq -1)
        if ($cwdt.count -gt 3) { $cwd = $cwd.substring(0,$cwdt[0]) + ".." + $cwd.substring($cwdt[$cwdt.count-3]) }
        $host.UI.RawUI.WindowTitle = "$(hostname) – $env:USERDNSDOMAIN$($env:username)"
        $host.UI.Write("Yellow", $host.UI.RawUI.BackGroundColor, "[PS]")
        " $cwd> "
    }
}

function PromptShortenPath {
    # https://stackoverflow.com/questions/1338453/custom-powershell-prompts
    function global:shorten-path([string] $path) {
        $loc = $path.Replace($HOME, '~')
        # remove prefix for UNC paths
        $loc = $loc -replace '^[^:]+::', ''
        # make path shorter like tabs in Vim,
        # handle paths starting with \\ and . correctly
        return ($loc -replace '\\(\.?)([^\\])[^\\]*(?=\\)','\$1$2')
    }
    function global:prompt {
        # our theme
        $cdelim = [ConsoleColor]::DarkCyan
        $chost = [ConsoleColor]::Green
        $cloc = [ConsoleColor]::Cyan

        write-host "$([char]0x0A7) " -n -f $cloc
        write-host ([net.dns]::GetHostName()) -n -f $chost
        write-host ' {' -n -f $cdelim
        write-host (shorten-path (pwd).Path) -n -f $cloc
        write-host '}' -n -f $cdelim
        return ' '
    }
}

function PromptUserAndExecutionTimer {
    function global:prompt {

        ### Title bar info
        $user = [Security.Principal.WindowsIdentity]::GetCurrent();
        $Elevated = ""
        if ((New-Object Security.Principal.WindowsPrincipal $user).IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator)) {$Elevated = "Admin: "}
        $TitleVer = "PS v$($PSVersionTable.PSversion.major).$($PSVersionTable.PSversion.minor)"
        # $($executionContext.SessionState.Path.CurrentLocation.path)

        ### Custom Uptime without seconds (not really necessary)
        # $wmi = gwmi -class Win32_OperatingSystem -computer "."
        # $LBTime = $wmi.ConvertToDateTime($wmi.Lastbootuptime)
        # [TimeSpan]$uptime = New-TimeSpan $LBTime $(get-date)
        # $s = "" ; if ($uptime.Days -ne 1) {$s = "s"}
        # $TitleUp = "[Up: $($uptime.days) day$s $($uptime.hours) hr $($uptime.minutes) min]"

        $Host.UI.RawUI.WindowTitle = "$($Elevated) $($TitleVer)"   # $($TitleUp)"

        ### History ID
        $HistoryId = $MyInvocation.HistoryId
        # Uncomment below for leading zeros
        # $HistoryId = '{0:d4}' -f $MyInvocation.HistoryId
        Write-Host -Object "$HistoryId " -NoNewline -ForegroundColor Cyan


        ### Time calculation
        $Success = $?
        $LastExecutionTimeSpan = if (@(Get-History).Count -gt 0) {
            Get-History | Select-Object -Last 1 | ForEach-Object {
                New-TimeSpan -Start $_.StartExecutionTime -End $_.EndExecutionTime
            }
        }
        else {
            New-TimeSpan
        }

        $LastExecutionShortTime = if ($LastExecutionTimeSpan.Days -gt 0) {
            "$($LastExecutionTimeSpan.Days + [Math]::Round($LastExecutionTimeSpan.Hours / 24, 2)) d"
        }
        elseif ($LastExecutionTimeSpan.Hours -gt 0) {
            "$($LastExecutionTimeSpan.Hours + [Math]::Round($LastExecutionTimeSpan.Minutes / 60, 2)) h"
        }
        elseif ($LastExecutionTimeSpan.Minutes -gt 0) {
            "$($LastExecutionTimeSpan.Minutes + [Math]::Round($LastExecutionTimeSpan.Seconds / 60, 2)) m"
        }
        elseif ($LastExecutionTimeSpan.Seconds -gt 0) {
            "$($LastExecutionTimeSpan.Seconds + [Math]::Round($LastExecutionTimeSpan.Milliseconds / 1000, 1)) s"
        }
        elseif ($LastExecutionTimeSpan.Milliseconds -gt 0) {
            "$([Math]::Round($LastExecutionTimeSpan.TotalMilliseconds, 0)) ms"
            # ms are 1/1000 of a sec so no point in extra decimal places here
        }
        else {
            "0 s"
        }

        if ($Success) {
            Write-Host -Object "[$LastExecutionShortTime] " -NoNewline -ForegroundColor Green
        }
        else {
            Write-Host -Object "! [$LastExecutionShortTime] " -NoNewline -ForegroundColor Red
        }

        ### User, removed
        $IsAdmin = (New-Object Security.Principal.WindowsPrincipal ([Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator)
        # Write-Host -Object "$($env:USERNAME)$(if ($IsAdmin){ '[A]' } else { '[U]' }) " -NoNewline -ForegroundColor DarkGreen
        # Write-Host -Object "$($env:USERNAME)" -NoNewline -ForegroundColor DarkGreen
        # Write-Host -Object " [" -NoNewline
        # if ($IsAdmin) { Write-Host -Object 'A' -NoNewline -F Red } else { Write-Host -Object 'U' -NoNewline }
        # Write-Host -Object "] " -NoNewline
        Write-Host "$($env:USERNAME)" -NoNewline -ForegroundColor DarkGreen
        Write-Host "[" -NoNewline
        if ($IsAdmin) { Write-Host 'A' -NoNewline -F Red } else { Write-Host -Object 'U' -NoNewline }
        Write-Host "] " -NoNewline

        # ### Path
        # $Drive = $pwd.Drive.Name
        # $Pwds = $pwd -split "\\" | Where-Object { -Not [String]::IsNullOrEmpty($_) }
        # $PwdPath = if ($Pwds.Count -gt 3) {
        #     $ParentFolder = Split-Path -Path (Split-Path -Path $pwd -Parent) -Leaf
        #     $CurrentFolder = Split-Path -Path $pwd -Leaf
        #     "..\$ParentFolder\$CurrentFolder"
        # go  # }
        # elseif ($Pwds.Count -eq 3) {
        #     $ParentFolder = Split-Path -Path (Split-Path -Path $pwd -Parent) -Leaf
        #     $CurrentFolder = Split-Path -Path $pwd -Leaf
        #     "$ParentFolder\$CurrentFolder"
        # }
        # elseif ($Pwds.Count -eq 2) {
        #     Split-Path -Path $pwd -Leaf
        # }
        # else { "" }
        # Write-Host -Object "$Drive`:\$PwdPath" -NoNewline

        Write-Host $pwd -NoNewline
        return "> "
    }
}

function PromptSlightlyBroken {
    # https://community.spiceworks.com/topic/1965997-custom-cmd-powershell-prompt

    # if ($MyInvocation.InvocationName -eq "PromptOverTheTop") {
    #     "`nWarning: Must dotsource '$($MyInvocation.MyCommand)' or it will not be applied to this session.`n`n   . $($MyInvocation.MyCommand)`n"
    # } else {
    if ($host.name -eq 'ConsoleHost') {
        # fff
        $Shell = $Host.UI.RawUI
        $Shell.BackgroundColor = "Black"
        $Shell.ForegroundColor = "White"
        $Shell.CursorSize = 10
    }
    # $Shell=$Host.UI.RawUI
    # $size=$Shell.BufferSize
    # $size.width=120
    # $size.height=3000
    # $Shell.BufferSize=$size
    # $size=$Shell.WindowSize
    # $size.width=120
    # $size.height=30
    # $Shell.WindowSize=$size
    # $Shell.BackgroundColor="Black"
    # $Shell.ForegroundColor="White"
    # $Shell.CursorSize=10
    # $Shell.WindowTitle="Console PowerShell"

    function global:Get-Uptime {
        $os = Get-WmiObject win32_operatingsystem
        $uptime = (Get-Date) - ($os.ConvertToDateTime($os.lastbootuptime))
        $days = $Uptime.Days ; if ($days -eq "1") { $days = "$days day" } else { $days = "$days days"}
        $hours = $Uptime.Hours ; if ($hours -eq "1") { $hours = "$hours hr" } else { $hours = "$hours hrs"}
        $minutes = $Uptime.Minutes ; if ($minutes -eq "1") { $minutes = "$minutes min" } else { $minutes = "$minutes mins"}
        $Display = "$days, $hours, $minutes"
        Write-Output $Display
    }
    function Spaces ($numspaces) { for ($i = 0; $i -lt $numspaces; $i++) { Write-Host " " -NoNewline } }

    # $MaximumHistoryCount=1024
    $IPAddress = @(Get-WmiObject Win32_NetworkAdapterConfiguration | Where-Object {$_.DefaultIpGateway})[0].IPAddress[0]
    $IPGateway = @(Get-WmiObject Win32_NetworkAdapterConfiguration | Where-Object {$_.DefaultIpGateway})[0].DefaultIPGateway[0]
    $UserDetails = "$env:UserDomain\$env:UserName (PS-HOME: $HOME)"
    $PSExecPolicy = Get-ExecutionPolicy
    $PSVersion = "$($PSVersionTable.PSVersion.Major).$($PSVersionTable.PSVersion.Minor) ($PSExecPolicy)"
    $ComputerAndLogon = "$($env:COMPUTERNAME)"
    $ComputerAndLogonSpaces = 28 - $ComputerAndLogon.Length
    Clear
    Write-Host "-----------------------------------------------------------------------------------------------------------------------" -ForegroundColor Green
    Write-Host "|    ComputerName:  " -nonewline -ForegroundColor Green; Write-Host $ComputerAndLogon -nonewline -ForegroundColor White ; Spaces $ComputerAndLogonSpaces ; Write-Host "UserName:" -nonewline -ForegroundColor Green ; Write-Host "  $UserDetails" -ForegroundColor White
    Write-Host "|    Logon Server:  " -nonewline -ForegroundColor Green; Write-Host $($env:LOGONSERVER)"`t`t`t`t" -nonewline -ForegroundColor White ; Write-Host "IP Address:`t" -nonewline -ForegroundColor Green ; Write-Host "`t$IPAddress ($IPGateway)" -ForegroundColor White
    Write-Host "|    Uptime:        " -nonewline -ForegroundColor Green; Write-Host "$(Get-Uptime)`t" -nonewline -ForegroundColor White; Write-Host "PS Version:`t" -nonewline -ForegroundColor Green ; Write-Host "`t$PSVersion" -ForegroundColor White
    Write-Host "-----------------------------------------------------------------------------------------------------------------------" -ForegroundColor Green
    # Write-Host "-----------------------------------------------------------------------------------------------------------------------" -ForegroundColor Green
    # Write-Host "|`tComputerName:`t" -nonewline -ForegroundColor Green; Write-Host $($env:COMPUTERNAME)"`t`t`t`t" -nonewline -ForegroundColor White ; Write-Host "UserName:`t$UserDetails" -ForegroundColor White
    # Write-Host "|`tLogon Server:`t" -nonewline -ForegroundColor Green; Write-Host $($env:LOGONSERVER)"`t`t`t`t" -nonewline -ForegroundColor White ; Write-Host "IP Address:`t$IPAddress ($IPGateway)" -ForegroundColor White
    # Write-Host "|`tUptime:`t`t" -nonewline -ForegroundColor Green; Write-Host "$(Get-Uptime)`t" -nonewline -ForegroundColor White; Write-Host "PS Version:`t$PSVersion" -ForegroundColor White
    # Write-Host "-----------------------------------------------------------------------------------------------------------------------" -ForegroundColor Green
    function global:admin {
        $Elevated = ""
        $currentPrincipal = New-Object Security.Principal.WindowsPrincipal( [Security.Principal.WindowsIdentity]::GetCurrent() )
        if ($currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) -eq $true) { $Elevated = "Administrator: " }
        $Host.UI.RawUI.WindowTitle = "$Elevated$TitleVer"
    }
    admin
    Set-Location C:\

    function global:prompt{
        $br = "`n"
        Write-Host "[" -noNewLine
        Write-Host $(Get-date) -ForegroundColor Green -noNewLine
        Write-Host "] " -noNewLine
        Write-Host "[" -noNewLine
        Write-Host "$env:username" -Foregroundcolor Red -noNewLine
        Write-Host "] " -noNewLine
        Write-Host "[" -noNewLine
        Write-Host $($(Get-Location).Path.replace($home,"~")) -ForegroundColor Yellow -noNewLine
        Write-Host $(if ($nestedpromptlevel -ge 1) { '>>' }) -noNewLine
        Write-Host "] "
        return "> "
    }
}

Set-Alias p0 PromptDefault
Set-Alias p-default PromptDefault
Set-Alias p-timer PromptUserAndExecutionTimer   # Using this as my console default
Set-Alias p-short PromptShortenPath
Set-Alias p-trunc PromptTruncatedPaths 
Set-Alias p-uptime PromptTimeUptime
Set-Alias p-broken PromptSlightlyBroken

# View current prompt with: (get-item function:prompt).scriptblock   or   cat function:\prompt

Upvotes: 0

mklement0
mklement0

Reputation: 439183

Scepticalist's helpful answer provides an effective solution for activating your prompt function at the time of import.

The approach in your question for activating the function on demand, by later dot-sourcing a function in which the prompt function is nested, fundamentally cannot work as written if that function is imported from a module, as explained next; for a solution, see the bottom section.

As for what you tried:

. prompt

This doesn't dot-source the definition of function prompt, it runs the function in the sourcing scope.

  • In effect, it (pointlessly) prints (once, as output) what the prompt string should be and makes the function-local variables linger in the caller's scope.

Therefore, by nesting the prompt function definition inside the PromptShortenPath function, dot-sourcing that defines the prompt function in the caller's scope automatically, along with the shorten-path function[1]

  • If your PromptShortenPath function is defined outside of a module, dot-sourcing it means that the sourcing scope is the (non-module) caller's current scope, which defines the nested functions there, and with the appearance of a new prompt function, the interactive prompt string changes, as intended.

  • By contrast, if your PromptShortenPath function is defined inside a module, dot-sourcing it means that the sourcing scope is the module of origin, which means that the caller's current scope is unaffected, and never sees the nested shorten-path and prompt functions - thus, the interactive prompt string does not change.

    • This bears repeating: dot-sourcing a function (as opposed to a script) runs the function in the current scope of the scope domain of origin rather than the caller's current scope; that is, dot-sourcing a function from a module invariably runs it in that module's current scope, which is distinct from and unrelated to the caller's scope (unless the caller happens to be the top-level scope inside the same module).

By contrast, Scepticalist's solution, by making shorten-path and prompt functions top-level functions of the module, implicitly (exports and) imports them both into the caller's scope with Import-Module, and, again, the appearance of a new prompt function in the caller's scope changes the interactive prompt string, albeit at the time of importing.


Alternative approach that also works with modules:

The simplest solution is to define the nested function with scope specifier global:, which directly defines it in the global scope, irrespective of what scope contains the definition.

As a beneficial side effect, you then no longer have to dot-source the prompt-activating function on invocation.

Note that the solution below embeds helper function shorten-path in the global:prompt function to ensure its availability to the latter; the alternative would be to define shorten-path as global:shorten-path too, but there is no need to clutter the global scope with helper functions, especially given that name collisions can occur.

# Use a dynamic module to simulate importing the `Set-Prompt` function
# from a (regular, persisted) module.
$null = New-Module {

  function Set-Prompt {

    # Note the `global:` prefix.
    Function global:prompt {
      # Note the *embedded* definition of helper function shorten-path,
      # which makes it available to the enclosing function only and avoids
      # the need to make the helper function global too.
      Function shorten-path([string] $path) {
        $loc = $path.Replace($HOME, '~')
        # remove prefix for UNC paths
        $loc = $loc -replace '^[^:]+::', ''
        # make path shorter like tabs in Vim,
        # handle paths starting with \\ and . correctly
        return ($loc -replace '\\(\.?)([^\\])[^\\]*(?=\\)', '\$1$2')
      }

      # our theme
      $cdelim = [ConsoleColor]::DarkCyan
      $chost = [ConsoleColor]::Green
      $cloc = [ConsoleColor]::Cyan

      Write-Host "$([char]0x0A7) " -n -f $cloc
      Write-Host ([net.dns]::GetHostName()) -n -f $chost
      Write-Host ' {' -n -f $cdelim
      Write-Host (shorten-path (pwd).Path) -n -f $cloc
      Write-Host '}' -n -f $cdelim
      return ' '
    }

  }

} 

# Now that Set-Prompt is imported, invoke it as you would
# any function, and the embedded `prompt` function will take effect.
Set-Prompt

[1] Note that while shorten-path follows PowerShell's noun-verb naming convention in principle, shorten is not on the list of approved verbs.

Upvotes: 2

Scepticalist
Scepticalist

Reputation: 3923

If you remove the outer function and save as modulename.psm1 in a folder by the same name within a module path:

Function shorten-path([string] $path) {
    $loc = $path.Replace($HOME, '~')
    # remove prefix for UNC paths
    $loc = $loc -replace '^[^:]+::', ''
    # make path shorter like tabs in Vim,
    # handle paths starting with \\ and . correctly
    return ($loc -replace '\\(\.?)([^\\])[^\\]*(?=\\)','\$1$2')
}
Function prompt {
    # our theme
    $cdelim = [ConsoleColor]::DarkCyan
    $chost = [ConsoleColor]::Green
    $cloc = [ConsoleColor]::Cyan

    write-host "$([char]0x0A7) " -n -f $cloc
    write-host ([net.dns]::GetHostName()) -n -f $chost
    write-host ' {' -n -f $cdelim
    write-host (shorten-path (pwd).Path) -n -f $cloc
    write-host '}' -n -f $cdelim
    return ' '
}

Now just:

Import-Module modulename

Note that the new prompt now takes effect upon importing the function

Upvotes: 2

Related Questions