Skip to main content

Record and replay TUI sessions

Replay turns a stream of terminal events into a repeatable session. Use it to reproduce a bug, run a deterministic demo, compare performance, or inspect what the renderer did at a particular moment.

What replay is useful for

Replay automation allows you to:

  • Record and replay user input programmatically
  • Convert trace logs into replay scenarios
  • Run deterministic demos and tests with precise timing control
  • Capture and inspect render state during playback
  • Block manual input during replay for consistent profiling

The system integrates with the TUI runtime through ProgramOptions.replay, with optional input blocking and custom event hooks.

Quick Start

import 'package:artisanal/tui.dart';

void main() async {
// Scripted replay with timed key presses
final replay = ProgramReplay.script([
ProgramReplayStep(
after: Duration(milliseconds: 500),
msg: KeyMsg(Key(KeyType.runes, runes: [0x61])), // 'a'
),
ProgramReplayStep(
after: Duration(milliseconds: 100),
msg: QuitMsg(),
),
]);

await runProgram(
MyModel(),
options: ProgramOptions(replay: replay),
);
}

Replay Protocol

ProgramReplay

The ProgramReplay class provides two factory constructors for different replay sources:

// Scripted replay from timed steps
final scriptReplay = ProgramReplay.script([
ProgramReplayStep(after: Duration(seconds: 1), msg: KeyMsg(...)),
ProgramReplayStep(after: Duration(seconds: 2), msg: QuitMsg()),
]);

// Live stream replay
final controller = StreamController<Msg>();
final streamReplay = ProgramReplay.stream(controller.stream);

ProgramReplayStep

One timed step for script replay:

final step = ProgramReplayStep(
after: Duration(milliseconds: 500),
msg: KeyMsg(Key(KeyType.enter)),
);

ProgramOptions Integration

final program = Program(
MyModel(),
options: ProgramOptions(
replay: ProgramReplay.script([...]),
blockInputWhileReplay: true, // Ignore manual input during replay
),
);

Replay Scenario

A replay scenario is a JSON document containing actions that can be played back:

final scenario = ReplayScenario(
name: 'demo_run',
description: 'Automated demo workflow',
screen: const ReplayScreen(width: 80, height: 24),
actions: [
ReplayAction(type: 'text', value: 'hello'),
ReplayAction(type: 'special', key: 'enter'),
ReplayAction(type: 'sleep', ms: 100),
],
);

// Convert to ProgramReplay
final replay = scenario.toProgramReplay(
loop: false,
keepOpen: false,
speed: 1.0,
);

Action Types

TypeDescriptionFields
textKeyboard text inputvalue, repeat
specialSpecial keys (enter, tab, arrows)key
wheelMouse wheeldirection (up/down/left/right), x, y, repeat
tapMouse clickx, y, repeat
moveMouse motionx, y
dragMouse drag gesturex, y, x2, y2, steps
sleepTiming delayms
eventCustom eventeventType, eventFields

Replay Event History Widgets

The artisanal_widgets package provides widgets for displaying replay events.

ReplayEventPanel

Shows a single replay event's details:

ReplayEventPanel(
presentation: event.presentation,
title: 'Replay Event',
maxDetailLines: 4,
);

ReplayEventHistoryPanel

Shows a filtered history of replay events:

ReplayEventHistoryBrowser(
events: eventHistory,
state: historyState,
onStateChanged: (newState) => historyState = newState,
);

Filter modes:

  • ReplayEventHistoryFilter.all - All events
  • ReplayEventHistoryFilter.renderCaptures - Only render capture events
  • ReplayEventHistoryFilter.custom - Only custom events

Display modes:

  • ReplayEventHistoryMode.flat - Show each event individually
  • ReplayEventHistoryMode.grouped - Group similar events

Trace Logging

Enable trace logging with environment variables:

# Enable trace logging
export ARTISANAL_TUI_TRACE=1

# Set trace file path
export ARTISANAL_TUI_TRACE_PATH=./traces/run.log

# Enable dispatch capture diagnostics
export ARTISANAL_TUI_TRACE_CAPTURE=1

# Filter by trace tags
export ARTISANAL_TUI_TRACE_TAGS=input,dispatch,render

When ARTISANAL_TUI_TRACE_PATH is unset, traces are written to ./traces/artisanal-YYYY-MM-DDTHH-MM-SS.log.

Trace Event Format

Trace events use a structured JSON format:

