Published in Articles on by Michiel van Oosterhout ~ 8 min read

This will be the last article in a series of articles aiming to reproduce the custom Bash prompt in Windows Command Processor. Previously, we created the Prompt extension to make it easy to create a colorful, segmented prompt mockup. In this article we'll use that to set the actual prompt.

The Windows Command Processor prompt

For Windows Command Processor, the prompt can be changed with the prompt command. Certain character sequences in the first parameter to prompt will be replaced, for example $p with the current drive and directory path, $g with >, and $s with a space.

Unlike Bash, which evaluates the PS1 prompt variable everytime the prompt is rendered, Windows Command Processor requires us to set the prompt explicitly everytime the context changes if we want our prompt to be dynamic (e.g. contain contextual information).

The SetPrompt extension

We will first create a function that can be called to change the prompt if the CMD_PROMPT environment variable is set. This function can be called by any extension that provides contextual information for the prompt, whenever the relevant context changes.

# Ensure the extension's directory exists
$path = "$Env:LOCALAPPDATA\Commando\SetPrompt"
$_ = New-Item $path -Force -ItemType Directory

# Declare the filter script contents
$script = @"
@echo off

for /f %%p in ('call %CMD%\GetCp\getcp.cmd') do set "codepage=%%p"
chcp 65001 >NUL

