For how termapy was built (and the role LLM tooling played), see On AI assistance.
Termapy is built on its own plugin system. Built-in commands (/help, /cfg, /grep, /proto, etc.) are regular plugins loaded from builtins/plugins/. The same Command + PluginContext API that implements the core REPL is available to user plugins. Drop a .py file in a folder to add commands, override builtins, or build device-specific tools. No compilation or registration required.
src/termapy/
├── app.py # (4264 lines) Textual TUI - UI, modals, app hooks
├── cli.py # (981 lines) Plain-text CLI frontend - CLITerminal class
├── serial_engine.py # (558 lines) Serial connection lifecycle, reader loop orchestrator
├── serial_port.py # (302 lines) Serial I/O wrapper + SerialReader data processor
├── capture.py # (336 lines) Capture state machine - text, binary, format spec
├── dialogs.py # (1661 lines) Modal screens - config editor, pickers, confirm
├── proto_debug.py # (1180 lines) Interactive protocol debug screen
├── protocol.py # (1479 lines) Protocol parsing, format specs, CRC, visualizers
├── demo.py # (1586 lines) Simulated device for --demo mode (FakeSerial)
├── repl.py # (1299 lines) REPL engine - dispatch, scripting, transforms
├── plugins.py # (1446 lines) Plugin system - Command, PluginContext, loading
├── help_dynamic.py # (245 lines) Reusable helpers for callable long_help
├── config.py # (648 lines) Config dirs, loading, validation, migration trigger
├── port_control.py # (1404 lines) Pure serial port control functions - no Textual
├── proto_runner.py # (287 lines) Protocol test script runner
├── scripting.py # (278 lines) Pure functions - templates, duration parsing, ANSI
├── migration.py # (239 lines) Config schema migration chain (v1->v8)
├── defaults.py # (467 lines) DEFAULT_CFG, templates
├── help/ # Markdown help pages (source for MkDocs)
├── html/ # Generated HTML help (MkDocs Material output)
├── builtins/
│ ├── plugins/ # 24 built-in REPL command plugins
│ ├── viz/ # Built-in packet visualizers (hex, text)
│ ├── crc/ # Built-in CRC plugins (sum8, sum16)
│ └── demo/ # Demo config, scripts, proto files, plugins
└── help.md # Legacy single-page help (bundled)
The plugin system is the central abstraction. Everything flows through it.
A Command declares a REPL command: its name, args, help text, handler function, and optional subcommands:
COMMAND = Command(
name="cfg",
args="{key {value}}",
help="Show or change config values.",
handler=_handler,
sub_commands={
"auto": Command(args="<key> <value>", help="Set immediately.", handler=_handler_auto),
"configs": Command(help="List all config files.", handler=_handler_configs),
"ss": Command(help="List ss/ files.", handler=_handler_ss,
sub_commands={
"explore": Command(help="Open ss/ in explorer.", handler=...),
"clear": Command(help="Delete all ss/ files.", handler=...),
}),
},
)The subcommand tree is flattened at registration into dotted names (cfg.auto, cfg.ss.explore) that the dispatch system looks up directly. The /help command walks the tree to show hierarchical output.
Every handler receives a PluginContext, the stable API boundary between plugins and the app:
Output: ctx.write(), ctx.write_markup(), ctx.notify()
Config: ctx.cfg, ctx.config_path
Serial port: ctx.port() - raw pyserial object (or None)
ctx.is_connected()
Serial I/O: ctx.serial_write(), ctx.serial_read_raw(), ctx.serial_drain()
ctx.serial_wait_idle(), ctx.serial_io() (context manager)
Filesystem: ctx.ss_dir, ctx.scripts_dir, ctx.proto_dir, ctx.cap_dir
Interaction: ctx.confirm(), ctx.clear_screen(), ctx.open_file()
Dispatch: ctx.dispatch() - route a command through the full pipeline
Namespaces: ctx.ns(name) - session-scoped state (see below)
Engine: ctx.engine - internal/unstable API for built-ins
External plugins use PluginContext only. EngineAPI is internal and may change.
ctx.ns(name) returns a session-scoped dict, created lazily on first access, shared across every call with the same name for the lifetime of the PluginContext. It is the supported way for both built-in and third-party plugins to keep per-session state - a sanctioned alternative to monkeypatching ctx or using module-level globals.
Namespaces are plain mutable dicts. They are not persisted (use ctx.cfg for that) and not isolated - any caller can read any namespace. The name is a collision-avoidance convention, not access control, which lets cooperating plugins share state on purpose (a "stats" plugin can walk every namespace and surface counters without the producers knowing it exists).
Built-ins use namespaces as worked examples of the pattern:
ctx.ns("seq") - sequence counters, mutated by {seqN+} template expansion
ctx.ns("target_commands") - device commands imported via /include
ctx.ns("flags") - engine-owned toggles: echo, verbose, hex_mode
The flags namespace is engine-reserved. Third-party plugins should use their own namespace name (conventionally the plugin name, e.g. ctx.ns("myplugin")). The engine's flag defaults are set once at context construction in _build_context; read sites access them with bare key lookups, so a missing key is a construction bug, not silent drift.
Contrast with ctx.engine: EngineAPI holds Textual, threading, and pyserial handles that genuinely cannot be generified. Anything that's just a dict or a flag lives in a namespace instead. Looking at the field list of each is the fastest way to see the distinction - engine is the escape hatch for privileged frontend state, ns() is the uniform state primitive for everything else.
Plugins that need setup, teardown, or per-script reset can export top-level lifecycle functions. There is no Plugin base class and no decorators - a plugin is a module that exports stuff, and lifecycle functions are just more stuff it can export.
on_app_start(ctx) - once after plugins load and ctx is wired, before first dispatch
on_app_stop(ctx) - once during graceful shutdown (not guaranteed on crash)
on_script_start(ctx) - when the outermost script begins (nested /run does NOT fire)
on_script_stop(ctx) - when the outermost script ends, including on /stop or error
Script hooks fire only at the top level - nested /run inside a running script does not re-fire on_script_start. A plugin that clears state in on_script_start will not have its state wiped by inner scripts. Plugins that need per-file nesting can track depth themselves via ctx.engine.in_script().
Hooks are stored in a flat list in load order (ReplEngine._lifecycle_hooks). fire_lifecycle(name) filters by name and calls matching handlers in registration order, catching exceptions per-hook so one bad plugin can't prevent later hooks from running. Errors surface through ctx.status().
Example use: the seq plugin (below) owns its counter state in ctx.ns("seq") and wires on_script_start to clear it, so scripts start with a clean counter set without ReplEngine knowing anything about sequence counters. This is the pattern to follow for any plugin with session-scoped state that needs lifecycle management.
1. builtins/plugins/ - 22 built-in commands (shipped with termapy)
2. termapy_cfg/plugins/ - user plugins (all configs on this machine)
3. termapy_cfg/<name>/plugins/ - per-config plugins (one config only)
4. App hooks (app.py/cli.py) - commands needing frontend access (ss, run, delay, etc.)
A user plugin with the same name as a built-in replaces it. App hooks override everything; they need direct access to frontend-specific features (Textual widgets in TUI, readline in CLI).
A Transform rewrites command text after the REPL/serial routing decision. Separate chains for REPL commands and serial commands. Used by the var plugin to expand $(NAME) placeholders and by env to expand $(env.NAME):
TRANSFORM = Transform(
name="var",
help="Expand $(NAME) placeholders from user-defined variables.",
repl=expand_vars,
serial=expand_vars,
)A Directive intercepts raw input lines before REPL/serial routing, before transforms, before prefix checking. Used for syntax that doesn't fit the /command pattern. Returns a DirectiveResult with an action (rewrite, warn, error, or none):
DIRECTIVE = Directive(
name="var_assign",
help="Assign user variables with $(NAME) = value syntax.",
pattern="$(NAME) = value",
handler=_directive_var_assign, # returns DirectiveResult
)Currently the only directive is var_assign which rewrites $(PORT) = COM7 into var.set PORT COM7. The directive system exists so this logic lives in the plugin rather than as a hardcoded special case in app.py.
A plugin file may export any of: a COMMAND, a TRANSFORM, a DIRECTIVE, and/or top-level lifecycle functions (on_app_start, on_app_stop, on_script_start, on_script_stop). All are optional; the loader picks up whatever's there.
def _handler(ctx: PluginContext, args: str) -> None:
ctx.write("Hello!")
def on_app_start(ctx: PluginContext) -> None:
ctx.ns("hello")["greeting"] = "Hello!"
# ── COMMAND (must be at end of file) ──────────────────────────────────────────
COMMAND = Command(name="hello", args="{name}", help="Say hello.", handler=_handler)"Must be at end of file" means after all handler functions it references.
There is deliberately no Plugin base class. A plugin is a module that exports stuff; the loader finds what's there. This keeps the mental model one sentence long and avoids the inheritance, decorator, and metaclass traps that creep into most plugin systems. If a plugin needs internal organization, it can use a class inside the module - the module boundary is the plugin boundary.
┌──────────────────────────────────────────────────┐
│ app.py - Textual App │
│ ┌─────────────┐ ┌──────────┐ ┌──────────────┐ │
│ │ Title Bar │ │ RichLog │ │ Bottom Bar │ │
│ │ (?,#,Cfg, │ │ (serial │ │ (Input, SS, │ │
│ │ Port, │ │ output) │ │ Scripts,Cap,│ │
│ │ Status) │ │ │ │ Proto,Exit) │ │
│ └─────────────┘ └──────────┘ └──────────────┘ │
│ ┌──────────────────────────────────────────┐ │
│ │ dialogs.py - Modal Screens │ │
│ │ ConfigPicker, ConfigEditor, PortPicker, │ │
│ │ ScriptPicker, NamePicker, ConfirmDialog │ │
│ └──────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────┐ │
│ │ proto_debug.py - Proto Debug Screen │ │
│ │ Interactive send/expect with visualizers │ │
│ └──────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────┐ │
│ │ App Hooks - commands needing Textual │ │
│ │ ss, run, delay, cfg.load, edit, help.open│ │
│ └──────────────────────────────────────────┘ │
├──────────────────────────────────────────────────┤
│ serial_engine.py - SerialEngine │
│ • Owns SerialPort, SerialReader, CaptureEngine │
│ • connect() / disconnect() / read_loop() │
│ • Callback-driven - no Textual dependency │
├──────────────────────────────────────────────────┤
│ serial_port.py - SerialPort + SerialReader │
│ • SerialPort: write, read_raw, drain, idle wait │
│ • SerialReader: bytes → lines, EOL, ANSI, clear │
│ • Works with real serial.Serial or FakeSerial │
├──────────────────────────────────────────────────┤
│ capture.py - CaptureEngine │
│ • start/stop/feed_bytes/feed_text/get_progress │
│ • Format spec decoding, CSV writing, echo │
│ • No Textual dependency - fully testable │
├──────────────────────────────────────────────────┤
│ repl.py - ReplEngine │
│ • dispatch_full() - full command routing │
│ • dispatch() - REPL command → plugin handler │
│ • Script runner with nested /run support │
│ • fire_lifecycle() - run on_*_start/stop hooks │
├──────────────────────────────────────────────────┤
│ plugins.py - Plugin System │
│ • Command - declares name, args, handler, subs │
│ • Transform - post-routing text rewriters │
│ • Directive / DirectiveResult - pre-routing │
│ • LifecycleHook - on_app/script_start/stop │
│ • PluginContext - stable API for all plugins │
│ • ctx.ns(name) - session-scoped state dicts │
│ • PluginInfo - flattened metadata + handler │
│ • EngineAPI - Textual/threading/serial handles │
│ • load_plugins_from_dir() - file discovery │
├──────────────────────────────────────────────────┤
│ protocol.py - Protocol Engine │
│ • Format spec language (H, U, I, S, F, B, CRC) │
│ • ProtoScript / TestCase - test data model │
│ • 62 CRC algorithms + plugin CRC loading │
│ • Visualizer loading and column rendering │
│ • diff_bytes() / diff_columns() - comparison │
├──────────────────────────────────────────────────┤
│ config.py - dirs, loading, validation │
│ defaults.py - DEFAULT_CFG, templates │
│ migration.py - schema migration v1→v8 │
│ scripting.py - pure functions, no state │
│ demo.py - simulated device for --demo │
│ proto_runner.py - protocol test execution │
└──────────────────────────────────────────────────┘
termapy --cli runs a plain-text terminal without Textual. It shares the same ReplEngine, SerialEngine, PluginContext, and all built-in plugins. The difference is how the frontend wires PluginContext callbacks:
| Callback | TUI (app.py) | CLI (cli.py) |
|---|---|---|
ctx.write() |
RichLog.write(Text(...)) |
Rich Console.print() |
ctx.confirm() |
Modal dialog + event.wait() |
input() prompt |
ctx.open_file() |
open_with_system() |
open_with_system() |
ctx.port() |
self.ser (via SerialEngine) |
engine.serial_port.port |
/delay |
set_timer() (non-blocking) |
time.sleep() + progress bar |
CLI-specific features: readline tab completion, shared command history, /color on|off toggle. CLI limitations: no /grep (no scrollback buffer), no /edit.cfg (no config editor modal).
SerialEngine.read_loop() [background thread]
→ serial.read() → rx_queue.put(data)
→ SerialReader.process(data) → ReaderResult
→ binary capture active? → CaptureEngine.feed_bytes() → skip display
→ proto_active? → suppress display
→ decode(encoding) → split on \n → batch lines
→ callbacks: on_lines → call_from_thread → RichLog
on_clear → clear screen
on_capture_done → stop capture
on_error → status message
Input.on_submit → _execute_command()
→ split on \n (multi-command)
→ _dispatch_single() → repl.dispatch_full()
→ /raw? → serial_write_raw (bypass everything)
→ run_directives() → rewrite/warn/error
→ starts with prefix? → apply transforms → repl.dispatch()
→ lookup dotted name → call handler(ctx, args)
→ else → apply serial transforms → serial_write(encoded bytes)
/cap.struct → CaptureEngine.start(path, mode, target, columns, ...)
→ SerialReader feeds bytes via CaptureEngine.feed_bytes()
→ on each record: apply format spec → write CSV row
→ on target reached: CaptureEngine.stop() → CaptureResult
cmd= sends device trigger after capture starts + drain
/run script.run → _run_script [background thread]
→ post ScriptStarted → mount overlay
→ repl.run_script() processes lines:
→ /delay → Event.wait (stop-aware)
→ /run nested.run → inline recursive call (up to 5 deep)
→ /confirm → dialog via call_from_thread
→ other → dispatch callback → _dispatch_single
→ post ScriptProgress → update overlay label
→ post ScriptFinished → teardown overlay
Input disabled during execution, Escape or Stop button aborts
termapy_cfg/
├── plugins/ # user plugins (all configs)
└── <name>/
├── <name>.cfg # JSON config file
├── <name>.log # session log
├── <name>.md # info report (from /cfg.info)
├── .cmd_history.txt # command history
├── plugins/ # per-config plugins
├── ss/ # screenshots (SVG + TXT)
├── scripts/ # .run script files
├── proto/ # .pro protocol test scripts
├── viz/ # per-config packet visualizers
└── cap/ # data capture output files
cfg_data_dir() auto-creates all subdirs on access. Old captures/ folders are auto-renamed to cap/.
┌─────────────────────┐
│ Main thread │ Textual event loop - all UI updates
│ (async) │ dispatch, modals, button handlers,
│ │ Message handlers (ScriptStarted, etc.)
├─────────────────────┤
│ _run_reader() │ Long-lived background thread
│ @work(thread=True) │ Calls SerialEngine.read_loop()
│ │ Callbacks post to main via call_from_thread
├─────────────────────┤
│ _run_script() │ Short-lived per script/command
│ @work(thread=True) │ Blocking commands (/delay, /confirm)
│ │ must run here, not on main thread
│ │ Nested /run executes inline (same thread)
├─────────────────────┤
│ _auto_reconnect() │ Short-lived, retries connection
│ _send_test() │ Short-lived, protocol test case
│ _run_cmds() │ Short-lived, setup/teardown commands
└─────────────────────┘
At most two workers run concurrently: the serial reader plus one command/script/test worker. call_from_thread posts UI updates back to the main thread. post_message is used for script lifecycle events (thread-safe).
| Plugin | Command | Purpose |
|---|---|---|
| cap.py | /cap | Unified data capture (text, bin, struct, hex) |
| cfg.py | /cfg | Config values, info, explore, per-folder file ops |
| cls.py | /cls | Clear terminal |
| confirm.py | /confirm | Yes/Cancel dialog (scripts) |
| echo.py | /echo | Toggle command echo |
| edit.py | /edit | Open project files (scripts, proto, plugins, cfg) |
| env_var.py | /env | Environment variable management |
| eol.py | /show_line_endings | Toggle line ending markers |
| exit.py | /exit | Quit the app |
| grep.py | /grep | Search scrollback (TUI only) |
| help.py | /help | Colorized command listing and help |
| os_cmd.py | /os | Run shell commands |
| port.py | /port | Serial port control (17 subcommands) |
| print.py | Print to terminal | |
| proto.py | /proto | Binary protocol tools |
| run_edit.py | /run.edit | Open .run scripts in system editor |
| seq.py | /seq | Sequence counters |
| show.py | /show | Display files |
| ss.py | /ss | Screenshots (TUI only, stub in CLI) |
| stop.py | /stop | Abort running script |
| var.py | /var | User variables |
| ver.py | /ver | Show termapy version |
52 test files, 1993 tests, 67% overall coverage:
| File | Covers |
|---|---|
| test_protocol.py | Format specs, CRC, visualizers, diff |
| test_engine.py | ReplEngine dispatch, dispatch_full, scripting |
| test_capture.py | CaptureEngine lifecycle, text/bin/hex, progress |
| test_serial_port.py | SerialPort I/O, SerialReader data processing |
| test_serial_engine.py | SerialEngine connect/disconnect, read_loop |
| test_app_config.py | Config utilities, custom buttons, templates |
| test_scripting.py | Template expansion, duration parsing |
| test_plugins.py | Plugin loading, context API |
| test_builtins.py | Built-in command handlers |
| test_repl_cfg.py | Config change mechanics |
| test_migration.py | Config schema migration |
| test_demo.py | Demo device simulation (FakeSerial) |
| test_var.py | User variable system |
| test_env_var.py | Environment variable commands |
| test_port_control.py | Serial port control pure functions |
| test_proto_runner.py | Protocol test runner |
| test_proto_send_crc.py | CRC in proto.send |
| test_resolve_config.py | Config resolution chain (16 tests) |
| test_cli_gold.py | CLI gold-standard integration test |
| test_vfs.py | Demo VFS: file list, info, delete, isolation |
| test_xmodem.py | XMODEM transfer, QueueByteReader, FakeSerial |
| test_crc_builtins.py | sum8/sum16 checksum modules |
| test_ymodem.py | YMODEM transfer, batch send, FakeSerial |
app.py, proto_debug.py, and dialogs.py are not unit tested; UI is tested manually. The serial engine, capture, reader, and dispatch layers are fully testable using FakeSerial.