[+123456us] [input] @event {"v":1,"type":"input.batch","messages":[...]}

Macro Recorder/Player

Record user input during a live session and replay it later:

final program = Program(MyModel());

// Start recording
program.startMacroRecording();
// ... user interacts ...
final macro = program.stopMacroRecording();

// Replay the macro
await Program(
MyModel(),
options: ProgramOptions(replay: macro.toReplay()),
);

Macro to Replay Conversion

class ProgramMacro {
final List<ProgramReplayStep> steps;

ProgramReplay toReplay({bool loop = false}) =>
ProgramReplay.script(steps, loop: loop);
}

Replay Scenarios and Conversion

Trace to Replay Conversion

Convert a trace log to a replay scenario:

final result = await ReplayTraceConverter.convertFile(
path: tracePath,
options: const ReplayTraceConversionOptions(
name: 'converted_scenario',
description: 'Converted from trace log',
screenWidth: 120,
screenHeight: 32,
minSleepUs: 30000, // Minimum gap to preserve
includeHoverMoves: false, // Skip hover-only mouse moves
includeCustomEvents: true, // Include custom trace events
),
);

// Save the scenario
await result.scenario.save('scenarios/converted.json');

Conversion Result

class ReplayTraceConversionResult {
final String tracePath;
final ReplayScenario scenario;
final int eventCount;
final int actionCount;
final int skippedCount;
final int inferredScreenWidth;
final int inferredScreenHeight;
}

TUI Render Capture Optimization

ProgramRenderCapture

Capture structured render state during replay:

class ProgramRenderCapture extends ProgramInterceptor {
// Most recent snapshot
ProgramRenderSnapshot? get lastSnapshot;

// Build a structured payload
ProgramRenderCapturePayload payload({String prefix = 'Render'}) =>
payload(prefix: prefix, maxFrameLines: 3);
}

ProgramRenderCapturePayload

class ProgramRenderCapturePayload {
final ProgramRenderStats stats;
final ProgramRenderCaptureReport report;
final ProgramRenderSnapshot? lastSnapshot;
final ProgramRenderSnapshotSummary? lastSnapshotSummary;
}

ReplayRenderCaptureEvent

Typed view of a render-capture replay event:

final customEvent = ReplayCustomEvent(
type: 'runtime.render_capture',
fields: payload.toJson(),
);

final renderCapture = customEvent.renderCapture; // ReplayRenderCaptureEvent?
if (renderCapture != null) {
print(renderCapture.presentation.summary);
}

Custom Event Hooks

Handle custom events during replay with an event hook:

final replay = ProgramReplay.stream(
replayScenarioStream(
actions,
eventHook: (event) async {
if (event.type == 'runtime.render_capture') {
final payload = event.renderCapturePayload;
if (payload != null) {
// Inspect render state during replay
print('Render at generation ${payload.lastSnapshotSummary?.renderGeneration}');
}
return ReplayEventDirective.proceed;
}
return ReplayEventDirective.emit([ReplayEventMsg(event)]);
},
),
);

Event Hook Return Values

// Continue with default behavior
ReplayEventDirective.proceed

// Continue with custom messages
ReplayEventDirective.emit([Msg1(), Msg2()])

// Stop replay
ReplayEventDirective.stop([Msg1()])

// Stop and quit
ReplayEventDirective.quit([Msg1()])

Coordinate Scaling

Mouse coordinates are scaled from the source screen dimensions to the current terminal size:

final interceptor = ReplayCoordinateInterceptor(
sourceWidth: 80,
sourceHeight: 24,
sourceRightFixedWidth: 0, // Right pane width for anchor scaling
);

This ensures mouse clicks and drags work correctly regardless of terminal size differences.

OpenCode Example Workflow

Record a manual run, convert to replay, and playback deterministically:

# 1. Record a manual run with tracing
ARTISANAL_TUI_TRACE=1 \
ARTISANAL_TUI_TRACE_CAPTURE=1 \
ARTISANAL_TUI_TRACE_PATH="traces/manual-$(date +%Y-%m-%dT%H-%M-%S).log" \
dart run my_app.dart

# 2. Convert trace to replay scenario
dart run my_app.dart \
--replay-trace "$LATEST_TRACE" \
--replay-trace-out scenarios/manual_from_trace.json \
--replay-trace-name manual_from_trace \
--replay-convert-only

