Skip to content
ConsoleMonster

Interactive apps

Build a live inventory filter with a named text input, shared application state and a table that updates after every edit.

A named input and a binding turn an ordinary layout into an interactive application. This example filters six synthetic hosts: type sql in Query and the count and status table update to show SQL01 and SQL02. Matching is literal and case-insensitive across host, role, site and status. No machines or services are queried.

Run the filter

  1. Install ConsoleMonster for PowerShell:

    Install-Module ConsoleMonster -Scope CurrentUser
    
  2. Copy the full PowerShell script below into live-filter.ps1, then run it in an interactive terminal:

    ./live-filter.ps1
    
  3. For C#, create a console project and add the ConsoleMonster package:

    dotnet new console -n HostFilter
    cd HostFilter
    dotnet add package ConsoleMonster
    
  4. Replace Program.cs with the full C# program below and run it:

    dotnet run
    

Query has focus when the application opens. Type to filter, use Backspace to broaden the query, and press Escape to quit. Tab and Shift+Tab move between controls in layouts with more than one input. A query with no matches keeps the column headers and shows an explanation beneath the empty table.

Host inventory · initial 120 columns24-bitFull
Host inventory · sql 120 columns24-bitFull
PowerShelllive-filter.ps1
<#
.SYNOPSIS
Filters a synthetic host inventory after every edit.
.DESCRIPTION
Builds a native PowerShell application with a Query input, matching count and
ordinary table. Matching is literal and case-insensitive across all columns.
Import ConsoleMonster before running this example.
.PARAMETER AppOptions
Optional Show-ConsoleApp parameters, such as StaticSnapshot for redirected output.
.OUTPUTS
None. The application draws to the terminal or the supplied capture.
.EXAMPLE
./live-filter.ps1
.EXAMPLE
./live-filter.ps1 -AppOptions @{ StaticSnapshot = $true }
#>
[CmdletBinding()]
param([hashtable] $AppOptions = @{})

Import-Module ConsoleMonster

