Building your own Terminal Status Bar in PowerShell

Building your own Terminal Status Bar in PowerShell

Β·

5 min read

This is what we are going to make in this post. It's running on the Windows Terminal and PowerShell.

Status bar demo

Motivation

Prompt customization

When I saw some prompt customizations for PowerShell that were showing time and CPU usage, I thought it would be cool if we could live-update the information like a status bar (e.g. tmux status line). However, rewriting the host buffer asynchronously is not that easy as users always interact with the console and commands also output the results to the host display.

After some attempts, I found a place where we could show the live information without being affected by the user's interaction: The Title Bar.

In this post, I'll show you how to make a status bar in PowerShell using the terminal title space.

DynamicTitle Module

In PowerShell, the console title can be set in this way:

$host.UI.RawUI.WindowTitle = 'This is a title'

To update the title in parallel to the user's interaction, you have to execute this code on a background thread and regularly update the text at a certain interval time. That requires a bit of work so I made a PowerShell module called DynamicTitle for this purpose.

Install the module with this command:

Install-Module -Name DynamicTitle -Scope CurrentUser

then you can show a live updating clock with this one-liner:

Start-DTTitle {Get-Date}

The ScriptBlock passed by a parameter runs on a background thread periodically and the module sets the title to the string returned by the ScriptBlock.

Do you feel the excitement🀩? Now, you can show any information that is accessible from PowerShell without blocking or being blocked by the main thread!

🌿 Git Status

Git status

Let's think about showing the git branch name and the number of modified files. You first need to know the current directory that the terminal is working in. Since the ScriptBlock passed to Start-DTTitle runs on another thread (Runspace), it has its own current directory. You need to pass the current directory from the main thread to the DynamicTitle thread.

The module provides a few types of Job objects to transfer the data between threads in a thread-safe way. PromptCallback job is a function that is called right before the PowerShell's Prompt function which is usually the timing where the current directory changes. You can get the job result by a thread-safe function, Get-DTJobLatestOutput.

$promptCallback = Start-DTJobPromptCallback {
    (Get-Location).Path
}
Start-DTTitle {
    param($promptCallback)
    $currentDir = Get-DTJobLatestOutput $promptCallback
    $currentDir
} -ArgumentList $promptCallback

Now what we need is just to return the git status string:

Start-DTTitle {
    param ($promptCallback)
    $currentDir = Get-DTJobLatestOutput $promptCallback
    if (-not $currentDir) {
        return
    }

    Set-Location $currentDir
    $branch = git branch --show-current
    if ($LastExitCode -ne 0) {
        # not a git repository
        return 'πŸ“‚{0}' -f $currentDir
    }
    if (-not $branch) {
        $branch = '❔'
    }

    $gitStatusLines = git --no-optional-locks status -s
    $modifiedCount = 0
    $unversionedCount = 0
    foreach ($line in $gitStatusLines) {
        $type = $line.Substring(0, 2)
        if (($type -eq ' M') -or ($type -eq ' R')) {
            $modifiedCount++
        }
        elseif ($type -eq '??') {
            $unversionedCount++
        }
    }

    $currentDirName = Split-Path $currentDir -Leaf
    'πŸ“‚{0} 🌿[{1}] ✏️{2}❔{3}' -f $currentDirName, $branch, $modifiedCount, $unversionedCount
} -ArgumentList $promptCallback

Remember that it's async. Even if your git repo is huge and takes a long time to get the status, it never slows down the prompt.

⌚ Command Execution Timer

Command execution timer

Let's take advantage of being asynchronous a little more. With the prompt string, it was only possible to show how long a command took to finish. On the other hand, with a background thread, it's possible to show an actual timer while a command is running.

CommandPreExecutionCallback job is a function that is called right before a command runs. You can use this job to capture the command start timing. PromptCallback job can be used to get the command finish timing.

$commandStartJob = Start-DTJobCommandPreExecutionCallback {
    param($command)
    (Get-Date), $command
}

$commandEndJob = Start-DTJobPromptCallback {
    Get-Date
}

Start-DTTitle {
    param($commandStartJob, $commandEndJob)
    $commandStartDate, $command = Get-DTJobLatestOutput $commandStartJob
    $commandEndDate = Get-DTJobLatestOutput $commandEndJob

    if ($null -ne $commandStartDate) {
        if (($null -eq $commandEndDate) -or ($commandEndDate -lt $commandStartDate)) {
            $commandDuration = (Get-Date) - $commandStartDate
            $isCommandRunning = $true
        } else {
            $commandDuration = $commandEndDate - $commandStartDate
        }
    }

    if ($command) {
        $command = $command.Split()[0]
    }

    $status = '🟒'
    if ($commandDuration.TotalSeconds -gt 1) {
        $commandSegment = '[{0}]-⌚{1}' -f $command, $commandDuration.ToString('mm\:ss')
        if ($isCommandRunning) {
            $status = '🟠'
        }
    }

    '{0} {1}' -f $status, $commandSegment
} -ArgumentList $commandStartJob, $commandEndJob

πŸ“ˆ CPU Usage and Network Bandwidth

CPU usage and bandwidth

If you are familiar with PowerShell, it should be fairly easy to get some of the system information such as CPU usage and network bandwidth.

This example uses another type of Job object, BackgroundThreadTimer. It creates another thread and executes the ScriptBlock at a specified interval. This job is suited for functions that take a long time to finish and might block the update of other segments. In this example, even if the system info job takes a long time to return, the date string is updated smoothly.

$systemInfoJob = Start-DTJobBackgroundThreadTimer -ScriptBlock {
    $cpuUsage = (Get-Counter -Counter '\Processor(_Total)\% Processor Time').CounterSamples.CookedValue
    $netInterface = (Get-CimInstance -class Win32_PerfFormattedData_Tcpip_NetworkInterface)[0]
    $cpuUsage, ($netInterface.BytesReceivedPersec * 8)
} -IntervalMilliseconds 1000

Start-DTTitle {
    param($systemInfoJob)
    $cpuUsage, $bpsReceived = Get-DTJobLatestOutput $systemInfoJob
    $date = Get-Date -Format 'MMM dd HH:mm:ss'

    'πŸ“† {0} πŸ”₯CPU:{1:f1}% πŸ”½{2}Mbps' -f $date, [double]$cpuUsage, [Int]($bpsReceived/1MB)
} -ArgumentList $systemInfoJob

Conclusion

Modern terminals seem to allow us to set the console title dynamically and also render colored emojis beautifully. Similar to the prompt string, it could be a fun area to customize.

Finally, if you have a chance to give it a try and come up with cool ideas, please visit the GitHub Discussions page and share your DynamicTitle scripts!

https://github.com/mdgrs-mei/DynamicTitle

Β