Skip to content

Terminal Emulator

Why a Terminal Emulator with an API?

When automating or testing terminal applications, many tools rely on simple stdin/stdout redirection. While this works for basic scenarios, it quickly falls short for real-world terminal applications:

  • No screen state: Stdin/stdout gives you a stream of bytes, not a structured view of what's on screen. You can't easily ask "what text is at row 5, column 10?" or "is the cursor blinking?"
  • No timing context: Terminal applications use escape sequences to position text, change colors, and manage the display. Raw byte streams don't tell you when a "frame" is complete.
  • No interactivity: Testing a TUI application means simulating user input and verifying visual output—you need something that understands terminal semantics.
  • No embedability: If you want to host a shell or another TUI inside your application (think VS Code's integrated terminal), you need a terminal emulator that maintains screen state in-process.

Hex1b's Hex1bTerminal is a headless terminal emulator—it processes ANSI/VT escape sequences and maintains an in-memory screen buffer, but doesn't render to any display by itself. This separation is intentional:

Headless by design: Hex1bTerminal owns the terminal state (screen buffer, cursor, attributes). Presentation adapters bridge that state to whatever UI you're targeting—the local console, a web browser, a GUI window, or nothing at all (for testing).

This architecture enables:

  • Automation: Programmatically read screen content, wait for specific text, inject keystrokes
  • Testing: Run TUI applications in CI/CD without a real terminal
  • Embedding: Host shells, editors, or other terminal programs inside your application
  • Remote terminals: Stream terminal state to web clients or other processes

Tutorial: Build a Web Terminal

For a hands-on walkthrough building a multi-terminal web app with WebSocket streaming and xterm.js, see Using the Emulator.

Key Features

Full VT/ANSI Support

Hex1b's terminal emulator supports the full range of terminal escape sequences:

  • Text formatting — Colors (16, 256, and 24-bit), bold, italic, underline, strikethrough
  • Cursor control — Positioning, visibility, shape changes
  • Screen management — Scrolling regions, alternate screen buffer
  • Mouse support — Click, drag, and scroll event reporting
  • Unicode — Full Unicode support including emoji and complex scripts

Child Process Integration

Run any command with proper PTY (pseudo-terminal) support:

csharp
// Start an interactive shell with PTY
await using var terminal = Hex1bTerminal.CreateBuilder()
    .WithPtyProcess("bash", "-i")
    .Build();

await terminal.RunAsync();
csharp
// Or run a command without PTY (for build tools, scripts)
await using var terminal = Hex1bTerminal.CreateBuilder()
    .WithProcess("dotnet", "build")
    .WithHeadless()
    .Build();

await terminal.RunAsync();

Docker Container Integration

Run commands inside isolated Docker containers using WithDockerContainer(). This wraps WithPtyProcess under the hood, so all terminal features (input sequences, pattern searching, recording) work unchanged:

csharp
// Start a container with the default .NET SDK image
await using var terminal = Hex1bTerminal.CreateBuilder()
    .WithDockerContainer()
    .Build();

await terminal.RunAsync();
csharp
// Configure the container with a specific image and environment
await using var terminal = Hex1bTerminal.CreateBuilder()
    .WithDockerContainer(c =>
    {
        c.Image = "ubuntu:24.04";
        c.Environment["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1";
        c.Volumes.Add("/host/data:/container/data:ro");
        c.WorkingDirectory = "/app";
    })
    .Build();

await terminal.RunAsync();
csharp
// Build from a Dockerfile (automatically skips rebuild if unchanged)
await using var terminal = Hex1bTerminal.CreateBuilder()
    .WithDockerContainer(c =>
    {
        c.DockerfilePath = "./test-env/Dockerfile";
        c.BuildArgs["SDK_VERSION"] = "10.0";
    })
    .Build();

await terminal.RunAsync();

The container runs interactively with docker run -it and is automatically removed on exit (--rm). Key options:

OptionDefaultDescription
Imagemcr.microsoft.com/dotnet/sdk:10.0Docker image to use
DockerfilePathnullBuild from a Dockerfile instead of pulling an image
Environment{}Environment variables (-e)
Volumes[]Volume mounts (-v)
MountDockerSocketfalseMount /var/run/docker.sock for Docker-in-Docker
Nameauto-generatedExplicit container name
Shell/bin/bashShell to run inside the container
ShellArgs["--norc"]Arguments passed to the shell
AutoRemovetrueRemove container on exit (--rm)
NetworknullDocker network to connect to

When to use WithDockerContainer vs WithPtyProcess

Use WithDockerContainer when you need an isolated, reproducible environment—particularly for testing or running untrusted code. Use WithPtyProcess when you want to run a local command directly. Under the hood, WithDockerContainer simply builds the docker run -it arguments and calls WithPtyProcess("docker", args).

Programmatic Control

Read and write to the terminal programmatically:

csharp
// Send input to the terminal
terminal.SendInput("ls -la\n");

// Read the current screen content
var screen = terminal.GetScreenContent();

// Wait for specific output
await terminal.WaitForTextAsync("$");

Architecture

The terminal emulator consists of several components:

ComponentPurpose
Hex1bTerminalHeadless terminal emulator — maintains screen state
Hex1bTerminal.CreateBuilder()Fluent configuration builder
IHex1bTerminalPresentationAdapterBridges terminal state to a display
IHex1bTerminalWorkloadAdapterManages what runs inside the terminal

Presentation Adapters

Since Hex1bTerminal is headless, you need a presentation adapter to display its content:

  • ConsolePresentationAdapter — Render to System.Console (the default)
  • HeadlessPresentationAdapter — No rendering (for testing/automation)
  • WebSocketPresentationAdapter — Stream to WebSocket clients (xterm.js, etc.)
  • Custom adapters — Implement your own for GUIs, web, etc.

See Presentation Adapters for details.

Use Cases

Embedding in a TUI

Combine the terminal emulator with Hex1b's TUI framework:

csharp
var terminal = new Hex1bTerminalBuilder()
    .WithSize(80, 24)
    .Build();

var app = new Hex1bApp(ctx =>
    ctx.VStack(v => [
        v.Text("My Terminal App"),
        v.Terminal(terminal).Fill(),
        v.InfoBar("Ctrl+D: Exit")
    ])
);

Building Dev Tools

Create development tools with integrated terminals:

csharp
var app = new Hex1bApp(ctx =>
    ctx.Splitter(
        left: ctx.Border(b => [
            b.Text("Files"),
            b.List(files)
        ]),
        right: ctx.Terminal(terminal)
    )
);

Automation & Testing

See the Automation & Testing guide for using the terminal emulator in testing scenarios.

Terminal Reflow

When a terminal is resized, soft-wrapped lines can be re-wrapped to fit the new width—a feature called reflow. Hex1b supports pluggable reflow strategies that match the behavior of different terminal emulators.

How It Works

Hex1b tracks soft wrapping at the cell level. When a character is written past the last column and the cursor wraps to the next line, the wrap-point cell is tagged with the SoftWrap attribute. Hard wraps (explicit \r\n) are not tagged.

On resize, the reflow engine:

  1. Collects all rows (scrollback + screen)
  2. Groups them into logical lines using SoftWrap flags
  3. Re-wraps each logical line to the new width
  4. Distributes rows back to screen and scrollback

Enabling Reflow

Reflow is disabled by default to preserve the traditional crop-and-extend resize behavior. Enable it by calling WithReflow() on the terminal builder:

csharp
// Auto-detect strategy based on TERM_PROGRAM
var terminal = Hex1bTerminal.CreateBuilder()
    .WithPtyProcess("bash")
    .WithReflow()
    .Build();

// Explicit strategy for testing
var terminal = Hex1bTerminal.CreateBuilder()
    .WithHeadless()
    .WithDimensions(80, 24)
    .WithReflow(AlacrittyReflowStrategy.Instance)
    .Build();

Built-in Strategies

StrategyBehaviorTerminals
AlacrittyReflowStrategyBottom-fills the screen after reflowAlacritty
WindowsTerminalReflowStrategyBottom-fills the screen after reflowWindows Terminal
KittyReflowStrategyAnchors the cursor to its current visual rowKitty
WezTermReflowStrategyAnchors the cursor to its current visual rowWezTerm
VteReflowStrategyCursor-anchored + reflows DECSC saved cursorGNOME Terminal, Tilix, xfce4-terminal
GhosttyReflowStrategyCursor-anchored + reflows DECSC saved cursorGhostty
FootReflowStrategyCursor-anchored + reflows DECSC saved cursorFoot
XtermReflowStrategyNo reflow — standard crop/extendxterm
ITerm2ReflowStrategyNo reflow — standard crop/extendiTerm2
NoReflowStrategyNo reflow — standard crop/extend (default)Unknown terminals

The key difference between strategies is how they handle cursor position during reflow. When narrowing the terminal, soft-wrapped lines split into more rows. AlacrittyReflowStrategy pushes content upward and keeps the bottom of the buffer visible, while KittyReflowStrategy and VteReflowStrategy keep the cursor at the same visual row.

VTE, Ghostty, and Foot additionally reflow the saved cursor position (set via DECSC / ESC 7). This ensures that applications using save/restore cursor across redraws continue to work correctly after a terminal resize.

Each terminal emulator has its own strategy class so that behavior can evolve independently as terminals are updated.

Why separate classes?

Even when two terminals currently behave identically (e.g., Foot and VTE), each gets its own strategy class. This allows us to update one terminal's behavior without affecting others — for example, when Kitty fixes its saved cursor reflow bug, we can update KittyReflowStrategy without changing WezTermReflowStrategy.

See Presentation Adapters for details on configuring reflow per adapter.

Choosing the correct strategy matters

The purpose of terminal reflow is to keep Hex1bTerminal's internal buffer state in sync with what the upstream terminal emulator is actually displaying. If you use ConsolePresentationAdapter with the wrong reflow strategy, the internal state will diverge from the real terminal — leading to cursor misplacement, garbled output, or visual artifacts. When using ConsolePresentationAdapter, prefer WithReflow() (no arguments) to auto-detect the correct strategy for the running terminal. Only override manually if you know the detection is wrong for your environment.

Best-effort strategies

Detailed information about terminal reflow behavior is hard to come by — terminal emulators rarely document their resize logic formally, and behavior can change between versions. These strategies are best effort, based on upstream source code, test suites, and observed behavior.

If you encounter a mismatch between Hex1bTerminal's internal state and what your terminal emulator displays — especially after a recent emulator update — please file an issue with evidence. A screen recording or video showing the before/after state is often the most helpful artifact.

For the HeadlessPresentationAdapter, reflow strategies enable testing of terminal-specific resize behavior without a real terminal. This is useful for validating that your application handles resize correctly across different emulators.

Released under the MIT License.