$hosts = @(
# … 17 more lines in the full program

Show-ConsoleApp {
    New-ConsoleRows -Gap 1 {
        '[bold]Host inventory[/] [muted]· synthetic data[/]'
        New-ConsoleTextInput -Name Query -Label Query -Placeholder 'Host, role, site or status'
        New-ConsoleBinding {
            param($State)
            $matches = @(& $matchingHosts $State)
            "[bold]$($matches.Count)[/] of $($hosts.Count) hosts"
            $matches | New-ConsoleTable -Property Host, Role, Site, Status -StatusProperty Status -NoHeaderSeparator
            if ($matches.Count -eq 0) {
                '[muted]No matching hosts. Backspace to broaden the query.[/]'
            }
        }
    }
} @AppOptions
Full program
<#
.SYNOPSIS
Filters a synthetic host inventory after every edit.
.DESCRIPTION
Builds a native PowerShell application with a Query input, matching count and
ordinary table. Matching is literal and case-insensitive across all columns.
Import ConsoleMonster before running this example.
.PARAMETER AppOptions
Optional Show-ConsoleApp parameters, such as StaticSnapshot for redirected output.
.OUTPUTS
None. The application draws to the terminal or the supplied capture.
.EXAMPLE
./live-filter.ps1
.EXAMPLE
./live-filter.ps1 -AppOptions @{ StaticSnapshot = $true }
#>
[CmdletBinding()]
param([hashtable] $AppOptions = @{})

Import-Module ConsoleMonster

$hosts = @(
    [pscustomobject] @{ Host = 'WEB01'; Role = 'Web'; Site = 'North'; Status = 'Pass' }
    [pscustomobject] @{ Host = 'WEB02'; Role = 'Web'; Site = 'West'; Status = 'Warn' }
    [pscustomobject] @{ Host = 'SQL01'; Role = 'Database'; Site = 'North'; Status = 'Pass' }
    [pscustomobject] @{ Host = 'SQL02'; Role = 'Database'; Site = 'West'; Status = 'Fail' }
    [pscustomobject] @{ Host = 'FS01'; Role = 'Files'; Site = 'North'; Status = 'Pass' }
    [pscustomobject] @{ Host = 'JOB01'; Role = 'Worker'; Site = 'West'; Status = 'Running' }
)
$matchingHosts = {
    param($State)
    $query = [string] $State.Query
    $hosts | Where-Object {
        $_.Host.IndexOf($query, [StringComparison]::OrdinalIgnoreCase) -ge 0 -or
        $_.Role.IndexOf($query, [StringComparison]::OrdinalIgnoreCase) -ge 0 -or
        $_.Site.IndexOf($query, [StringComparison]::OrdinalIgnoreCase) -ge 0 -or
        $_.Status.IndexOf($query, [StringComparison]::OrdinalIgnoreCase) -ge 0
    }
}

Show-ConsoleApp {
    New-ConsoleRows -Gap 1 {
        '[bold]Host inventory[/] [muted]· synthetic data[/]'
        New-ConsoleTextInput -Name Query -Label Query -Placeholder 'Host, role, site or status'
        New-ConsoleBinding {
            param($State)
            $matches = @(& $matchingHosts $State)
            "[bold]$($matches.Count)[/] of $($hosts.Count) hosts"
            $matches | New-ConsoleTable -Property Host, Role, Site, Status -StatusProperty Status -NoHeaderSeparator
            if ($matches.Count -eq 0) {
                '[muted]No matching hosts. Backspace to broaden the query.[/]'
            }
        }
    }
} @AppOptions
C#Program.cs
var hosts = new[] {
// … 7 more lines in the full program

IWidget Results(string query) {
    var matches = hosts.Where(host =>
        host.Name.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0 ||
        host.Role.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0 ||
        host.Site.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0 ||
        host.Status.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0).ToArray();
    TextBlock count = new TextBlock("[bold]" + matches.Length.ToString(CultureInfo.InvariantCulture) +
        "[/] of " + hosts.Length.ToString(CultureInfo.InvariantCulture) + " hosts");
    Table table = Table.For(matches)
        .Column("Host", host => host.Name)
        .Column("Role", host => host.Role)
        .Column("Site", host => host.Site)
        .Column("Status", host => host.Status, column => column.Presenter = CellPresenters.StatusWord);
    table.HeaderSeparator = false;
    if (matches.Length == 0) {
        return new Rows(count, table, new TextBlock("[muted]No matching hosts. Backspace to broaden the query.[/]"));
    }
    return new Rows(count, table);
}

Rows layout = new Rows(
    new TextBlock("[bold]Host inventory[/] [muted]· synthetic data[/]"),
    new TextInput("Query") {
        Label = "Query",
        Placeholder = "Host, role, site or status"
    },
    new AppBinding(state => Results(state.Get<string>("Query", string.Empty)))) { Gap = 1 };

ConsoleApp.Run(layout);
Full program
using System.Globalization;
using ConsoleMonster.Interactive;
using ConsoleMonster.Layout;
using ConsoleMonster.Presenters;
using ConsoleMonster.Widgets;

var hosts = new[] {
    new { Name = "WEB01", Role = "Web", Site = "North", Status = "Pass" },
    new { Name = "WEB02", Role = "Web", Site = "West", Status = "Warn" },
    new { Name = "SQL01", Role = "Database", Site = "North", Status = "Pass" },
    new { Name = "SQL02", Role = "Database", Site = "West", Status = "Fail" },
    new { Name = "FS01", Role = "Files", Site = "North", Status = "Pass" },
    new { Name = "JOB01", Role = "Worker", Site = "West", Status = "Running" }
};

IWidget Results(string query) {
    var matches = hosts.Where(host =>
        host.Name.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0 ||
        host.Role.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0 ||
        host.Site.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0 ||
        host.Status.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0).ToArray();
    TextBlock count = new TextBlock("[bold]" + matches.Length.ToString(CultureInfo.InvariantCulture) +
        "[/] of " + hosts.Length.ToString(CultureInfo.InvariantCulture) + " hosts");
    Table table = Table.For(matches)
        .Column("Host", host => host.Name)
        .Column("Role", host => host.Role)
        .Column("Site", host => host.Site)
        .Column("Status", host => host.Status, column => column.Presenter = CellPresenters.StatusWord);
    table.HeaderSeparator = false;
    if (matches.Length == 0) {
        return new Rows(count, table, new TextBlock("[muted]No matching hosts. Backspace to broaden the query.[/]"));
    }
    return new Rows(count, table);
}

Rows layout = new Rows(
    new TextBlock("[bold]Host inventory[/] [muted]· synthetic data[/]"),
    new TextInput("Query") {
        Label = "Query",
        Placeholder = "Host, role, site or status"
    },
    new AppBinding(state => Results(state.Get<string>("Query", string.Empty)))) { Gap = 1 };

ConsoleApp.Run(layout);
The same program at 120 columns, before typing and after sql. The count changes from six hosts to two. Full program includes the synthetic inventory and all setup code.

State and dependent widgets

In PowerShell, New-ConsoleTextInput -Name Query stores the current text in $State.Query. New-ConsoleBinding runs after each edit and builds the matching count and table from that value. The layout remains native PowerShell: New-ConsoleRows, the text input and New-ConsoleTable compose the screen inside Show-ConsoleApp.

In C#, TextInput("Query") writes to AppState, and AppBinding reads it with state.Get<string>("Query", string.Empty). ConsoleApp.Run drives the existing live renderer and returns the final state when the application ends. Use Show-ConsoleApp -PassThru when a PowerShell caller needs the final named values.

The input stays in place while the binding rebuilds its dependent widgets. The count and table use the same query snapshot, and the status column uses the built-in status presenter.

At 60 columns

Host inventory · initial 60 columns24-bitFull
Host inventory · sql 60 columns24-bitFull
PowerShelllive-filter.ps1
<#
.SYNOPSIS
Filters a synthetic host inventory after every edit.
.DESCRIPTION
Builds a native PowerShell application with a Query input, matching count and
ordinary table. Matching is literal and case-insensitive across all columns.
Import ConsoleMonster before running this example.
.PARAMETER AppOptions
Optional Show-ConsoleApp parameters, such as StaticSnapshot for redirected output.
.OUTPUTS
None. The application draws to the terminal or the supplied capture.
.EXAMPLE
./live-filter.ps1
.EXAMPLE
./live-filter.ps1 -AppOptions @{ StaticSnapshot = $true }
#>
[CmdletBinding()]
param([hashtable] $AppOptions = @{})

Import-Module ConsoleMonster

$hosts = @(
# … 17 more lines in the full program

Show-ConsoleApp {
    New-ConsoleRows -Gap 1 {
        '[bold]Host inventory[/] [muted]· synthetic data[/]'
        New-ConsoleTextInput -Name Query -Label Query -Placeholder 'Host, role, site or status'
        New-ConsoleBinding {
            param($State)
            $matches = @(& $matchingHosts $State)
            "[bold]$($matches.Count)[/] of $($hosts.Count) hosts"
            $matches | New-ConsoleTable -Property Host, Role, Site, Status -StatusProperty Status -NoHeaderSeparator
            if ($matches.Count -eq 0) {
                '[muted]No matching hosts. Backspace to broaden the query.[/]'
            }
        }
    }
} @AppOptions
Full program
<#
.SYNOPSIS
Filters a synthetic host inventory after every edit.
.DESCRIPTION
Builds a native PowerShell application with a Query input, matching count and
ordinary table. Matching is literal and case-insensitive across all columns.
Import ConsoleMonster before running this example.
.PARAMETER AppOptions
Optional Show-ConsoleApp parameters, such as StaticSnapshot for redirected output.
.OUTPUTS
None. The application draws to the terminal or the supplied capture.
.EXAMPLE
./live-filter.ps1
.EXAMPLE
./live-filter.ps1 -AppOptions @{ StaticSnapshot = $true }
#>
[CmdletBinding()]
param([hashtable] $AppOptions = @{})

Import-Module ConsoleMonster

$hosts = @(
    [pscustomobject] @{ Host = 'WEB01'; Role = 'Web'; Site = 'North'; Status = 'Pass' }
    [pscustomobject] @{ Host = 'WEB02'; Role = 'Web'; Site = 'West'; Status = 'Warn' }
    [pscustomobject] @{ Host = 'SQL01'; Role = 'Database'; Site = 'North'; Status = 'Pass' }
    [pscustomobject] @{ Host = 'SQL02'; Role = 'Database'; Site = 'West'; Status = 'Fail' }
    [pscustomobject] @{ Host = 'FS01'; Role = 'Files'; Site = 'North'; Status = 'Pass' }
    [pscustomobject] @{ Host = 'JOB01'; Role = 'Worker'; Site = 'West'; Status = 'Running' }
)
$matchingHosts = {
    param($State)
    $query = [string] $State.Query
    $hosts | Where-Object {
        $_.Host.IndexOf($query, [StringComparison]::OrdinalIgnoreCase) -ge 0 -or
        $_.Role.IndexOf($query, [StringComparison]::OrdinalIgnoreCase) -ge 0 -or
        $_.Site.IndexOf($query, [StringComparison]::OrdinalIgnoreCase) -ge 0 -or
        $_.Status.IndexOf($query, [StringComparison]::OrdinalIgnoreCase) -ge 0
    }
}

Show-ConsoleApp {
    New-ConsoleRows -Gap 1 {
        '[bold]Host inventory[/] [muted]· synthetic data[/]'
        New-ConsoleTextInput -Name Query -Label Query -Placeholder 'Host, role, site or status'
        New-ConsoleBinding {
            param($State)
            $matches = @(& $matchingHosts $State)
            "[bold]$($matches.Count)[/] of $($hosts.Count) hosts"
            $matches | New-ConsoleTable -Property Host, Role, Site, Status -StatusProperty Status -NoHeaderSeparator
            if ($matches.Count -eq 0) {
                '[muted]No matching hosts. Backspace to broaden the query.[/]'
            }
        }
    }
} @AppOptions
C#Program.cs
var hosts = new[] {
// … 7 more lines in the full program

IWidget Results(string query) {
    var matches = hosts.Where(host =>
        host.Name.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0 ||
        host.Role.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0 ||
        host.Site.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0 ||
        host.Status.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0).ToArray();
    TextBlock count = new TextBlock("[bold]" + matches.Length.ToString(CultureInfo.InvariantCulture) +
        "[/] of " + hosts.Length.ToString(CultureInfo.InvariantCulture) + " hosts");
    Table table = Table.For(matches)
        .Column("Host", host => host.Name)
        .Column("Role", host => host.Role)
        .Column("Site", host => host.Site)
        .Column("Status", host => host.Status, column => column.Presenter = CellPresenters.StatusWord);
    table.HeaderSeparator = false;
    if (matches.Length == 0) {
        return new Rows(count, table, new TextBlock("[muted]No matching hosts. Backspace to broaden the query.[/]"));
    }
    return new Rows(count, table);
}

Rows layout = new Rows(
    new TextBlock("[bold]Host inventory[/] [muted]· synthetic data[/]"),
    new TextInput("Query") {
        Label = "Query",
        Placeholder = "Host, role, site or status"
    },
    new AppBinding(state => Results(state.Get<string>("Query", string.Empty)))) { Gap = 1 };

ConsoleApp.Run(layout);
Full program
using System.Globalization;
using ConsoleMonster.Interactive;
using ConsoleMonster.Layout;
using ConsoleMonster.Presenters;
using ConsoleMonster.Widgets;

var hosts = new[] {
    new { Name = "WEB01", Role = "Web", Site = "North", Status = "Pass" },
    new { Name = "WEB02", Role = "Web", Site = "West", Status = "Warn" },
    new { Name = "SQL01", Role = "Database", Site = "North", Status = "Pass" },
    new { Name = "SQL02", Role = "Database", Site = "West", Status = "Fail" },
    new { Name = "FS01", Role = "Files", Site = "North", Status = "Pass" },
    new { Name = "JOB01", Role = "Worker", Site = "West", Status = "Running" }
};

IWidget Results(string query) {
    var matches = hosts.Where(host =>
        host.Name.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0 ||
        host.Role.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0 ||
        host.Site.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0 ||
        host.Status.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0).ToArray();
    TextBlock count = new TextBlock("[bold]" + matches.Length.ToString(CultureInfo.InvariantCulture) +
        "[/] of " + hosts.Length.ToString(CultureInfo.InvariantCulture) + " hosts");
    Table table = Table.For(matches)
        .Column("Host", host => host.Name)
        .Column("Role", host => host.Role)
        .Column("Site", host => host.Site)
        .Column("Status", host => host.Status, column => column.Presenter = CellPresenters.StatusWord);
    table.HeaderSeparator = false;
    if (matches.Length == 0) {
        return new Rows(count, table, new TextBlock("[muted]No matching hosts. Backspace to broaden the query.[/]"));
    }
    return new Rows(count, table);
}

Rows layout = new Rows(
    new TextBlock("[bold]Host inventory[/] [muted]· synthetic data[/]"),
    new TextInput("Query") {
        Label = "Query",
        Placeholder = "Host, role, site or status"
    },
    new AppBinding(state => Results(state.Get<string>("Query", string.Empty)))) { Gap = 1 };

ConsoleApp.Run(layout);
The same inventory and query at 60 columns. Both widths are rendered in light and dark themes, and the saved Windows PowerShell 5.1 and PowerShell 7 output is checked against the C# program cell by cell.

Redirected output and CI

An interactive application needs input. Without an interactive terminal, ConsoleApp.Run and Show-ConsoleApp report that prompting is unavailable unless the caller explicitly requests a static snapshot. To print the initial PowerShell layout without reading keys, run the same script with its snapshot option:

./live-filter.ps1 -AppOptions @{ StaticSnapshot = $true }

For a C# snapshot, pass new ConsoleAppOptions { StaticSnapshot = true } as the options argument to ConsoleApp.Run. A snapshot initializes the layout once; it does not read a query.

Try interactive prompts for one answer at a time, live sessions for dashboards updated by running work, or threads and concurrency for the live engine's threading rules.

Edit this page on GitHub