Skip to content

Autopilot and Recording

An autopilot drives an app without a person at the keyboard: it feeds the app events and controls, as tests and these docs' screenshots do. A recording saves what an app draws, with its timing, as an asciicast file that asciinema-player and asciinema play play back, whether a person or an autopilot is driving. This page covers both, and how they combine to record a demo that comes out the same on every run.

Driving an app

app's autopilot is an iterable of events and controls, which the app handles in order, one per render, starting after its first frame. It doesn't quit the app when it runs out, so end it with Quit(). With headless=True the app neither reads the terminal nor draws on it, and dimensions gives it a screen size:

await app(
    search(),
    headless=True,
    dimensions=(30, 8),
    autopilot=[
        KeyPressed(key="a"),
        KeyPressed(key="n"),
        Screenshot.to_file(Path("typed.svg")),
        Quit(),
    ],
)

Controlling time

An app reads the time from its clock, the system's by default. A FrozenClock reads the same time until the autopilot moves it on, with AdvanceClock or by waiting, so an autopilot can show a minute of a clock-driven app in milliseconds, and show the same thing on every run. To pace an autopilot, put an AdvanceClock between its steps with more_itertools.intersperse:

typed = [KeyPressed(key=character) for character in "mango"]

await app(
    search(),
    headless=True,
    dimensions=(30, 8),
    clock=FrozenClock(datetime(2026, 3, 14, tzinfo=UTC)),
    autopilot=[*intersperse(AdvanceClock(by=timedelta(milliseconds=300)), typed), Quit()],
)

Waiting for real work

An autopilot runs ahead of anything the app does in the background, such as an effect waiting on a subprocess or a command, since it hands the app its next step as soon as the last one has rendered. Two steps hold it back while the app keeps handling events and rendering:

  • WaitFor waits until a condition is true of the frame on screen, and fails with AutopilotTimedOut if it's still false after its timeout. It's the one to reach for, as Playwright's assertions are, since it waits exactly as long as the work takes.
  • Wait waits for a duration of real time.
await app(
    test_runner(),
    headless=True,
    dimensions=(60, 20),
    autopilot=[
        KeyPressed(key="r"),
        WaitFor(lambda frame: "passed" in frame.text(ansi=False), timeout=timedelta(seconds=30)),
        Screenshot.to_file(Path("passed.svg")),
        Quit(),
    ],
)

Both wait in real time, and a FrozenClock runs in real time while they do, so a component that reads the clock, such as a spinner, keeps moving while the work goes on. Each step of an autopilot can pick its own kind of time: an AdvanceClock skips ahead at once, and a wait lets the time the work really takes pass. A Wait moves the clock on by exactly its duration, and a WaitFor by however long its condition took to hold.

Waiting brings real time into a frozen clock

An app under an autopilot that waits doesn't come out the same on every run, even with a FrozenClock. The frames it renders during a wait are timed by when the event loop wakes, and a WaitFor moves the clock on by however long the work took, which shifts everything the app shows of the time from then on. For screenshots and recordings that must not change between runs, such as ones checked into a repository, pace the autopilot with AdvanceClock alone.

Recording

Pass app a Recording to record it to a file:

await app(search(), recording=Recording(Path("search.cast"), title="Search"))

The recording holds what the app draws on its terminal, as it draws it: the first frame in full, and each later frame as the cells that changed, which a player applies to the screen it already shows, as a terminal does. Each frame is timed by the app's clock. With the system's clock, run the app on a terminal and use it, and it records itself, with no asciinema rec around it. With a FrozenClock and an autopilot, its timing is the autopilot's AdvanceClocks and waits, so with no waits it records as fast as the app renders and comes out the same on every run, as this one, from the spinner recipe, does:

The recording also holds the screen's size when it changes, and how the app exited: 0 once it quits, and 1 if it stops with an error. While the app is suspended, the recording can't see what the program it hands the terminal to draws, so the time it spends suspended is cut out, and a suspended marker shows where.

Recording input records everything typed

Recording(input=True) records every key press, paste and mouse event the app handles, which asciinema-player can show as they're typed (its keystrokeOverlay option). That includes passwords and tokens typed into the app, in a file that's made to be shared, so it's off by default, under an autopilot too. Turn it on when you know what will be typed, as for an autopilot's demo.

