Verbose Message ANSI Alternatives
If you've been following my work for a while, you know I am a big fan of using Write-Verbose
. I find the verbose output very helpful when I am writing a PowerShell function. The verbose output writes to a separate stream and is identifiable in the console.
VERBOSE: [14:17:41.9281665 BEGIN ] Running under PowerShell version 7.4.3
Lately, I've been experimenting with alternatives. Instead of verbose output that is intermingled with the output and that scrolls with the output, I'd like to have a message that is displayed in a specific area, with each message overwriting the previous one. I don't always care about the verbose output after the command finishes. Maybe I only need to see the messages while the code is executing.
One way I can accomplish this is by leveraging ANSI escape sequences. You are probably familiar with them from $PSStyle
to add foreground and background color or styles. But there are also escape sequences that allow you to move the cursor around the console window. You can find this documented on Wikipedia.
ESC[nA
Moves the cursor up by n linesESC[nB
Moves the cursor down by n linesESC[nC
Moves the cursor right by n charactersESC[nD
Moves the cursor left by n charactersESC[2K
Clears the lineESC[2J
Clears the screen
The ESC
character in PowerShell 7 is `e
or you can use [char]27
.
"$([char]27)[2J"
The letters in the escape sequence like
A
orJ
are case-sensitive.
For my demonstrations, I am using PowerShell 7 running in Windows Terminal. I expect most of you are using Windows Terminal. This app automatically enables ANSI support. If you are using a different console and not getting the results you expect, you might need to configure output encoding.
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
Here's a simple example of what I want to achieve. You can copy and paste this code into a PowerShell 7 session.
1..10 | ForEach-Object {
Write-Host "`e[2KWriting message $_"
Start-Sleep -Seconds 1
Write-Host "`e[1A" -NoNewline
} -End { "`e[2K" }
You'll see the message 'Write message 1to
Write message 10` displayed on the same line, overwriting the previous message. When the loop finishes, the line is cleared.
You need to be careful about using NoNewLine
or even if you are using Write-Host
. Consider this list of names.
$Names = "Alice","Bob","Charlie","David","Eve"
I might try code like this to stream the names to the console, sharing the same line.
$Names | Foreach-Object { "`e[1A$($_)"; Start-Sleep -Seconds 1 }
If you try this, and I hope you do, you'll see the name written in front of your prompt. That won't work.
You might try this which first writes an empty line.
$Names | ForEach-Object -Begin { ' ' } -Process { "`e[1A$($_)"; Start-Sleep -Seconds 1 }
Now all of the names are written on the same line, overwriting the previous name. However, if the previous name was longer than the current name, you'll see leftover characters. Let's fix this with ANSI escape sequences.
$Names | ForEach-Object -Begin { "`e[1B" } -Process { "`e[1A`e[2K$($_)"; Start-Sleep -Seconds 1 } -End {
"`e[1A`e[2K"
}
Much better. This is the kind of thing I have in mind for my code.
$Names | ForEach-Object -Begin { "`e[1B" } -Process { "`e[1A`e[2K`e[93;1;3mSTATUS: Processing $($_)`e[0m"; Start-Sleep -Seconds 1 } -End {
"`e[1A`e[2K"
}

Figuring out a prototype gave me experience and a better understanding of how to use ANSI escape sequences. Let's apply these principles to some PowerShell code.
Write-Host
My goal is to display informational messages to the user. Because the messages are not part of the function's output, I don't want to write them to the pipeline. I can use Write-Host
which will display them in the console but not write them to the pipeline.
Let's move on to another prototype.
$strings = 'I am starting', 'processing data', 'finalizing output', 'formatting', 'cleaning up', 'done'
foreach ($string in $strings) {
Write-Host "`e[1A`e[2K`e[93;1;3mSTATUS: $string`e[0m"
Start-Sleep -Seconds 1
}
This combines multiple ANSI escape sequences into a single line. The status message will be displayed in yellow and italicized.
Where this gets messy is when you have to incorporate pipeline output. This example works just fine because there is no pipeline output until after all of the messages have been displayed.
$sb = {
$strings = 'I am starting', 'processing data', 'finalizing output', 'formatting', 'cleaning up', 'done'
Write-Host "`e[1B"
$r=@()
foreach ($string in $strings) {
Write-Host "`e[2K`e[93;1;3mSTATUS: $string`e[0m"
$r+=Get-Random
Start-Sleep -Seconds 1
"`e[2A"
}
"`n"
$r
}
But typically we want to write to the pipeline, especially if the command is feeding another command.
A solution that I've been working with is to use an ANSI escape sequence to write a string at a specific location on the console.
Write-Host "`e[1;50HHello World"`
This will write Hello World
on row 1, column 50. For my purposes, I am going to write my status messages at the bottom of the screen.
$row = $host.UI.RawUI.WindowSize.Height - 1
Write-Host "`e[$row;1HHello World"
Let's incorporate the other ANSI escape sequences.
'Hello world', 'How are you?', 'Good-bye world' | ForEach-Object {
Write-Host "`e[$row;1H`e[2K$_ [$row]"
Start-Sleep -Seconds 1
}

With this in mind, I'm going to continue prototyping with a script block.
$sb = {
Clear-Host
$row = $host.UI.RawUI.WindowSize.Height - 1
#get current cursor position
$pos = $host.UI.RawUI.CursorPosition
$strings = 'I am starting', 'thinking about it', 'processing data', 'finalizing output',
'formatting', 'procrastinating', 'cleaning up', 'done'
foreach ($string in $strings) {
Write-Host "`e[$Row;1H`e[2K`e[93;1;3mSTATUS: $string`e[0m"
$r = Get-Random
$host.UI.RawUI.CursorPosition = $pos
[PSCustomObject]@{
Random = $r
User = $env:USERNAME
Computername = $env:COMPUTERNAME
Version = $PSVersionTable.PSVersion
Time = (Get-Date).TimeOfDay
}
#save cursor position
$pos = $host.UI.RawUI.CursorPosition
Start-Sleep -Seconds 1
}
#clear the last Status message
Write-Host "`e[$Row;10H`e[2K`e[0m"
}

When you try this code, you'll see that the status messages will eventually scroll up and I lose the message at the bottom of the screen. To resolve this, conceptually I knew I had to detect when I was approaching the bottom of the screen. When I did and the output scrolled up, I needed to find the line of the scrolled status message and delete it. This took some trial and error but I came up with this script block to test
$sb = {
Clear-Host
$row = $host.UI.RawUI.WindowSize.Height - 1
#get current cursor position
$pos = $host.UI.RawUI.CursorPosition
$up = 0
$strings = 'I am starting', 'thinking about it', 'processing data', 'finalizing output',
'formatting', 'procrastinating', 'cleaning up', 'done'
foreach ($string in $strings) {
Write-Host "`e[$Row;1H`e[2K`e[93;1;3mSTATUS: $string`e[0m"
$r = Get-Random
$host.UI.RawUI.CursorPosition = $pos
$PreY = $host.UI.RawUI.CursorPosition.Y
[PSCustomObject]@{
Random = $r
User = $env:USERNAME
Computername = $env:COMPUTERNAME
Version = $PSVersionTable.PSVersion
Position = $Host.UI.RawUI.CursorPosition
Time = (Get-Date).TimeOfDay
}
#save cursor position after writing the output
$pos = $host.UI.RawUI.CursorPosition
$PostY = $host.UI.RawUI.CursorPosition.Y
#calculate the number of lines moved=, ignoring the
#first object which started at the top of the screen
if ($up -eq 0 -AND $PreY -gt 0) {
$up = $PostY - $PreY
}
If ($pos.Y -ge $Row - 2) {
#We are at the bottom of the window
#Move up and wipe the last status message
Write-Host "`e[$($up+1)A`e[2K"
#need to re-write the status message
Write-Host "`e[$Row;1H`e[2K`e[93;1;3mSTATUS: $string`e[0m"
}
Start-Sleep -Milliseconds 1500
}
#clear the last Status message
Write-Host "`e[$Row;10H`e[2K`e[0m"
}
The code works best with a clear screen. I trust my code comments explain the code. Now, all of my messages remain at the bottom of the screen and the scrolled messages are removed.

Function Integration
With this prototype in hand, let's turn to a PowerShell function. I wrote a simple function to display module details. The function includes typical verbose output.
#requires -version 7.4
#requires -module Microsoft.PowerShell.PSResourceGet
Function Get-ModuleInfo {
[cmdletbinding()]
[OutputType('psModuleInfo')]
Param(
[Parameter(
Position = 0,
ValueFromPipeline,
ValueFromPipelineByPropertyName,
HelpMessage = 'Specify the module name to get information for'
)]
[ValidateNotNullOrEmpty()]
[SupportsWildcards()]
[string]$Name = '*'
)
Begin {
Write-Verbose "[$((Get-Date).TimeOfDay) BEGIN ] Starting $($MyInvocation.MyCommand)"
Write-Verbose "[$((Get-Date).TimeOfDay) BEGIN ] Running under PowerShell version $($PSVersionTable.PSVersion)"
} #begin
Process {
$Modules = Get-Module -Name $Name
foreach ($Module in $Modules) {
Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Processing $Module"
Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Getting module size"
$measure = Get-ChildItem $Module.ModuleBase -Recurse -File | Measure-Object -Property Length -Sum
Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Getting PSGallery information for $($Module.Name)"
Try {
$online = Find-PSResource -Name $Module.Name -Repository PSGallery -ErrorAction Stop
$LastUpdate = $online.PublishedDate
$PSGalleryVersion = $online.Version
}
Catch {
$LastUpdate = $Null
$PSGalleryVersion = $Null
}
if ($Module.ModuleBase -match $env:Username) {
$ModuleScope = 'User'
}
else {
$ModuleScope = 'AllUsers'
}
[PSCustomObject]@{
PSTypeName = 'psModuleInfo'
Name = $Module.Name
Author = $Module.Author
Version = $Module.Version
PSGalleryVersion = $PSGalleryVersion
PSGalleryUpdate = $LastUpdate
Installed = (Get-Item $Module.ModuleBase).CreationTime
Scope = $moduleScope
PSEdition = $Module.CompatiblePSEditions
Commands = $Module.ExportedCommands.Count
Files = $measure.Count
Size = $measure.sum
Tags = $module.Tags
Path = $Module.ModuleBase
ComputerName = [System.Environment]::MachineName
}
} #foreach
} #process
End {
Write-Verbose "[$((Get-Date).TimeOfDay) END ] Ending $($MyInvocation.MyCommand)"
} #end
} #close Get-ModuleInfo
The verbose output is in line with the function output.
PS C:\> Get-moduleInfo PSScriptTools -Verbose
VERBOSE: [15:43:09.8723944 BEGIN ] Starting Get-ModuleInfo
VERBOSE: [15:43:09.8728926 BEGIN ] Running under PowerShell version 7.4.3
VERBOSE: [15:43:09.8733148 PROCESS] Processing psscripttools
VERBOSE: [15:43:09.8737630 PROCESS] Getting module size
VERBOSE: [15:43:09.8838456 PROCESS] Getting PSGallery information for psscripttools
Name : psscripttools
Author : Jeff Hicks
Version : 2.49.0
PSGalleryVersion : 2.49.0
PSGalleryUpdate : 6/6/2024 5:46:14 PM
Installed : 6/6/2024 2:03:42 PM
Scope : AllUsers
PSEdition : {Desktop, Core}
Commands : 173
Files : 296
Size : 17466688
Tags : {scripting, logging, functions, filename…}
Path : C:\Program Files\WindowsPowerShell\Modules\psscripttools\2.49.0
ComputerName : PROSPERO
VERBOSE: [15:43:10.3624983 END ] Ending Get-ModuleInfo
PS C:\>
I want to display the verbose messages at the bottom of the screen as custom status messages. I wrote a private helper function to handle this.
Function Status {
Param([string]$Message)
#$Row is found in the parent scope
Write-Host "`e[$Row;1H`e[2K`e[93;1;3mSTATUS: $Message`e[0m"
Start-Sleep -Milliseconds 400
}`
My demo script includes Start-Sleep
statements to help illustrate the status messages.
#requires -version 7.4
#requires -module Microsoft.PowerShell.PSResourceGet
#This version uses ANSI-displayed messages
#The function has artificial pauses using Start-Sleep to simulate processing long
#running code and to allow Status messages to be displayed
Function Get-ModuleInfo {
[cmdletbinding()]
[OutputType('psModuleInfo')]
Param(
[Parameter(
Position = 0,
ValueFromPipeline,
ValueFromPipelineByPropertyName,
HelpMessage = 'Specify the module name to get information for'
)]
[ValidateNotNullOrEmpty()]
[SupportsWildcards()]
[string]$Name = '*'
)
Begin {
#define a private helper function to write the status messages
Function Status {
Param([string]$Message)
#$Row is found in the parent scope
Write-Host "`e[$Row;1H`e[2K`e[93;1;3mSTATUS: $Message`e[0m"
Start-Sleep -Milliseconds 250
}
Clear-Host
#get current cursor position
$pos = $host.UI.RawUI.CursorPosition
$Row = $host.UI.RawUI.WindowSize.Height - 1
Status "[$((Get-Date).TimeOfDay) BEGIN ] Starting $($MyInvocation.MyCommand)"
Status "[$((Get-Date).TimeOfDay) BEGIN ] Running under PowerShell version $($PSVersionTable.PSVersion)"
} #begin
Process {
$Modules = Get-Module -Name $Name
foreach ($Module in $Modules) {
#using variables for the status messages in the Process block in case
#a message has to be re-written after scrolling up
$msg = "[$((Get-Date).TimeOfDay) PROCESS] Processing $($Module.Name) version $($Module.Version)"
Status $msg
$msg = "[$((Get-Date).TimeOfDay) PROCESS] Getting $($Module.Name) module size"
Status $msg
$measure = Get-ChildItem $Module.ModuleBase -Recurse -File | Measure-Object -Property Length -Sum
$msg = "[$((Get-Date).TimeOfDay) PROCESS] Getting PSGallery information for $($Module.Name)"
Status $msg
Try {
$online = Find-PSResource -Name $Module.Name -Repository PSGallery -ErrorAction Stop
$LastUpdate = $online.PublishedDate
$PSGalleryVersion = $online.Version
}
Catch {
$LastUpdate = $Null
$PSGalleryVersion = $Null
}
if ($Module.ModuleBase -match $env:Username) {
$ModuleScope = 'User'
}
else {
$ModuleScope = 'AllUsers'
}
#Wipe the last status message so it doesn't get scrolled up
Write-host "`e[$Row;1H`e[2K"
#set the cursor position to write the output
$host.UI.RawUI.CursorPosition = $pos
[PSCustomObject]@{
PSTypeName = 'psModuleInfo'
Name = $Module.Name
Author = $Module.Author
Version = $Module.Version
PSGalleryVersion = $PSGalleryVersion
PSGalleryUpdate = $LastUpdate
Installed = (Get-Item $Module.ModuleBase).CreationTime
Scope = $moduleScope
PSEdition = $Module.CompatiblePSEditions
Commands = $Module.ExportedCommands.Count
Files = $measure.Count
Size = $measure.sum
Tags = $module.Tags
Path = $Module.ModuleBase
ComputerName = [System.Environment]::MachineName
}
#save cursor position after writing the output
$pos = $host.UI.RawUI.CursorPosition
#Start-Sleep -Milliseconds 500
} #foreach
} #process
End {
Status "[$((Get-Date).TimeOfDay) END ] Ending $($MyInvocation.MyCommand)"
#clear the last Status message
Write-Host "`e[$Row;1H`e[2K`e[0m"
} #end
} #close Get-ModuleInfo
When I run the command Get-ModuleInfo
, I get my status messages at the bottom of the screen and the output scrolls in the console.

I realized while working on this function that I don't have to figure out how far up to move in order to remove a status message so it doesn't scroll. I'll remove the message regardless before I write the object to the pipeline.
Write-host "`e[$Row;1H`e[2K"
The status message is constantly overwritten anyway so this solves my scrolling problem.
Summary
What I've demonstrated today is more proof-of-concept than production-ready code. I am hoping it will inspire you to get creative with your PowerShell scripting. By the way, if you run this code under a transcript, you'll see all the status messages, but they will include the ANSI escape characters. If you view the file in Notepad you'll see the escape characters. If you view the file contents in PowerShell, the escape sequences will be processed so you'll probably only see the last last status message.
I want to explore this topic a little more but I'll save that for next time. In the mean time, I hope you'll try out my examples and leave me your questions or comments.