Skip to content

Your First Flow App

Flow apps render interactive UI steps inline in the normal terminal buffer — like npm init, dotnet new, or az login. Each step's output stays in the terminal's scrollback history, creating a natural top-to-bottom conversation.

Looking for full-screen apps?

If you want to build a full-screen TUI that takes over the entire terminal (like vim or htop), see Your First App instead. Flow is for sequential, inline experiences.

How Flow Works

A flow is a sequence of steps. Each step:

  1. Reserves rows at the current cursor position
  2. Runs an interactive mini-app in that space
  3. When step.Complete(builder) is called, replaces the interactive UI with frozen output
  4. Advances the cursor, and the next step begins below

The result is a series of interactive prompts that scroll naturally through the terminal.

Terminal$dotnet runTerminal ready

Each Step is a Full Widget

A step isn't a simplified prompt — it's a real Hex1b app running in a reserved region of the normal terminal buffer. Inside a step, you have access to the full widget system:

  • Layout: VStack, HStack, Border, Splitter, ScrollPanel
  • Input: TextBox, List, Button, Picker, Checkbox, Slider
  • Display: Text, Spinner, ProgressBar, Table, Tree
  • Behavior: Theming, focus navigation, input bindings, background tasks

The only difference from a full-screen app is where it renders — in a fixed number of rows in the normal buffer instead of the entire alternate screen.

The State Pattern

Define a state object and build your UI as a function of that state. This is the same pattern used in full-screen apps — widgets read from the state, event handlers mutate it, and Hex1b re-renders automatically.

csharp
class SetupState
{
    public string ProjectName { get; set; } = "";
    public string Framework { get; set; } = "";
    public bool Confirmed { get; set; }
    public int ExitCode { get; set; } = 1;
}

The state object is a plain class with no framework dependencies, making it easy to test independently of the UI.

All output should go through Hex1b

Avoid using Console.WriteLine or writing directly to stdout while Hex1b is running. Hex1b manages cursor positioning, screen regions, and ANSI state — direct console writes will corrupt the display. Use widgets like Text() for all output, and use the state object to pass results out of the UI after it exits.

Basic Example

csharp
using Hex1b;
using Hex1b.Flow;

await using var terminal = Hex1bTerminal.CreateBuilder()
    .WithHex1bFlow(async flow =>
    {
        // Step 1: Ask for a name
        var name = "";
        var nameStep = flow.Step(ctx =>
            ctx.TextBox()
                .OnSubmit(e =>
                {
                    name = e.Text ?? "";
                    ctx.Step.Complete(y => y.Text($"  ✓ Name: {name}"));
                }),
            options: opts => opts.MaxHeight = 3
        );
        await nameStep.WaitForCompletionAsync();

        // Step 2: Pick a color
        var colors = new[] { "Red", "Green", "Blue" };
        var color = "";
        var colorStep = flow.Step(ctx =>
            ctx.VStack(v => [
                v.Text("Pick a color:"),
                v.List(colors).OnItemActivated(e =>
                {
                    color = colors[e.ActivatedIndex];
                    ctx.Step.Complete(y => y.Text($"  ✓ Color: {color}"));
                })
            ]),
            options: opts => opts.MaxHeight = 6
        );
        await colorStep.WaitForCompletionAsync();

        // After all steps complete, write a final summary
        var summaryStep = flow.Step(ctx =>
            ctx.Text($"Hello {name}, you picked {color}!"));
        await summaryStep.CompleteAsync();
    })
    .Build();

await terminal.RunAsync();

The FlowStep Handle

flow.Step() returns a FlowStep handle with these methods:

MethodPurpose
Complete(builder)Sets frozen output and stops the step (fire-and-forget)
Complete()Stops the step without frozen output (fire-and-forget)
CompleteAsync(builder)Completes and waits for cleanup
CompleteAsync()Completes without output and waits for cleanup
WaitForCompletionAsync()Waits for a step to finish (after user-driven Complete())
Invalidate()Triggers a re-render (thread-safe)
RequestFocus(predicate)Moves focus to a matching node