Marking chapters

The Marker control marks a point in the recording with a label, which asciinema-player shows on its progress bar as a chapter to jump to. An autopilot can mark each step of a demo, and a handler can return one too, such as to mark each view the user opens. An app that isn't recording ignores it.

API

counterweight.autopilot.AutopilotStep module-attribute

AutopilotStep = AnyEvent | AnyControl | Wait | WaitFor

What an autopilot feeds an app: events, controls, and the steps that wait.

counterweight.autopilot.Wait dataclass

Wait(duration: timedelta)

Hold the autopilot back for duration of real time, while the app keeps handling events and rendering, so work like a subprocess can make progress.

A FrozenClock runs in real time while the autopilot waits, and ends the wait exactly duration later, however late the event loop wakes from it. The frames the app renders during the wait are timed by when the event loop wakes, so a recording with them comes out a little different on every run.

To wait for work to finish, prefer WaitFor, which doesn't have to guess how long the work takes.

duration instance-attribute

duration: timedelta

counterweight.autopilot.WaitFor dataclass

WaitFor(
    condition: Callable[[Frame], bool], timeout: timedelta
)

Hold the autopilot back until condition is true of the frame on screen, checked after each render, while the app keeps handling events and rendering, as Playwright's assertions wait for the page.

The condition receives the whole screen as a Frame, so it can look for text with frame.text(ansi=False).

A FrozenClock runs in real time while the autopilot waits, so it moves on by however long the condition took to hold, which is different on every run, as is everything the app shows of the time from then on.

RAISES DESCRIPTION
AutopilotTimedOut

From app, if the condition is still false after timeout of real time.

condition instance-attribute

condition: Callable[[Frame], bool]

timeout instance-attribute

timeout: timedelta

counterweight.autopilot.AutopilotTimedOut

A WaitFor's condition was still false when its timeout passed.

counterweight.asciicast.Recording dataclass

Recording(
    path: Path,
    title: str | None = None,
    idle_time_limit: timedelta | None = None,
    theme: Theme | None = None,
    input: bool = False,
)

Record the app, as app's recording, to an asciicast v3 file at path, whether it's headless or not, which asciinema-player and asciinema play play back.

The recording holds what the app draws, as it draws it on a terminal, with each frame's time as the app's clock reads it: with a SystemClock that's real time, and with a FrozenClock only the autopilot moves it, so an autopilot can record a minute of an app in milliseconds, the same on every run unless it waits in real time. Marker marks a point in it that players can jump to.

While the app is suspended, the recording can't see what the program it hands the terminal to draws, so the time it spends suspended is cut out, and a suspended marker shows where.

Recording input records everything typed

With input, the recording holds every key press, paste and mouse event the app handles, including passwords and tokens typed into it, in a file that's made to be shared. That's why it's off by default, and stays off under an autopilot, whose input is safe to share: turn it on yourself when you know what will be typed.

path instance-attribute

path: Path

Where to write the recording, creating its parent directories. It's overwritten if it exists.

title class-attribute instance-attribute

title: str | None = None

The recording's title, which players show.

idle_time_limit class-attribute instance-attribute

idle_time_limit: timedelta | None = None

The longest a player waits between events, cutting longer pauses short.

theme class-attribute instance-attribute

theme: Theme | None = None

The colors to play the recording in, where the app leaves the color to the terminal.

input class-attribute instance-attribute

input: bool = False

Whether to record the input the app handles, which players can show as it's typed.

counterweight.asciicast.Theme dataclass

Theme(
    foreground: Color,
    background: Color,
    palette: tuple[Color, ...],
)

The colors a player draws a recording in, where the app leaves the color to the terminal (TERMINAL_DEFAULT and IndexedColor), as the header's term.theme. Without one, a player draws those colors in its own theme.

RAISES DESCRIPTION
ValueError

If palette holds other than 8 or 16 colors.

foreground instance-attribute

foreground: Color

background instance-attribute

background: Color

palette instance-attribute

palette: tuple[Color, ...]

The first 8 or 16 indexed colors, from black to bright white.

__post_init__

__post_init__() -> None