| name | drive-tui |
| description | Drive interactive terminal (TUI) programs — CLIs, REPLs, installers, menu apps, agent CLIs, and editors like vim — through a PTY, reading semantic screen snapshots. A pattern library classifies a screen (REPL, menu, pager, fzf search, confirm dialog, form, spinner, wizard) and drives it with a ready recipe. Use when a program expects a live terminal (arrow-key menus, prompts, spinners, password fields, curses UIs), or when a piped command hangs or prints nothing. |
| allowed-tools | Bash, Read |
| version | 0.2.3 |
drive-tui
Drive interactive terminal programs the way a human does: look at the screen, decide, press keys, wait, look again. A normal subprocess/pipe can't do this — line-buffered pipes hide menus, spinners, and prompts, and many programs refuse to run without a real TTY. This skill uses smartcli_core (a PTY + pyte screen model + semantic snapshots + readiness waits) behind a thin CLI, scripts/tui.py.
When to use
- Launching a program that draws a full-screen or line-mode UI and expects keystrokes (REPLs, installers,
vim, agent CLIs, arrow-key menus, y/n prompts, password entry).
- A piped command hangs, prints nothing, or says "not a terminal".
- You need to see what an interactive program is showing right now, then act on it.
- Do NOT use for non-interactive commands whose full output you just want captured — run those directly.
Core mental model: the perceive → decide → act loop
Never fire keystrokes blind and never guess with sleep. Every interaction is one turn of this loop:
- Perceive — take a snapshot. Read the header line (cursor position, selected row, status bar, errors) and the row-numbered body.
- Decide — classify the screen: is it a menu, a text prompt, a confirmation, a spinner still loading, an error, or a finished result? Decide the single next action.
- Act — translate intent into key tokens and send them (
send-line, send-text, or keys).
- Wait — call
wait / wait-regex. NEVER sleep. The wait pumps output until the screen settles or an expected marker appears.
- Confirm — re-snapshot and verify the screen changed as expected before the next action.
Setup
scripts/tui.py adds the repo root to sys.path itself, so run it from anywhere. It locates smartcli_core via smartcli_bootstrap.locate_core(): $SMARTCLI_ROOT → walk up every parent of the script for a dir containing smartcli_core/__init__.py → a bundled _vendor/ next to the skill → an existing pip install (there is no fixed "N levels up"). It also needs the deps pyte + pywinpty (Windows) importable. Set SMARTCLI_ROOT to override, or run tui.py doctor to report where the core resolved and which deps are missing.
All commands below are shown from the repo root. Use the exact form:
python skills/drive-tui/scripts/tui.py <subcommand> ...
Two ways to drive
A. Persistent session (interactive, multi-turn) — the default
A detached daemon owns one live program; each command connects over a localhost-only socket, so state survives across separate shell calls. This is the real perceive→decide→act loop.
- Start (prints a session id — capture it):
python skills/drive-tui/scripts/tui.py start --cmd "python3 -i -q" --cols 100 --rows 30
(On Windows use --cmd "py -i -q".) Optional: --id NAME picks your own session
id (1-64 chars, [A-Za-z0-9][A-Za-z0-9_.-]*); --cwd DIR sets the program's
working directory; repeatable --env KEY=VALUE adds environment variables
(KEY must be a valid identifier and may not start with SMARTCLI_TUI_).
run (mode B) accepts --cwd/--env too. Size caps: 1000 cols, 500 rows,
100000 cells.
- Wait for the first prompt, then snapshot is printed automatically:
python skills/drive-tui/scripts/tui.py wait-regex --id <SID> ">>> " --timeout-ms 15000
- Act, then wait, then it re-snapshots:
python skills/drive-tui/scripts/tui.py send-line --id <SID> "print(6*7)"
python skills/drive-tui/scripts/tui.py wait --id <SID>
- Snapshot any time without acting:
python skills/drive-tui/scripts/tui.py snapshot --id <SID>
Add --json for the structured Snapshot.to_json() form (selected span, menu_items, errors).
- Check liveness / list / close:
python skills/drive-tui/scripts/tui.py alive --id <SID> (exit 0 alive, 1 dead)
python skills/drive-tui/scripts/tui.py list
python skills/drive-tui/scripts/tui.py close --id <SID> (always close when done)
close REFUSES and exits 1 if the request failed but the daemon's pid is still
alive — a socket timeout is not proof of death, and that registry file is the only
store of the token AND the pid, so deleting it would strand a live PTY child.
Retry the verb, or kill the printed pid; --force deletes anyway.
Subcommands: start, snapshot, send-text, send-line, keys, wait, wait-regex, wait-change, wait-visual-change, wait-any, alive, resize, close, list. The wait* verbs print liveness + reason/index to stderr and the snapshot to stdout. wait-change blocks until the screen text changes from a baseline (the "did my action land?" primitive); wait-visual-change is the same but also counts attribute/cursor-only changes (see Choosing the right wait); wait-any races several --pattern regexes (pexpect expect([...]) style) and reports WHICH matched first (index on stderr) — order patterns most-specific-first, since the earliest in the list wins a same-poll tie.
Baseline workflow for the change waits: snapshot prints # hash=<H> visual_hash=<V> alive=<bool> alt_screen=<bool> on stderr — record the hashes BEFORE acting, then use wait-change --baseline-hash <H> or wait-visual-change --baseline-hash <V> after. The two hashes are separate primitives: never feed a hash to wait-visual-change or a visual_hash to wait-change. (Omit --baseline-hash to baseline on the screen at call time.)
snapshot, every wait* verb, start, close, and list accept --json for machine-readable output (alive reports via its exit code instead).
B. One-shot script (batch, non-interactive)
When you already know the whole sequence, run it in a single process against a fresh program. No session to manage. Write a JSON list of steps to a file and run:
python skills/drive-tui/scripts/tui.py run --cmd "python3 -i -q" --steps steps.json --cols 100 --rows 30
(On Windows use --cmd "py -i -q".)
Step objects (executed in order; each wait_*/snapshot prints a snapshot):
{"action":"send_text","text":"..."} — type literally, no Enter.
{"action":"send_line","text":"..."} — type + Enter.
{"action":"send_keys","keys":["Down","Down","Enter"]} — key tokens.
{"action":"wait_ready","marker":"regex"} — wait for marker OR stability (marker optional).
{"action":"wait_regex","pattern":"regex","timeout_ms":10000} — wait strictly for the regex.
{"action":"wait_any","patterns":["regexA","regexB"],"timeout_ms":10000} — race several regexes; reports which matched (index).
{"action":"wait_change","timeout_ms":10000} — wait for any text change (optional "baseline_hash"; defaults to the screen at step time).
{"action":"wait_visual_change","timeout_ms":10000} — as above, but attribute/cursor-only changes count too (optional "baseline_hash").
{"action":"snapshot"} — print the current screen.
C. MCP server (drive from any MCP client)
scripts/mcp_server.py exposes the same verb surface as MCP tools, so any MCP
client (Claude Desktop, an agent framework) can drive TUIs without shelling out.
It reuses the CLI's client layer, so the per-session capability token is
attached automatically and no verb is exposed unauthenticated. Install the
package and run it (stdio transport):
pip install smartcli-toolkit
smartcli-mcp
Tools: start, list_sessions, snapshot, send_text, send_line,
send_keys, wait_regex, wait_change, wait_visual_change, wait_any,
wait_ready, alive, resize, close — a 1:1 map of the daemon verbs. The
same perceive → decide → act loop applies.
Silent / background operation — driving without disturbing the user
By design, driving a CLI here is already headless and non-intrusive — there is no window to hide, and the user's focus is never stolen. This is a property of the PTY model, not an extra feature:
- The program runs inside a pseudo-terminal: an in-memory character grid (ConPTY via
pywinpty on Windows, the stdlib pty on POSIX). The CLI believes it has a real terminal — so it still draws menus, the cursor, colours — but that "terminal" exists only in memory. There is no GUI window, no console window, nothing painted on screen for the user to see or lose focus to.
- The persistent-session daemon is launched fully detached: on Windows with
CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP — no console window is created, so it never steals focus (this deliberately replaced the older DETACHED_PROCESS, which does not prevent a conhost window from flashing up and grabbing focus); on POSIX with start_new_session=True (no controlling terminal). Its stdin/stdout/stderr are all DEVNULL, so it never prints into the user's shell.
- So the loop is invisible: the AI
starts a program, snapshots the in-memory screen, send-keys, wait, snapshot again — all while the user keeps typing in their editor, undisturbed. Nothing pops up, nothing grabs the cursor.
Two intents, both already supported — this is a choice you make, not code you write:
- Silent (default). Just drive it. The AI reads snapshots itself and reports a summary to the user in chat. The user never sees the raw TUI. This is the normal mode and needs nothing special — it is what every example above does.
- Show the user what you saw. Because a snapshot is plain text/ANSI, you can surface it on demand: paste
snapshot output into the reply, or render it to a PNG with tools/screenshot (shot.render_bytes_to_screen → screen_to_png) and show that. The program still runs headless; you are only echoing what the AI perceived, when the user asks.
How to decide (default hidden; you judge the scenario). The default is silent — drive, perceive, summarise; the user does not see the raw TUI. Whether to surface a snapshot is a judgment call you make each time, in this priority order:
- User asked to see it → show it. ("show me the menu", "what does it look like", "paste the screen".) Explicit request always wins.
- The user must choose / the screen is the point → show it. A menu they need to pick from, a diff/preview they should eyeball, an error you can't resolve alone, or when the TUI's appearance is what they care about.
- It's a means to an end → stay silent, report the outcome. If the user wanted a result ("switch the model to Opus", "answer the installer's prompts"), just do it and say what happened — the intermediate screens are noise to them.
- Unsure and it's cheap to show → lean toward a short snapshot; seeing is reassuring. Unsure and it's long/noisy → summarise, and offer ("want to see the raw screen?").
Never open a visible terminal window to "let the user watch" — that is the intrusion the PTY model avoids (and would steal their focus). Echo a snapshot (text or a tools/screenshot PNG) instead.
One real caveat (be honest about it): a few programs behave differently with no real TTY — e.g. ConPTY may delay the first prompt ~3s (use wait-regex with a 15s timeout, never a bare wait on startup), and a bare Ctrl-C can be unreliable under ConPTY (close + re-open instead). These are noted in Constraints / gotchas below.
Reading a snapshot
to_text() output = one header line + row-numbered body:
[screen 24x80] cursor=r3c4 selected=r3[0:80]">>>"(cursor_line) status="..." errors=1
0 | Python 3.14.6 ...
3 | >>>
- Header:
cursor=rNcM, optional alt_screen, selected=..., status="...", title, errors=N, screen_reverse. alt_screen is inserted first among the flags — it changes what an action MEANS: a full-screen program (vim/less/htop) owns the screen, so keys are commands rather than text, the body is that program's frame rather than scrollback, and the token disappears when the primary screen is restored on exit.
- Body:
<row><*>| text. A * marks a selected/highlighted row. Blank runs collapse to ....
selected_line is the key menu signal. In an arrow-key menu the highlighted (reverse-video / colored) row is the current choice — the snapshot surfaces it as the * row and in the header selected. Use it to know where the cursor sits before pressing Up/Down.
Deciding: classify the screen
- Full-screen program (
alt_screen in the header) → the body is that program's frame, not scrollback. Drive with keys (keys q, keys Escape), do not send-line text, and prefer wait-visual-change after navigation keys.
- Prompt (
>>> , $ , Password:, Continue? [y/N]) → send the answer with send-line, or a single key with keys.
- Menu (a
*/selected row, list of options) → move with keys Up/keys Down, choose with keys Enter. Do not type the option text.
- Spinner / progress / still loading → do not act; call
wait again (it caps at --timeout-ms and returns the last screen).
- Error (
errors=N in header, red text, "Traceback"/"failed") → stop and read; do not keep sending input.
- Finished result → capture the snapshot; act or close.
Translating intent to keys (keys subcommand / send_keys)
Named tokens: Enter Tab BackTab Space Backspace Delete Escape Up Down Left Right Home End PageUp PageDown Insert F1–F12.
Combos: C-c / ^C (Ctrl+C), M-x (Alt+x). Anything else is sent literally.
- Menus/keys →
keys. Free text + submit → send-line. Text without submit (fills a field) → send-text.
send-line submits with \r. A raw-mode app that needs \n → use send-text "...\n".
- Slash-commands (
/model, /help, …) on Git Bash / MSYS — use --stdin. A leading / in a native-command argument is rewritten to a Windows path by MSYS path-conversion before Python sees it (/model arrives as D:/Software/Git/model — the tool then faithfully types the garbage). This is a shell quirk, not a tool bug. The robust, shell-agnostic fix is to pipe the text so it never rides in argv: printf '/model' | python … send-line --id <SID> --stdin. (echo -n works too.) Alternatives that also work but are easier to forget: prefix MSYS_NO_PATHCONV=1, or double the slash (//model). Prefer --stdin — it's immune on Git Bash, cmd, PowerShell, and POSIX alike. Do not try to type a slash-command with the keys subcommand: there is no slash token — unknown tokens are typed literally, so keys slash types the word "slash".
Choosing the right wait — do NOT sleep
- Know the exact text that means "ready" (a prompt, a banner) →
wait-regex <pattern>. Prefer this. It watches strictly for the regex and will NOT return early.
- Don't know the marker (screen just needs to settle after a keystroke) →
wait (marker optional): returns on stability or a marker, capped by timeout. Form: wait --id <SID> [--marker REGEX] [--timeout-ms N].
- Sent a navigation key and the menu only moves its highlight bar (attributes change, text stays identical) →
wait-visual-change: it also counts attribute- and cursor-only changes, which wait/wait-change deliberately ignore. For output streams, still prefer wait-change — text-only hashing is immune to blink/cosmetic attribute churn.
- Startup gotcha: a program can sit quiet for seconds before its banner appears (Python's REPL banner arrives ~3s after spawn on Windows ConPTY). Plain
wait/wait_ready may declare STABLE on the still-blank screen during that gap. So for the FIRST prompt after start, use wait-regex with a generous --timeout-ms (e.g. 15000), not bare wait.
- Regex tips: match against the whole screen text. Anchoring with
$ needs multiline semantics — prefer a loose marker like ">>> " over ">>> $". Confirm the result with a follow-up snapshot.
Recovering when a program doesn't respond
- Re-
snapshot — confirm it actually didn't change (you may have missed output).
alive --id <SID> — if dead, the child exited; read the final snapshot for the reason, then close.
- Still alive but stuck: try
keys Enter (dismiss a pager/prompt) or keys Escape (leave a mode).
- Interrupt a running command:
keys C-c. Windows caveat: under ConPTY a raw Ctrl-C byte does NOT reliably raise an interrupt in a line-mode child (verified — time.sleep in the Python REPL was not interrupted). On Windows, if C-c fails, the reliable recovery is close --id <SID> and start a fresh session. On POSIX, C-c works normally.
- Always
close sessions you started (frees the daemon and the child).
- A verb that times out does not mean the daemon died. The accept loop is serial,
so a busy daemon is indistinguishable from a dead one at the socket — that is why
close refuses to delete a live daemon's entry. Retry before concluding anything,
and treat --force as data loss you have chosen.
Constraints / gotchas
- Never blind-
sleep to wait for output — use wait / wait-regex; they pump the PTY and have hard timeouts.
- Always re-snapshot after acting; the snapshot before your keystroke is stale.
- Arrow/nav keys are adaptive:
send_keys reads the live screen's cursor-key mode and emits SS3 (ESC O A) when the program has enabled DECCKM (application cursor keys — most full-screen/curses TUIs), else CSI (ESC [ A). This is auto — you just send keys Up. (Verified on real Linux ncurses; before this, CSI-only arrows silently did nothing in DECCKM apps.) If a rare app still ignores arrows, re-snapshot and fall back to its letter/number shortcuts.
- The daemon binds
127.0.0.1 only — local process control, no network exposure. One
connection can no longer stall the others: the unauthenticated read happens off the
accept path, and fast verbs (snapshot, alive, send-*, keys) are answered even
while a long wait* is in flight. A second LONG wait does queue behind the first —
one session owns one screen; use two sessions if you need two independent waits.
--env may not set SmartCLI's own control variables (SMARTCLI_TUI_*,
SMARTCLI_ROOT, SMARTCLI_MAX_SESSIONS, SMARTCLI_AUTO_INSTALL), compared
case-insensitively. Your own names pass through untouched.
- Session cap:
start refuses beyond 8 concurrent sessions by default (SMARTCLI_MAX_SESSIONS, 1-128). Stale registry files count against the quota — if you hit the limit, list and close dead sessions first.
- The session registry is per-user, never a shared fixed path:
%TEMP%\smartcli_tui (Windows); $XDG_RUNTIME_DIR/smartcli_tui when set, else ~/Library/Caches/SmartCLI/sessions (macOS) or ~/.cache/smartcli/sessions (Linux). SMARTCLI_TUI_DIR overrides it; list reads exactly this directory.
- Paths above are relative to this skill's folder; run the script by its path from the repo root.
- Deeper API details: read
references/core_api.md only when you need field-level specifics.
Known limitations & self-improvement (read when something doesn't work)
This skill is honest about its gaps, and it is built to be extended by the AI using it. If you hit a wall, don't just work around it silently — research it, fix it if you can verify the fix, and record what you learned so the next run benefits.
Where the gaps are tracked: references/LIMITATIONS.md — a short, living log of known-unfixed edges, environment caveats, and fixes that have already landed (with how each was verified). Read it first when a program misbehaves — your problem may already be solved or explained there.
The self-improvement loop (when you hit a genuine limitation):
- Reproduce narrowly. Drive the failing case with the smallest command + snapshot that shows the wrong behavior. Capture the exact bytes/screen, not a guess.
- Research the mechanism. Check
references/LIMITATIONS.md and the knowledge graph (knowledge/INDEX.md) first. If it's a terminal-protocol issue, the cause is usually a mode (DECCKM, bracketed paste, alt-screen) or a timing gap — verify with a tiny probe, don't assume.
- Verify on the real path — never head-canon. A fix is only real if you drove the actual program and saw it work (a green preview or a mocked backend proves nothing). If the fix touches
smartcli_core, it is DO-NOT-MODIFY-except-under-verification: prove it on the real run path, and re-run the drive-probe suite (tests/_drive_probe*.py, tests/_tui_cli_probe.py) to show zero regression. On a non-Windows host, also run tests/_sandbox_posix_backend.py.
- Record it. Append a dated entry to
references/LIMITATIONS.md: the symptom, the root cause, the fix (or "still open, with reason"), and exactly how you verified it. One honest paragraph beats a vague TODO.
- Then continue using the skill with the fix in place.
The point: the skill should get more capable each time it's pushed against a new program, and every future run inherits that — the AI's findings are written back into the tool, not lost in one conversation.
Knowledge base — look before you build
Before scripting a new interaction or pattern, consult the SmartCLI knowledge graph at
knowledge/INDEX.md (repo root). Its tui-patterns/ domain is a 1:1 conceptual
map of the 8 recipes below — [[list-menu-navigation]], [[fuzzy-search-filter]],
[[pager-navigation]], [[confirm-yes-no-dialog]], [[progress-spinner-waiting]],
[[form-field-input]], [[repl-session]], [[wizard-installer-flow]] — plus the sourced
foundations behind every gotcha in this skill: [[application-cursor-mode]] (the exact
arrow-keys-don't-move bug), [[alternate-screen-detection]] (why alt_screen in the
header means the body is a full-screen program's frame), [[quiescence-detection]] and
[[snapshot-stability-hash]] (how wait decides "settled"), [[key-encoding-reference]]
(key tokens → bytes),
[[cursor-row-binding]] and [[verify-movement-step-by-step]] (why matches bind to the
cursor row and every action re-snapshots). Read the relevant note before inventing a
recipe from scratch.
Pattern library — classify a screen, drive it with a recipe
Above is the manual loop through scripts/tui.py. For recurring paradigms there is a Python pattern library (skills/drive-tui/patterns/) that recognizes a screen and drives it for you. It works on smartcli_core Snapshot/PtySession objects, so use it from Python (import patterns), not the tui.py CLI. Add skills/drive-tui to sys.path, or run from there.
Classify / explain a screen
classify(snapshot, threshold=0.05) -> [(Pattern, confidence), ...] — every registered pattern scored 0..1 against the snapshot, highest first. A pattern whose matches() raises is scored 0, never poisoning the ranking.
explain(snapshot) -> str — a human/agent-readable description: raw facts (size, cursor, highlighted row + reason, menu spans, status bar, errors, screen-reverse) followed by the ranked paradigms with confidences.
import sys; sys.path.insert(0, "skills/drive-tui")
from patterns import registry
from patterns.classify import classify, explain
snap = session.snapshot()
print(explain(snap))
best, conf = classify(snap)[0]
The 8 recipes (registry names → tags)
Live catalog: [p.name for p in registry.all_patterns()].
| name | drives | intent | tags |
|---|
repl | interactive REPL/shell prompt | one code line to run | repl, shell, prompt, interactive |
menu_select | arrow-key vertical menu | int index or substring | menu, list, navigation |
pager | less/more/man/git-log | to_end / next_page / search:<term> | pager, scroll |
search_filter | fzf / Ctrl-R / palette | the query string | fuzzy, filter, incremental |
confirm | [y/N] / (yes/no) dialog | bool (or yes/no string) | dialog, yesno |
form | labelled input fields | dict/list/str of field values | form, input |
progress | spinner / progress bar | optional completion regex | spinner, progress, wait |
wizard | multi-step installer | list of per-step actions | wizard, multistep, installer |
Call a recipe
Get the registered singleton and call .drive(session, intent, **kw). It runs the perceive→act→wait→confirm loop against the live session and returns a PatternResult(ok, detail, snapshot, data) — ok is a real screen assertion, snapshot is the last screen as evidence, data is the pattern-specific payload.
from patterns import registry
res = registry.get("repl").drive(session, intent="6*7")
assert res.ok; print(res.data["output"])
registry.get("menu_select").drive(session, intent="Charlie")
registry.get("menu_select").drive(session, intent=0, press=False)
registry.get("pager").drive(session, intent="to_end", max_pages=200)
registry.get("search_filter").drive(session, intent="grapef", stop_at=1, accept=True)
registry.get("confirm").drive(session, intent=True)
registry.get("form").drive(session, intent={"Name": "Ada", "Email": "a@b.c"})
registry.get("progress").drive(session, intent=r"DONE!", max_wait_ms=60000)
registry.get("wizard").drive(session, intent=[
{: [, ]}, {: , : }, ])
Module-level convenience helpers: patterns.recipes.repl_session.run_line(session, code, **kw) and patterns.recipes.paginate.read_all(session, max_pages=200, key="Space") -> (text, PatternResult) (accumulates the pager's full text across pages, dedup'ing repainted boundary rows).
Recipe drive kwargs worth knowing:
repl: expect=<regex> (confirm a marker instead of waiting for STABLE), timeout_ms, quiet_ms, min_wait_ms.
menu_select: max_steps, settle_ms, press (send Enter, default True), enter_wait_ms.
pager: max_pages, key (Space|PageDown|f, default Space; f is the less/more forward-page key).
search_filter: incremental (default True), stop_at (match-count threshold, default 1), accept (Enter to pick), settle_ms.
form: next_key (default Tab), submit (default Enter); intent may be a dict, a list of (label, value), or a single string.
progress: max_wait_ms, quiet_ms (pure observer, sends no keys; returns timeout rather than a false complete if it never settles).
wizard: advance_key (default Enter), max_steps. Each intent item is {"pattern": name, "value": intent}, {"keys": [...]}, or None/{} for auto (classify + best recipe, fallback advance_key).
Recipes fail loud on bad params (empty/mistyped intent raises ValueError) rather than doing something silently wrong.
Add a NEW pattern
Drop one module in patterns/recipes/ — pkgutil auto-imports it and @register wires it into all_patterns/classify. A module that fails to import is logged (stderr + registry.load_errors()) and skipped; it never breaks the rest of the catalog. Avoid import-time side effects that can't fail-soft.
Contract (patterns/base.py):
- Subclass
Pattern; set name (lowercase, unique), description, tags.
matches(self, snapshot) -> float (0..1): cheap, no side effects. Bind signals to the CURSOR ROW and cell attributes (selected, menu_items, status_bar) — NOT bare body text, since prompts and [y/N] strings also echo in scrollback. Use the helpers Pattern.cursor_row_text(snap), Pattern.last_rows_text(snap, n), Pattern.visible_text(snap), Pattern.rx(pat, text, flags).
drive(self, session, intent=None, **kw) -> PatternResult: perceive→act→wait→confirm. NEVER blind-sleep; always re-snapshot after acting; always bound waits (wait_stable/wait_for with a hard ceiling). Return PatternResult(ok, detail, snapshot=<last snapshot>, data={...}) where ok is asserted from the screen.
- Decorate with
@patterns.registry.register above the class.
from smartcli_core import PtySession, Snapshot
from patterns.base import Pattern, PatternResult
from patterns import registry
@registry.register
class TabBar(Pattern):
name = "tabbar"
description = "a top tab bar; switch tabs with the arrow/Tab keys."
tags = ("tabs", "navigation")
def matches(self, snapshot: Snapshot) -> float:
row0 = next((e[1] for e in snapshot.lines if e != "..." and e[0] == 0), "")
return 0.8 if self.rx(r"\[.+\]\s+\[.+\]", row0) else 0.0
def drive(self, session: PtySession, intent=None, **kw) -> PatternResult:
before = session.snapshot()
session.send_keys(["Right"])
session.wait_stable(quiet_ms=120, max_wait_ms=1500)
after = session.snapshot()
moved = after.cursor != before.cursor
return PatternResult(ok=moved, detail="switched tab" if moved else "no move",
snapshot=after, data={: moved})
Fault isolation is verified: a recipe module that crashes at import leaves all other recipes registered, and classify() is safe on a degenerate snapshot.
Worked example — driving the Python REPL (persistent session)
# 1. start -> capture the id it prints
$ python skills/drive-tui/scripts/tui.py start --cmd "python" --cols 80 --rows 24
s21576_90197
# 2. PERCEIVE the first prompt (wait-regex, generous timeout for the ~3s banner gap)
$ python skills/drive-tui/scripts/tui.py wait-regex --id s21576_90197 ">>> " --timeout-ms 15000
# matched=True alive=True
[screen 24x80] cursor=r3c4 selected=r3[0:80]">>>"(cursor_line)
0 | Python 3.14.6 ... on win32
2 | Type "help", "copyright", "credits" or "license" for more information.
3 | >>>
# 3. DECIDE: it's a prompt. ACT: send an expression. WAIT + CONFIRM.
$ python skills/drive-tui/scripts/tui.py send-line --id s21576_90197 "print(6*7)"
$ python skills/drive-tui/scripts/tui.py wait --id s21576_90197
[screen 24x80] cursor=r5c4 ...
3 | >>> print(6*7)
4 | 42
5 | >>>
# 4. done -> exit cleanly, then close the session
$ python skills/drive-tui/scripts/tui.py send-line --id s21576_90197 "exit()"
$ python skills/drive-tui/scripts/tui.py close --id s21576_90197
closed s21576_90197