# 3. Replay deterministically (with input blocking)
dart run my_app.dart \
--replay-scenario scenarios/manual_from_trace.json \
--replay-block-input \
--replay-speed 8

Trace Analysis

Analyze trace hotspots after a run:

python analyze_trace.py "$LATEST_TRACE" --top 12

Reference

Core Classes

ClassDescription
ProgramReplayMessage replay source for ProgramOptions.replay
ProgramReplayStepTimed replay step for script mode
ProgramMacroRecorded user input macro
ReplayScenarioJSON-serializable replay document
ReplayActionSingle action in a scenario
ReplayCustomEventStructured custom event
ReplayEventPresentationPresentation model for UI/debug
ReplayRenderCaptureEventTyped render-capture view

Interceptor Classes

ClassDescription
ReplayCoordinateInterceptorScales mouse coordinates
ProgramRenderRecorderRecords render snapshots
ProgramRenderCaptureCombined recorder + monitor

Conversion Classes

ClassDescription
ReplayTraceConverterConverts traces to scenarios
ReplayTraceConversionOptionsConversion configuration
ReplayTraceConversionResultConversion output

Best Practices

  1. Always include QuitMsg - Replay does not automatically quit; include QuitMsg at the end of your script.

  2. Use blockInputWhileReplay for profiling - This ensures consistent timing by ignoring manual input.

  3. Match screen dimensions - For best mouse coordinate scaling, record traces at the same dimensions you'll replay.

  4. Keep actions meaningful - Focus on essential user actions; skip noisy intermediate states with minSleepUs.

  5. Test with different speeds - Use --replay-speed to find timing-sensitive bugs.

  6. Capture custom events - Use event actions for application-specific state that should be preserved.

  7. Verify determinism - Run the same scenario multiple times to ensure consistent output.

Troubleshooting Determinism

Replay timing differs from original

  • Check terminal size matches the recorded trace
  • Use ReplayCoordinateInterceptor for mouse events
  • Ensure frame tick timing is consistent

Mouse clicks in wrong positions

  • Verify ReplayScreen dimensions in the scenario
  • Use --replay-block-input to prevent terminal interference
  • Check that the UI doesn't dynamically resize during playback

Missing messages during replay

  • Replay messages pass through filters/interceptors; check for filtering
  • Verify the stream isn't being cancelled prematurely
  • tui.md - TUI runtime and Program class
  • testing.md - Testing infrastructure including storms and gauntlet
  • uv.md - UV renderer integration

Replay Harness Mixin

The ReplayHarnessMixin (exported from package:artisanal/tui.dart) wraps trace-to-scenario conversion, child-process spawning, and trace-summary analysis into a reusable mixin so any TUI app can add replay and profile subcommands in ~10 lines.

Quick Start — Auto-Wired Runner

import 'package:artisanal/args.dart' show CommandRunner;
import 'package:artisanal/tui.dart' show HarnessCommandsMixin;

class MyRunner extends CommandRunner<void> with HarnessCommandsMixin {
MyRunner() : super('myapp', 'My TUI app');


String get harnessEntrypointPath => 'bin/myapp.dart';
}

This automatically registers myapp replay and myapp profile subcommands. Running:

# Convert a trace to a replay scenario
dart run bin/myapp.dart replay --replay-trace traces/latest --replay-convert-only

# Run the replay
dart run bin/myapp.dart replay --replay-scenario scenarios/demo.json --replay-speed 8 --replay-block-input

# Profile the replay
dart run bin/myapp.dart profile --replay-scenario scenarios/demo.json

Flags

The harness registers the following CLI flags:

