public/live/Invoke-SpectreLive.ps1

function Invoke-SpectreLive {
    <#
    .SYNOPSIS
    Invokes a script block with live rendering.

    .DESCRIPTION
    Starts live rendering for a given renderable. The script block is able to update the renderable in real-time and Spectre Console redraws every time the scriptblock calls `$Context.refresh()`.
    See https://spectreconsole.net/live/live-display for more information.

    .PARAMETER Data
    The renderable object to render.

    .PARAMETER ScriptBlock
    The script block to execute while the live renderable is being rendered.

    .EXAMPLE
    # **Example 1**
    # This is a live updating table example, the table will be updated every second with a new row.
    $data = @(
        [pscustomobject]@{Name="John"; Age=25; City="New York"},
        [pscustomobject]@{Name="Jane"; Age=30; City="Los Angeles"}
    )
    $table = Format-SpectreTable -Data $data

    Invoke-SpectreLive -Data $table -ScriptBlock {
        param (
            [Spectre.Console.LiveDisplayContext] $Context
        )
        $Context.refresh()
        for ($i = 0; $i -lt 5; $i++) {
            Start-Sleep -Seconds 1
            $table = Add-SpectreTableRow -Table $table -Columns "Shaun $i", $i, "Wellington"
            $Context.refresh()
        }
    }

    .EXAMPLE
    # **Example 2**
    # This is a complex live updating nested layout example. It demonstrates how to create a file browser with a preview panel.
    # The root layout is constructed with a header and a content panel. The content panel is split into two columns: filelist and preview.
    # Invoke-SpectreLive is used to render the layout and update the content of each panel on every loop iteration until the escape key is pressed.
    $layout = New-SpectreLayout -Name "root" -Rows @(
        # Row 1
        (
            New-SpectreLayout -Name "header" -MinimumSize 5 -Ratio 1 -Data ("empty")
        ),
        # Row 2
        (
            New-SpectreLayout -Name "content" -Ratio 10 -Columns @(
                (
                    New-SpectreLayout -Name "filelist" -Ratio 2 -Data "empty"
                ),
                (
                    New-SpectreLayout -Name "preview" -Ratio 4 -Data "empty"
                )
            )
        )
    )

    # Functions for rendering the content of each panel
    function Get-TitlePanel {
        return "File Browser - Spectre Live Demo [gray]$(Get-Date)[/]" | Format-SpectreAligned -HorizontalAlignment Center -VerticalAlignment Middle | Format-SpectrePanel -Expand
    }

    function Get-FileListPanel {
        param (
            $Files,
            $SelectedFile
        )
        $fileList = $Files | ForEach-Object {
            $name = $_.Name
            if ($_.Name -eq $SelectedFile.Name) {
                $name = "[Turquoise2]$($name)[/]"
            }
            return $name
        } | Out-String
        return Format-SpectrePanel -Header "[white]File List[/]" -Data $fileList.Trim() -Expand
    }

    function Get-PreviewPanel {
        param (
            $SelectedFile
        )
        $item = Get-Item -Path $SelectedFile.FullName
        $result = ""
        if ($item -is [System.IO.DirectoryInfo]) {
            $result = "[grey]$($SelectedFile.Name) is a directory.[/]"
        } elseif ($item.Name -match "\.(jpg|jpeg|png|gif)$") {
            $result = Get-SpectreSixelImage $item.FullName
        } else {
            try {
                $content = Get-Content -Path $item.FullName -Raw -ErrorAction Stop
                $result = "[grey]$($content | Get-SpectreEscapedText)[/]"
            } catch {
                $result = "[red]Error reading file content: $($_.Exception.Message | Get-SpectreEscapedText)[/]"
            }
        }
        return $result | Format-SpectrePanel -Header "[white]Preview[/]" -Expand
    }

    function Get-LastKeyPressed {
        $lastKeyPressed = $null
        while ([Console]::KeyAvailable) {
            $lastKeyPressed = [Console]::ReadKey($true)
        }
        return $lastKeyPressed
    }

    # Start live rendering the layout
    # Type "↓", "↓", "↓" to navigate the file list, and press "Enter" to open a file in Notepad
    Invoke-SpectreLive -Data $layout -ScriptBlock {
        param (
            [Spectre.Console.LiveDisplayContext] $Context
        )

        # State
        $fileList = @(@{Name = ".."; Fullname = ".."}) + (Get-ChildItem)
        $selectedFile = $fileList[0]

        while ($true) {
            # Handle input
            $lastKeyPressed = Get-LastKeyPressed
            if ($lastKeyPressed -ne $null) {
                if ($lastKeyPressed.Key -eq "DownArrow") {
                    $selectedFile = $fileList[($fileList.IndexOf($selectedFile) + 1) % $fileList.Count]
                } elseif ($lastKeyPressed.Key -eq "UpArrow") {
                    $selectedFile = $fileList[($fileList.IndexOf($selectedFile) - 1 + $fileList.Count) % $fileList.Count]
                } elseif ($lastKeyPressed.Key -eq "Enter") {
                    if ($selectedFile -is [System.IO.DirectoryInfo] -or $selectedFile.Name -eq "..") {
                        $fileList = @(@{Name = ".."; Fullname = ".."}) + (Get-ChildItem -Path $selectedFile.FullName)
                        $selectedFile = $fileList[0]
                    } else {
                        notepad $selectedFile.FullName
                        return
                    }
                } elseif ($lastKeyPressed.Key -eq "Escape") {
                    return
                }
            }

            # Generate new data
            $titlePanel = Get-TitlePanel
            $fileListPanel = Get-FileListPanel -Files $fileList -SelectedFile $selectedFile
            $previewPanel = Get-PreviewPanel -SelectedFile $selectedFile

            # Update layout
            $layout["header"].Update($titlePanel) | Out-Null
            $layout["filelist"].Update($fileListPanel) | Out-Null
            $layout["preview"].Update($previewPanel) | Out-Null

            # Draw changes
            $Context.Refresh()
            Start-Sleep -Milliseconds 200
        }
    }

    .EXAMPLE
    # **Example 3**
    # This is a simple example of creating a chat application. In this example a different approach is used to render the components, each component has been passed a copy of the context and layout object so it can update itself.

    Set-SpectreColors -AccentColor DeepPink1

    # Build root layout scaffolding for:
    # +--------------------------------+
    # | Title | <- Update-TitleComponent will render the title
    # |--------------------------------|
    # | | <- Update-MessageListComponent will display the list of messages here
    # | |
    # | Messages |
    # | |
    # | |
    # |--------------------------------|
    # | CustomTextEntry | <- Update-CustomTextEntryComponent will create a text entry prompt here that is manually managed by pushing keys into a string
    # |________________________________|

    $layout = New-SpectreLayout -Name "root" -Rows @(
        # Row 1
        (New-SpectreLayout -Name "title" -MinimumSize 5 -Ratio 1 -Data ("empty")),
        # Row 2
        (New-SpectreLayout -Name "messages" -Ratio 10 -Data ("empty")),
        # Row 3
        (New-SpectreLayout -Name "customTextEntry" -MinimumSize 5 -Ratio 1 -Data ("empty"))
    )

    # Component functions for rendering the content of each panel
    function Update-TitleComponent {
        param (
            [Spectre.Console.LiveDisplayContext] $Context,
            [Spectre.Console.Layout] $LayoutComponent
        )
        $component = @(
            ("🧠 ChaTTY" | Format-SpectreAligned -HorizontalAlignment Center -VerticalAlignment Middle | Format-SpectrePadded -Padding 1),
            (Write-SpectreRule -LineColor DeepPink1 -PassThru)
        ) | Format-SpectreRows | Format-SpectrePanel -Border None
        $LayoutComponent.Update($component) | Out-Null
        $Context.Refresh()
    }

    function Update-MessageListComponent {
        param (
            [Spectre.Console.LiveDisplayContext] $Context,
            [Spectre.Console.Layout] $LayoutComponent,
            [System.Collections.Stack] $Messages
        )

        $rows = @()

        foreach ($message in $Messages) {
            if ($message.Actor -eq "System") {
                $rows += $message.Message.PadRight(6) `
                    | Get-SpectreEscapedText `
                    | Write-SpectreHost -Justify Left -PassThru `
                    | Format-SpectrePanel -Color Grey -Header "System" `
                    | Format-SpectreAligned -HorizontalAlignment Left `
                    | Format-SpectrePadded -Top 0 -Left 10 -Bottom 0 -Right 0
            } else {
                $rows += $message.Message.PadRight($message.Actor.Length) `
                    | Get-SpectreEscapedText `
                    | Write-SpectreHost -Justify Right -PassThru `
                    | Format-SpectrePanel -Color Pink1 -Header $message.Actor `
                    | Format-SpectreAligned -HorizontalAlignment Right `
                    | Format-SpectrePadded -Top 0 -Left 0 -Bottom 0 -Right 10
            }
        }

        # Add the heights of each message until reaching the max size, subtract the height of the title and text entry components (10)
        $availableHeight = $Host.UI.RawUI.WindowSize.Height - 10
        $totalHeight = 0
        $rowsToRender = @()
        foreach ($row in $rows) {
            $totalHeight += ($row | Get-SpectreRenderableSize).Height
            if ($totalHeight -gt $availableHeight) {
                break
            }
            $rowsToRender += $row
        }

        # Stack is LIFO, so we need to reverse it to display the messages in the correct order
        [array]::Reverse($rowsToRender)

        $component = $rowsToRender | Format-SpectreRows | Format-SpectreAligned -VerticalAlignment Top | Format-SpectrePanel -Border None
        $LayoutComponent.Update($component) | Out-Null
        $Context.Refresh()
    }

    function Update-CustomTextEntryComponent {
        param (
            [Spectre.Console.LiveDisplayContext] $Context,
            [Spectre.Console.Layout] $LayoutComponent,
            [string] $CurrentInput
        )
        $safeInput = [string]::IsNullOrEmpty($CurrentInput) ? "" : ($CurrentInput | Get-SpectreEscapedText)
        $component = "[gray]Prompt:[/] $safeInput" | Format-SpectrePanel -Expand | Format-SpectrePadded -Top 0 -Left 20 -Bottom 0 -Right 20 | Format-SpectreAligned -HorizontalAlignment Center
        $LayoutComponent.Update($component) | Out-Null
        $Context.Refresh()
    }

    # App logic functions
    function Get-SomeChatResponse {
        param (
            [System.Collections.Stack] $Messages,
            [Spectre.Console.LiveDisplayContext] $Context,
            [Spectre.Console.Layout] $LayoutComponent
        )

        # Pretend to be thinking
        $ellipsisCount = 1
        for ($i = 0; $i -lt 3; $i++) {
            $Messages.Push(@{ Actor = "System"; Message = ("." * $ellipsisCount) })
            $ellipsisCount++

            Update-MessageListComponent -Context $Context -LayoutComponent $LayoutComponent -Messages $Messages
            Start-Sleep -Milliseconds 500

            # Remove the last thinking message
            $null = $Messages.Pop()
        }

        # Return the response
        return @{ Actor = "System"; Message = "I don't understand what you're saying." }
    }

    function Get-LastChatKeyPressed {
        return [Console]::ReadKey($true)
    }

    # Start live rendering the layout
    Invoke-SpectreLive -Data $layout -ScriptBlock {
        param (
            [Spectre.Console.LiveDisplayContext] $Context
        )

        # State
        $messages = [System.Collections.Stack]::new(@(
            @{ Actor = "System"; Message = "👋 Hello, welcome to ChaTTY!" },
            @{ Actor = "System"; Message = "Type your message and press Enter to send it." },
            @{ Actor = "System"; Message = "Use the Up and Down arrow keys to scroll through previous messages." },
            @{ Actor = "System"; Message = "Press 'ctrl-c' to close the chat." }
        ))
        $currentInput = ""

        while ($true) {
            # Update components
            Update-TitleComponent -Context $Context -LayoutComponent $layout["title"]
            Update-MessageListComponent -Context $Context -LayoutComponent $layout["messages"] -Messages $messages
            Update-CustomTextEntryComponent -Context $Context -LayoutComponent $layout["customTextEntry"] -CurrentInput $currentInput

            # Real basic input handling, just add characters and remove if backspace is pressed, submit message if Enter is pressed
            [Console]::TreatControlCAsInput = $true
            $lastKeyPressed = Get-LastChatKeyPressed
            if ($lastKeyPressed.Key -eq "C" -and $lastKeyPressed.Modifiers -eq "Control") {
                # Exit the loop. You have to treat ctrl-c as input to avoid the console readkey blocking the sigint
                return
            } elseif ($lastKeyPressed.Key -eq "Enter") {
                # Add the latest user message to the message stack
                $messages.Push(@{ Actor = ($env:USERNAME + $env:USER); Message = $currentInput })
                $currentInput = ""
                Update-CustomTextEntryComponent -Context $Context -LayoutComponent $layout["customTextEntry"] -CurrentInput $currentInput
                Update-MessageListComponent -Context $Context -LayoutComponent $layout["messages"] -Messages $messages
                $messages.Push((Get-SomeChatResponse -Messages $messages -Context $Context -LayoutComponent $layout["messages"]))
            } elseif($lastKeyPressed.Key -eq "Backspace") {
                # Remove the last character from the current input string
                $currentInput = $currentInput.Substring(0, [Math]::Max(0, $currentInput.Length - 1))
            } elseif ($lastKeyPressed.KeyChar) {
                # Add the character to the current input string
                $currentInput += $lastKeyPressed.KeyChar
            }
        }
    }
    #>

    [CmdletBinding(HelpUri='https://pwshspectreconsole.com/reference/live/invoke-spectrelive/')]
    [Reflection.AssemblyMetadata("title", "Invoke-SpectreLive")]
    param (
        [Parameter(ValueFromPipeline)]
        [RenderableTransformationAttribute()]
        [object] $Data,
        [scriptblock] $ScriptBlock
    )

    Start-AnsiConsoleLive -Data $Data -ScriptBlock $ScriptBlock
}