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:
- Reserves rows at the current cursor position
- Runs an interactive mini-app in that space
- When
step.Complete(builder)is called, replaces the interactive UI with frozen output - Advances the cursor, and the next step begins below
The result is a series of interactive prompts that scroll naturally through the terminal.
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.
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
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:
| Method | Purpose |
|---|---|
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:
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:
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:
| Property | Purpose |
|---|---|
TerminalWidth | Terminal width in columns |
TerminalHeight | Terminal height in rows |
StepHeight | Rows 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:
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:
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
SetupStateindependently 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:
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:
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:
- Call
flow.Step()to start the UI — it returns aFlowStephandle immediately - Do async work directly in the flow callback, calling
step.Invalidate()after each state change - 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:
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:
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
- Your First App — Build a full-screen TUI with Hex1bApp
- Widgets & Nodes — Understand the widget architecture
- Layout System — Master constraint-based layouts
- Input Handling — Keyboard navigation and shortcuts
- Theming — Customize the appearance of your app
- Widgets — Explore the full widget library