if not "%CMD_PROMPT%"=="" (
    for /f "tokens=* delims=" %%p in ('call %CMD%\Prompt\prompt.cmd %CMD_PROMPT%') do (
        prompt %%p`$s
    )
)

chcp %codepage% >NUL
set "codepage="
"@

# Create the Windows Command Processor script
$path = "$path\set_prompt.cmd"
Set-Content -Path $path -Value $script

# Ensure proper line endings
((Get-Content $path) -join "`r`n") + "`r`n" | Set-Content -NoNewline $path

Remember from an earlier article that we need to correctly interpret the bytes in the prompt.cmd script, so the set_prompt.cmd script temporarily sets the code page to 65001 before restoring the original code page.

Now we can just call %CMD%\SetPrompt\set_prompt.cmd and the prompt will be changed, but only if the CMD_PROMPT environment variable is set (e.g. setx CMD_PROMPT "bW ~/Desktop/test / Y- main").

Setting a static prompt in Windows Command Processor
Setting a static prompt in Windows Command Processor

The prompt should be set immediately when Windows Command Processor starts. For this we can use the extension's Extend.cmd script:

# Ensure the extension's directory exists
$path = "$Env:LOCALAPPDATA\Commando\SetPrompt"
$_ = New-Item $path -Force -ItemType Directory

# Declare the script contents
$script = @"
@echo off

call %CMD%\SetPrompt\set_prompt.cmd
"@

# Create the Windows Command Processor script
$path = "$path\Extend.cmd"
Set-Content -Path $path -Value $script

# Ensure proper line endings
((Get-Content $path) -join "`r`n") + "`r`n" | Set-Content -NoNewline $path

The next set of extensions will use the set_prompt function to set the prompt whenever some context changes.

Extensions for contextual information

A custom prompt is valuable when it shows contextual information, e.g. information that is updated as you use commands in Windows Command Processor, for example changing the current working directory, or checking out a different Git branch. For this we need some extensions that filter commands like cd and git to set some environment variables that we can then use in our custom prompt.

The GitAt extensions

First we'll create a function that can get the current Git branch, tag, or commit ID. The Windows PowerShell script below creates a new extension called GitAt.

# Ensure the directory exists
$path = "$Env:LOCALAPPDATA\Commando\GitAt"
$_ = New-Item $path -Force -ItemType Directory

# Declare the script contents
$script = @'
@echo off
setlocal enabledelayedexpansion

rem Check that current directory is in a Git repository
git rev-parse --is-inside-work-tree >NUL 2>&1

if not errorlevel 1 (
    rem Get current branch
    for /f %%b in ('git branch --show-current') do set "result=%%b"

    if "!result!"=="" (
        rem Get first tag instead
        for /f %%t in ('git --no-pager tag --points-at HEAD') do (
            if "!result!"=="" (
                set "result=%%t"
            )
        )

        if not "!result!"=="" (
            set "result=[!result!]"
        )
    )

    if "!result!"=="" (
        rem Get commit ID instead
        for /f %%c in ('git rev-parse --short HEAD') do set "result=%%c"
    )

    if not "!result!"=="" (
        rem Echo the result without a newline
        <NUL set /p _=!result!
    )
)

endlocal
'@

# Create the Windows Command Processor script
$path = "$path\git_at.cmd"
Set-Content -Path $path -Value $script

# Ensure proper line endings
((Get-Content $path) -join "`r`n") + "`r`n" | Set-Content -NoNewline $path

The extension exports the git_at function, which checks if the current directory is in a Git repository, and, if so, echos the name of the current branch, or the name of the current tag when not on a branch, or the current commit ID. This function can be called like this: call %CMD%\GitAt\git_at.cmd.

The GitAt.Prompt extension

We can create another extension that will use the git_at function to set PROMPT_GIT_AT every time the current working directory is changed, or every time the git command is executed.

# Ensure the extension's directory exists
$path = "$Env:LOCALAPPDATA\Commando\GitAt.Prompt"
$_ = New-Item $path -Force -ItemType Directory

# Declare the filter script contents
$script = @"
@echo off
setlocal enabledelayedexpansion

for /f "delims=; tokens=1,*" %%a in ("%~1") do (
    if "%%~b"=="" (set next=%%~a) else (set next=call %%~a "%%b")
)

set params=0

shift
:loop_params
set next=%next% %1 && shift && if not "%~1"=="" set /a params+=1 && goto loop_params

endlocal && %next%

set "PROMPT_GIT_AT="
for /f %%s in ('call %CMD%\GitAt\git_at.cmd') do set "PROMPT_GIT_AT=%%s"

call %CMD%\SetPrompt\set_prompt.cmd
"@

# Create the filter scripts
@("cd", "chdir", "pushd", "git") | ForEach-Object {
    $filePath = "$path\$_.filter.cmd"
    Set-Content -Path $filePath -Value $script

    # Ensure proper line endings
    ((Get-Content $filePath) -join "`n") + "`n" | Set-Content -NoNewline $filePath
}

With this extension in place, the PROMPT_GIT_AT variable is set, and the prompt updated, immediately after the current working directory is changed, or after a git command. But when Windows Command Processor is started in a directory that is inside a Git repository, then PROMPT_GIT_AT is not set yet.

To fix this, the extension should also set PROMPT_GIT_AT as part of its installation.

# Ensure the extension's directory exists
$path = "$Env:LOCALAPPDATA\Commando\GitAt.Prompt"
$_ = New-Item $path -Force -ItemType Directory

# Declare the script contents
$script = @"
@echo off

for /f %%s in ('call %CMD%\GitAt\git_at.cmd') do set "PROMPT_GIT_AT=%%s"
"@

# Create the Windows Command Processor script
$path = "$path\Extend.cmd"
Set-Content -Path $path -Value $script

# Ensure proper line endings
((Get-Content $path) -join "`r`n") + "`r`n" | Set-Content -NoNewline $path

With this extension in place, the PROMPT_GIT_AT variable is always available for use in a custom prompt, and the prompt is updated immediately afterwards.

The Dir.Prompt extension

Because we also want to display the shortened current working directory in the prompt, we'll create one more extension. This one will set PROMPT_DIR every time the current working directory is changed, and immediately update the prompt.

# Ensure the directory exists
$path = "$Env:LOCALAPPDATA\Commando\Dir.Prompt"
$_ = New-Item $path -Force -ItemType Directory

# Declare the script contents
$script = @"
@echo off
setlocal enabledelayedexpansion

for /f "delims=; tokens=1,*" %%a in ("%~1") do (
    if "%%~b"=="" (set next=%%~a) else (set next=call %%~a "%%b")
)

set params=0

shift
:loop_params
set next=%next% %1 && shift && if not "%~1"=="" set /a params+=1 && goto loop_params

endlocal && %next%

setlocal enabledelayedexpansion

for /f "tokens=*" %%l in ('echo %CD%') do (
    set "line=%%l"
)

endlocal && set "PROMPT_DIR=%line:$Env:USERPROFILE=~%"

call %CMD%\SetPrompt\set_prompt.cmd
"@

# Create the filter scripts
@("cd", "chdir", "pushd") | ForEach-Object {
    $filePath = "$path\$_.filter.cmd"
    Set-Content -Path $filePath -Value $script

    # Ensure proper line endings
    ((Get-Content $filePath) -join "`n") + "`n" | Set-Content -NoNewline $filePath
}

And just as with the GitAt.Prompt extension, the Dir.Prompt extension should also set PROMPT_DIR as part of its installation.

# Ensure the extension's directory exists
$path = "$Env:LOCALAPPDATA\Commando\Dir.Prompt"
$_ = New-Item $path -Force -ItemType Directory

# Declare the script contents
$script = @"
@echo off
setlocal enabledelayedexpansion

for /f "tokens=*" %%l in ('echo %CD%') do (
    set "line=%%l"
    set "line=!line:$Env:USERPROFILE=~!"
)

endlocal && set "PROMPT_DIR=%line%"
"@

# Create the Windows Command Processor script
$path = "$path\Extend.cmd"
Set-Content -Path $path -Value $script

# Ensure proper line endings
((Get-Content $path) -join "`r`n") + "`r`n" | Set-Content -NoNewline $path

With this extension in place, the PROMPT_DIR variable is always available for use in a custom prompt, and the prompt is updated immediately afterwards.

Configuring a dynamic prompt

With everything in place, we can now update CMD_PROMPT to a dynamic prompt:

setx CMD_PROMPT "bW ^%PROMPT_DIR^% / Y- ^%PROMPT_GIT_AT^%"
Dynamic prompt in Windows Command Processor
Dynamic prompt in Windows Command Processor

Notice that we delay expansion of the PROMPT_ variables by 'escaping' the %-signs. This way the variables will be expanded when the set_prompt function is called.

Conclusion

It takes considerably more effort to achieve a dynamic prompt with contextual information in Windows Command Processor. And while it may look pretty on the screenshots, it add noticable latency to the CLI. Finally, due to the myriad ways of changing the context, the prompt may not always contain correct information (for example after mkdir test && cd test the PROMPT_DIR variable is not updated). We may revisit the implementation in the future to see if these problems can be fixed.