Skip to content

Interactive TUIs from a .psm1

ps-bash turns a pipeline of objects into an interactive terminal app — a navigable list you move through with the arrow keys, expand for detail, select, and run actions on. And you build your own by writing a PowerShell module: define an adapter, register some actions, pipe objects in. No C#, no TUI framework, no curses.

Terminal window
ls -la | browse # arrow-key file workbench
ps aux | browse # processes, with a built-in "stop" action
seq 1 5 | Show-Styled # CSS-themed, expandable viewer

There are two complementary surfaces:

browse — object workbench

A compact table you navigate, select, inspect, and act on. Extended in pure PowerShell via adapters. Preserves your live objects — actions get the real thing, not a copy.

Show-Styled — themed viewer

A full-screen, CSS-driven list with detail expansion. Extended with stylesheets. Auto-themes by object kind. See Styled Output.

Pipe any objects into browse and you get a single-key-driven table:

KeyAction
n / p (or / )Move the cursor down / up
sToggle selection on the current row
iInspect — dump the current object’s properties below the list
aRun a named action on the selection
eRun an inline command ($_, $1, $items are bound to your selection)
qQuit

It repaints incrementally (only the cells that change), shows up to 14 rows at a time, and redraws on terminal resize. Crucially, it preserves your original objects — every row carries the live .NET object, so an action operates on the real Process or FileInfo, not a stringified shadow.

browse is also scriptable — the same workbench, driven by flags instead of keys:

Terminal window
ls -la | browse --list # just the rows (line mode)
ls -la | browse --inspect 0 # inspect row 0
ps aux | browse --select 0 --action stop # preview the stop action
ps aux | browse --select 0 --action stop --force # actually run it
ls | browse --select 0,2 --exec 'Remove-Item $_'

browse ships adapters that decide a type’s columns and actions:

AdapterMatchesColumns / actions
fileFileInfo, DirectoryInfo, LsEntry, FindEntryname/size/dates; inspect, delete (destructive)
processProcess, PsEntryname/id/cpu; inspect, stop (destructive)
defaultanything elsedisplay set → public properties → ToString()

The first adapter whose TypeNames match the object’s PSTypeNames (or .NET type) wins.

Actions flagged destructive (delete, stop, …) return a preview unless you pass -Force. A command classifier independently rejects destructive verbs (Remove-Item, Stop-Process, kill, …) entered through --exec without -Force. You can’t fat-finger a deletion from the workbench.

This is the headline: a custom TUI is just a module. Define an action, wrap it in an adapter for your type, register it, and pipe your objects in.

  1. Write an action. A scriptblock receives $Current (the focused row) and $Items (the selection). Mark mutating actions -Destructive.

    Terminal window
    $approve = New-BrowseAction -Name 'approve' -Description 'Approve this PR' -Script {
    param($Current, [object[]]$Items)
    foreach ($pr in $Items) { gh pr review $pr.Number --approve }
    }
    $close = New-BrowseAction -Name 'close' -Description 'Close PR' -Destructive -Script {
    param($Current, [object[]]$Items)
    foreach ($pr in $Items) { gh pr close $pr.Number }
    }
  2. Wrap it in an adapter for your object type, naming the columns to show:

    Terminal window
    $prAdapter = New-BrowseAdapter -Name 'pr' `
    -TypeNames @('MyTools.PullRequest') `
    -DisplayProperties @('Number', 'Title', 'Author', 'State') `
    -Actions @($approve, $close)
  3. Register it with Register-BrowseAdapter. This is the supported entry point — it writes PsBash’s own adapter registry (a bare $script:BrowseAdapters += … from your module would write the wrong scope), and your adapter takes precedence over the built-ins:

    Terminal window
    Register-BrowseAdapter -Adapter $prAdapter
    # or inline, no New-BrowseAdapter needed:
    Register-BrowseAdapter -Name 'pr' -TypeNames @('MyTools.PullRequest') `
    -DisplayProperties @('Number','Title','Author','State') -Actions @($approve, $close)
    Get-BrowseAdapter # list registered adapters
    Unregister-BrowseAdapter -Name 'pr' # remove yours (the 'default' fallback is protected)
  4. Use it. Anything carrying PSTypeName = 'MyTools.PullRequest' now drives your TUI:

    Terminal window
    Get-MyPullRequests | browse # n/p to move, s to select, a to approve, q to quit

The whole adapter surface is PowerShell — the binary browse cmdlet just dispatches to your psm1 helpers. You ship a module; your users get a workbench.

Build your own with CSS instead: Show-Styled

Section titled “Build your own with CSS instead: Show-Styled”

If your “TUI” is really a themed, expandable view — color rows by state, hide detail until expanded — you don’t need an adapter at all. Write a stylesheet and let Show-Styled drive the cascade:

~/.config/ps-bash/styles/pr.css
PullRequest { display: block; color: white; }
PullRequest.draft { color: gray; }
PullRequest.approved { color: green; font-weight: bold; }
PullRequest:focused { background: blue; color: bright-white; }
/* detail rows hidden until the row is expanded with Enter */
PullRequest > DetailLine { display: none; }
PullRequest:expanded > DetailLine { display: block; }
Terminal window
Get-MyPullRequests | Show-Styled pr # ↑↓/jk move, Enter expand, q quit

Show-Styled builds a Surface → (Row | Detail)* tree, runs the cascade, and repaints on each keystroke — mutating the focused row’s :focused / :expanded pseudo-state. The styled path is CSS-only; no PowerShell code required.

Both workbenches use a Console.ReadKey loop over the alternate screen buffer (ESC[?1049hESC[?1049l) — the exact path vim and the line editor use. An earlier Terminal.Gui-based projection was built and verified, then removed: Terminal.Gui v2 (prealpha) drove the tty through its own input loop and left stdin dead on exit. The ReadKey + Spectre approach round-trips a following command cleanly — proven by the PTY tests that navigate, quit, then run an echo afterward.