🌈

PowerShellで自分だけのオリジナルステータスバーを作る

2023/04/10に公開

PowerShellでこんな感じ↓のステータスバーを作る方法をご紹介します。

StatusBar demo

Windows Terminal上で動作していますが、他のターミナルアプリケーション、またLinux/macOSでも同様にカスタマイズが可能です。

本記事の英語版はこちら。

https://mdgrs.hashnode.dev/building-your-own-terminal-status-bar-in-powershell

背景

Prompt customiztion

普段はWindowsで作業をすることが多く、シェルはPowerShellをメインで使用しています。Oh My Posh などでPowerShellのプロンプトをカスタマイズされている方は多いかと思いますが、その中で現在時刻やCPUの使用率を表示するセグメントを見かけることがあります。これらはプロンプトのテキストなので、当然コマンドを実行したりEnterを押してプロンプトを更新させないとその表示内容も更新されません。ターミナルのステータスバー(tmuxのstatus lineやiTerm2のStatus Bar)のように、リアルタイムに情報を更新することはできないものか?と思い立ち、いくつかテスト実装を行いました。

ところが、コンソールバッファはユーザ操作によっていつでも書き換わる可能性があるため、非同期にこれを書き換えることは容易ではありません。試行錯誤した結果、ユーザ操作の影響を受けずに安全に情報を書き換えられ、かつPowerShellからアクセス可能な場所を見つけました。コンソールのタイトル領域です。

ここでは、ターミナルのタイトルをステータスバーのようにカスタマイズする方法をご紹介します。

DynamicTitle モジュール

PowerShellでは以下のようにしてコンソールのタイトルを設定することができます。

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

ターミナル上でのユーザ操作と並行してリアルタイムに表示内容を更新するためには、このコードをバックグランウドスレッドからある時間間隔で定期的に呼び出さなければなりません。スレッド管理やスレッド間のデータの受け渡し、タイマー処理などが必要になるため、これらを手軽に扱えるよう DynamicTitle というPowerShellモジュールを作成しました。

以下のコマンドでモジュールをインストールすると、

Install-Module -Name DynamicTitle -Scope CurrentUser

ワンライナーでリアルタイムの時刻を表示することができるようになります。

Start-DTTitle {Get-Date}

Live updating clock

パラメータで指定されたスクリプトブロックはバックグラウンドスレッドで定期的に実行され、スクリプトブロックの出力がタイトルとして設定されます。

スクリプトブロックが利用できるため、PowerShellからアクセス可能なものは基本的にどんな情報でも表示することができます。メインスレッド(ユーザによるターミナル操作)をブロックすることもありません。

🌿 Gitステータス

Git status

はじめに、gitリポジトリの現在のブランチ名と変更のあるファイルの数を表示してみます。

gitのステータスを取得するにはまず、コンソールのカレントディレクトリを知る必要があります。Start-DTTitleに渡されたスクリプトブロックは裏のスレッド(Runspace)で実行されるため、プロンプトに表示されるものとは別の独自のカレントディレクトリを保持しています。メインスレッドからこのDynamicTitleのスレッドにカレントディレクトリを渡す必要があるわけですが、モジュールではこのようなデータ転送のためにいくつかのジョブを用意しています。

PromptCallbackジョブでは、PowerShellのPrompt関数が実行される直前(通常このタイミングでカレントディレクトリも変化する)にメインスレッドで呼び出されるスクリプトブロックを登録することができます。スクリプトブロックが返すオブジェクトはスレッドセーフな関数、Get-DTJobLatestOutputで取得することができます。

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

カレントディレクトリが取得できたら、あとはgitステータスの文字列を生成するのみです。

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

gitステータスの取得は非同期に実行されるため、リポジトリが巨大で長時間かかったとしてもプロンプト表示が遅くなることはありません。

⌚ コマンドタイマー

Command timer

プロンプトのカスタマイズで直前のコマンドの実行時間を表示している例を見かけたことがあるかもしれません。プロンプトでは、コマンドの実行終了後に何秒かかったのかを表示することしかできませんが、非同期処理の場合はコマンド実行中に今何秒経過しているかを表示することができます。

CommandPreExecutionCallbackジョブを使用すると、コマンドが実行される直前にメインスレッドで呼び出されるスクリプトブロックを登録することができます。このジョブによってコマンドの開始時刻を取得することができます。コマンドの終了時刻は先ほどのPromptCallbackジョブを使って取得します。

$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使用率とネットワーク速度

CPU usage and network bandwidth

CPU使用率やネットワーク通信速度などのシステム情報もPowerShellで容易に取得することができるため、ステータスバーによくある表示を作ることも可能です。

この例ではもう一つのJobオブジェクトであるBackgroundThreadTimerを使用しています。BackgroundThreadTimerジョブは独自のスレッドを作成し、そのスレッド上で指定されたスクリプトブロックを一定間隔で呼び出します。処理の完了に長時間を要し、何もしないと他の項目の更新をブロックしてしまうようなものへの利用を想定しています。例えばこの例ではsystemInfoJobが長時間戻ってこなかったとしても、日付のテキストの更新を阻害することがありません。

$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

おわりに

PowerShellのコンソールタイトル領域を非同期に設定することで、リアルタイムに様々な情報を表示することができます。Windows Terminalを含めモダンなターミナルであれば、カラー絵文字もきれいにレンダーしてくれるので、プロンプト文字列と同じようにカスタマイズして楽しむ領域としてとらえるのも面白いかもしれません。

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

Discussion