FlagDefaultDescription
--replay-traceTrace log to convert
--replay-scenarioScenario file to load
--replay-scenario-outWhere to write the converted scenario
--replay-speed1.0Speed multiplier
--replay-block-inputfalseIgnore manual input during replay
--replay-loopfalseRestart replay on finish
--replay-keep-openfalseKeep the app alive after replay
--replay-lead-in-ms3500Initial wait before first action
--replay-script-filterbin/*.dartSession filter for multi-session traces
--replay-trace-min-sleep-us30000Minimum trace gap preserved as sleep
--replay-trace-screen-width0Override source screen width
--replay-trace-screen-height0Override source screen height
--replay-trace-fixed-right-width60Right-pane anchor for mouse scaling
--replay-trace-from-usTrim trace before this microsecond
--replay-trace-to-usTrim trace after this microsecond
--replay-trace-include-hoverfalseInclude hover-only mouse moves
--replay-convert-onlyfalseConvert trace without running app
--replay-capture-tracetrueCapture a lightweight trace while replaying
--replay-trace-out.dart_tool/replay/trace.logWhere to write the replay trace
--replay-trace-tagsgeneral,render,layout,paint,scrollTrace tags for capture
--replay-capture-dispatchfalseInclude dispatch capture diagnostics
--replay-summary-count12Number of slowest spans to print
--replay-max-span-us0Fail if the slowest span exceeds this
--replay-timeout-seconds180Kill child process after this timeout

Profile subcommand adds:

FlagDefaultDescription
--profile-profiler-commanddevtools-profilerProfiler executable
--profile-artifact-dir.dart_tool/profileWhere to write artifacts
--profile-clean-artifact-dirtrueDelete artifact dir before profiling
--profile-regiontrueMark replay window as profile region
--profile-region-nameapp.replayProfile region name
--profile-timeout-seconds240Kill profiler after this timeout

Override Points

Override these in a command or runner that mixes in ReplayHarnessMixin / ProfileHarnessMixin:

MethodTypeDefaultPurpose
harnessEntrypointPathString (getter)requiredPath to the app entrypoint
buildAppSpecificReplayArgs(config, scenarioPath) → List<String>[]App-specific CLI args (e.g. --limit, --view)
customizeReplayEnvironment(config) → Map<String, String>?nullOverride child-process env vars
customizeReplayScenario(scenario, path) → Future<Scenario?>nullTransform the scenario before execution
resolveTracePath(path) → Stringfile-exists checkResolve --replay-trace paths
tryResolveTracePath(path) → Future<String?>traces/ searchResolve latest alias to newest trace
resolveScenarioPath(path) → Stringfile-exists checkResolve --replay-scenario paths
tryResolveScenarioPath(path) → Future<String?>nullResolve special scenario paths (e.g. issuesscenarios/issues_scroll_detail.json)
onReplayPrepared(prepared) → voidLog or inspect the prepared scenario
onReplayCompleted(prepared, exitCode, …) → voidPost-replay logging / summary
enableReplayHarnessbool (getter)trueGate for auto-adding replay subcommand
enableProfileHarnessbool (getter)trueGate for auto-adding profile subcommand

ProfileHarnessMixin adds:

MethodTypeDefaultPurpose
profileProfilerCommandString (getter)requiredProfiler executable name
profileArtifactDirString (getter)requiredArtifact directory path
profileRegionNameString (getter)requiredProfile region identifier
profileEventPrefixString (getter)profile.harnessEvent type prefix for profile regions
profileRegionMetadata(scenarioPath) → Map{}Extra fields on the start event
buildProfileArgs(config, scenarioPath) → List<String>defaultsFull profiler command arguments
buildDevtoolsProfilerRunArgs(config, scenarioPath) → List<String>devtools argsArgs after the profiler executable
onProfileCompleted(config, exitCode) → voidPost-profile logging

Standalone Helpers

For apps that don't use the auto-wired commands, these top-level utilities provide the same functionality:

FunctionSignatureDescription
loadReplayPlan()({required ReplayHarnessConfig config, Future<String?> Function(String)? resolveScenarioPath}) → Future<ResolvedReplay>Load and resolve a replay plan from the given config. Parses the scenario, processes traces, and returns a ResolvedReplay ready for execution.
registerReplayFlags()Extension ReplayFlagsArgParser on ArgParserRegisters all --replay-* flags.
registerProfileFlags()Extension ProfileFlagsArgParser on ArgParser with optional defaultsRegisters all --profile-* flags. Defaults: profilerCommand = 'devtools-profiler', artifactDir = '.dart_tool/profile', regionName = 'app.replay'.
import 'package:artisanal/args.dart' show ArgParser;
import 'package:artisanal/tui.dart'
show registerReplayFlags, registerProfileFlags, loadReplayPlan;

final parser = ArgParser()
..registerReplayFlags()
..registerProfileFlags();
final config = ReplayHarnessConfig.fromArgResults(parser.parse(args));
final resolved = await loadReplayPlan(config: config);

References

  • pkgs/artisanal/example/tui/examples/harness_demo/main.dart — minimal example (10 lines of app code).
  • pkgs/artisanal/lib/src/tui/replay_harness_mixin.dart — source of ReplayHarnessMixin, ProfileHarnessMixin, HarnessCommandsMixin.