Use Complete() in event handlers (where you can't await), and CompleteAsync() or WaitForCompletionAsync() in the flow callback.

All async methods accept an optional CancellationToken. The flow context exposes flow.CancellationToken which is cancelled when the outer flow runner is stopped:

csharp
await step.WaitForCompletionAsync(flow.CancellationToken);
await step.CompleteAsync(y => y.Text("Done"), flow.CancellationToken);

Accessing the Step from Event Handlers

The builder receives a FlowStepContext (which extends RootContext) with a .Step property. Use ctx.Step in event handlers to complete or invalidate the step without needing a separate variable:

csharp
var step = flow.Step(ctx =>
    ctx.TextBox().OnSubmit(e =>
    {
        ctx.Step.Complete(y => y.Text($"  ✓ {e.Text}"));
    })
);
await step.WaitForCompletionAsync();

Sizing Information

The FlowStep handle exposes terminal dimensions:

PropertyPurpose
TerminalWidthTerminal width in columns
TerminalHeightTerminal height in rows
StepHeightRows allocated to this step

The Hex1bFlowContext also exposes AvailableHeight — the number of rows from the current cursor position to the bottom of the terminal before any scrolling would occur.

Step Options

Each step accepts an optional options callback to configure Hex1bFlowStepOptions:

csharp
options: opts =>
{
    opts.MaxHeight = 6;       // Limit rows reserved for this step
    opts.EnableMouse = true;  // Enable mouse input for this step
}

If MaxHeight is not set, the step uses the full terminal height.

Multi-Step State

Define a state class that accumulates results across steps:

csharp
using Hex1b;
using Hex1b.Flow;

var state = new SetupState();

await using var terminal = Hex1bTerminal.CreateBuilder()
    .WithHex1bFlow(async flow =>
    {
        // Step 1: Project name
        var nameStep = flow.Step(ctx =>
            ctx.VStack(v => [
                v.Text("Enter your project name:"),
                v.TextBox(state.ProjectName)
                    .OnSubmit(e =>
                    {
                        state.ProjectName = e.Text ?? "";
                        ctx.Step.Complete(y =>
                            y.Text($"  ✓ Project: {state.ProjectName}"));
                    })
                    .FillWidth()
            ]),
            options: opts => opts.MaxHeight = 4
        );
        await nameStep.WaitForCompletionAsync();

        // Step 2: Framework
        var fwStep = flow.Step(ctx =>
            ctx.VStack(v => [
                v.Text("Select a framework:"),
                v.List(SetupState.Frameworks)
                    .OnItemActivated(e =>
                    {
                        state.Framework = SetupState.Frameworks[e.ActivatedIndex];
                        ctx.Step.Complete(y =>
                            y.Text($"  ✓ Framework: {state.Framework}"));
                    })
                    .FixedHeight(SetupState.Frameworks.Length + 1)
            ]),
            options: opts => opts.MaxHeight = 8
        );
        await fwStep.WaitForCompletionAsync();

        // Step 3: Confirm
        var confirmStep = flow.Step(ctx =>
            ctx.VStack(v => [
                v.Text($"Create '{state.ProjectName}' with {state.Framework}?"),
                v.HStack(h => [
                    h.Button("Yes").OnClick(_ =>
                    {
                        state.Confirmed = true;
                        ctx.Step.Complete(y =>
                            y.Text($"  ✓ Created {state.ProjectName}!"));
                    }),
                    h.Button("No").OnClick(_ =>
                    {
                        ctx.Step.Complete(y => y.Text("  ✗ Cancelled."));
                    })
                ])
            ]),
            options: opts => opts.MaxHeight = 4
        );
        await confirmStep.WaitForCompletionAsync();
    })
    .Build();

await terminal.RunAsync();

// Return an exit code based on state
return state.Confirmed ? 0 : 1;

class SetupState
{
    public static readonly string[] Frameworks =
        ["ASP.NET Core", "Blazor", "Console App", "Worker Service"];

    public string ProjectName { get; set; } = "";
    public string Framework { get; set; } = "";
    public bool Confirmed { get; set; }
}

This pattern has several advantages:

  • Testable: You can unit test SetupState independently of the UI
  • Inspectable: After the flow completes, the state object holds all results
  • Exit codes: Derive the exit code from the final state

Returning Exit Codes

Since the flow callback is just an async lambda, derive your exit code from the state object after the flow completes:

csharp
using Hex1b;
using Hex1b.Flow;

var state = new CommandState();

await using var terminal = Hex1bTerminal.CreateBuilder()
    .WithHex1bFlow(async flow =>
    {
        var step = flow.Step(ctx =>
            ctx.VStack(v => [
                v.Text("Delete all files in /tmp?"),
                v.HStack(h => [
                    h.Button("Yes, delete").OnClick(_ =>
                    {
                        state.ExitCode = 0;
                        ctx.Step.Complete(y => y.Text("  ✓ Deleted."));
                    }),
                    h.Button("Cancel").OnClick(_ =>
                    {
                        state.ExitCode = 1;
                        ctx.Step.Complete(y => y.Text("  ✗ Cancelled."));
                    })
                ])
            ]),
            options: opts => opts.MaxHeight = 4
        );
        await step.WaitForCompletionAsync();
    })
    .Build();

await terminal.RunAsync();
return state.ExitCode;

class CommandState
{
    public int ExitCode { get; set; } = 1;
}

Background Work with Invalidate

The flow callback itself is the natural place for background work. Start a step, do async work, and call step.Invalidate() to trigger re-renders:

csharp
using Hex1b;
using Hex1b.Flow;

await using var terminal = Hex1bTerminal.CreateBuilder()
    .WithHex1bFlow(async flow =>
    {
        var status = "Initializing...";
        var done = false;

        // Start the step — UI renders immediately
        var step = flow.Step(ctx => ctx.HStack(h => [
            h.Spinner(),
            h.Text($" {status}")
        ]),
            options: opts => opts.MaxHeight = 2
        );

        // Do background work in the flow callback itself
        var steps = new[]
        {
            "Installing packages...",
            "Compiling project...",
            "Running tests..."
        };

        foreach (var s in steps)
        {
            status = s;
            step.Invalidate();
            await Task.Delay(1000);
        }

        done = true;
        step.Invalidate();
        await Task.Delay(300);
        await step.CompleteAsync(y => y.Text("  ✓ All tasks complete!"));
    })
    .Build();

await terminal.RunAsync();

The pattern is:

  1. Call flow.Step() to start the UI — it returns a FlowStep handle immediately
  2. Do async work directly in the flow callback, calling step.Invalidate() after each state change
  3. Call await step.CompleteAsync(builder) when the work finishes — this completes the step and waits for cleanup in one call

Static Content with ShowAsync

For non-interactive content like headers, dividers, or status lines, use ShowAsync instead of Step + CompleteAsync:

csharp
await flow.ShowAsync(ctx => ctx.Text("═══ Project Setup ═══"));
await flow.ShowAsync(ctx => ctx.Grid(grid => [
    grid.Cell(c => c.Text("■")).Column(0),
    grid.Cell(c => c.Text("CONFIGURATION")).Column(1),
]));

ShowAsync renders the widget as frozen output and advances the cursor — no step lifecycle, no input handling.

Mixing Flow and Full-Screen

Flow supports FullScreenStepAsync for steps that need the alternate screen buffer. The flow saves inline state before entering full-screen and restores it after:

csharp
await flow.FullScreenStepAsync((app, options) => ctx =>
    ctx.VStack(v => [
        v.Text("Full-screen editor"),
        v.Button("Done").OnClick(_ => app.RequestStop())
    ])
);

This is useful for steps that need more space than inline rendering can provide — like a file picker or a diff viewer.

Next Steps

Released under the MIT License.