textual — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited textual (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Write current, compiling Textual code (pinned to 8.2.7, Python ≥3.9, Rich 15.0.0) and refuse the pre-1.0 / 0.x patterns the model remembers from training — the 1.0 (Dec 2024) and 2.0 (Feb 2025) releases were hard breaks. The body is the load-bearing 20%: one mental model and one run-verified example per concept. Everything enumerable — the full widget catalog, every TCSS rule, the streaming model, testing, deploy, migration — lives in references/. Open the matching reference before writing nontrivial code in that area.
Textual is a retained, reactive, DOM-like framework — the opposite of Ratatui's immediate mode and Bubble Tea's MVU. You build a tree of widget objects once, then mutate their state; Textual re-renders only the affected parts, like a web framework. It is async-native (asyncio) and styled with Textual CSS (TCSS), not layout math.
Four nouns carry everything:
App().run() (or await run_async()). Holds screens, handles input, owns the @work workers.compose(). Modals/dialogs are screens.render()); compound widgets yield children (compose()).query_one/query (CSS selectors) and style with TCSS. Mutating a widget's reactive state schedules a repaint.compose() runs once to build the tree; never touch widgets there — the DOM isn't mounted yet. Wait for on_mount, then resolve widgets by selector with query_one.
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Button, Label
class CounterApp(App):
BINDINGS = [("a", "add", "Add"), ("q", "quit", "Quit")] # key → action_* → footer hint
def compose(self) -> ComposeResult: # build the tree ONCE
yield Header()
yield Label("count: 0", id="lbl")
yield Button("hit", id="btn")
yield Footer()
def on_mount(self) -> None: # DOM is live; safe to touch widgets
self.count = 0
def action_add(self) -> None: # bound to "a"
self.count += 1
self.query_one("#lbl", Label).update(f"count: {self.count}")
if __name__ == "__main__":
CounterApp().run()Header()/Footer() auto-dock to the top/bottom. Lifecycle order is on_mount → on_ready → (running) → on_unmount. Run with App().run(), run_async() inside an existing loop, or App().run_test() in tests. Full event/action/BINDINGS detail: references/architecture.md.
Declare state with reactive(default). On assignment Textual runs validate_ → compute_ → watch_, then does a smart refresh. Use var() for state that should not auto-refresh.
from textual.reactive import reactive
from textual.widget import Widget
class Thermostat(Widget):
temp = reactive(20) # auto-refresh on change
def validate_temp(self, value: int) -> int:
return max(0, min(100, value)) # 1. coerce/clamp
def watch_temp(self, old: int, new: int) -> None:
self.refresh() # 3. react to the changeThe mutable-reactive footgun: assigning fires watchers; mutating in place does not.
items = reactive(list) # factory default for mutable state
self.items.append(x) # SILENT — watch_items does NOT fire
self.items = [*self.items, x] # reassign → fires
self.mutate_reactive(Thermostat.items) # canonical: notify after an in-place changeSetting a reactive in __init__ can fire a watcher against the unmounted DOM (NoMatches); use set_reactive to set without triggering. Flags (init, always_update, layout, recompose, bindings): references/architecture.md.
Widgets communicate by posting messages that bubble up the DOM. Handle them with @on(MessageType, "selector") (preferred since 0.23) or an on_<message> method — both beat a giant on_button_pressed if-chain. Define custom messages as a nested Message subclass.
from textual import on
from textual.message import Message
from textual.widgets import Button
class Stepper(Widget):
class Changed(Message): # nested custom message
def __init__(self, delta: int) -> None:
self.delta = delta
super().__init__()
def compose(self) -> ComposeResult:
yield Button("+", id="inc")
@on(Button.Pressed, "#inc") # selector-filtered handler
def _inc(self) -> None:
self.post_message(self.Changed(+1)) # bubbles toward the App
# in the parent App:
@on(Stepper.Changed)
def _on_changed(self, message: Stepper.Changed) -> None:
self.total += message.deltaCall event.stop() to halt bubbling. references/architecture.md has the event taxonomy and the BINDINGS → action_* flow.
TCSS lives in a class CSS string (or CSS_PATH). dock pins a widget to a non-scrolling edge; the fr unit distributes leftover space; containers from textual.containers group children. Selectors target type (Button), id (#sidebar), or class (.panel).
from textual.containers import Horizontal, VerticalScroll
from textual.widgets import Static
class Dashboard(App):
CSS = """
#topbar { dock: top; height: 1; background: $panel; }
#status { dock: bottom; height: 1; }
#sidebar { width: 24; } /* fixed-width column */
#body { width: 1fr; } /* fills the rest */
"""
def compose(self) -> ComposeResult:
yield Static("title", id="topbar")
with Horizontal():
yield Static("nav", id="sidebar")
yield VerticalScroll(id="body")
yield Static("ready", id="status")Grids use layout: grid; grid-size: 2 2; grid-gutter: 1. Iterate styling live with textual run --dev (TCSS hot-reload). Selectors, the box model, units, grids, containers, and themes: references/styling-and-layout.md.
render() vs compose()A leaf widget implements render() (returns a string/Rich/Content renderable). A compound widget implements compose() (yields child widgets). Pick one — never both.
class Pct(Widget): # LEAF
pct = reactive(0)
def render(self) -> str:
return f"{self.pct}%"
class Card(Widget): # COMPOUND
def compose(self) -> ComposeResult:
yield Label("title")
yield Pct()Catalog, the *State-free reactive pattern, and authoring guidance: references/widgets.md.
Stream from a @work async worker so the event loop never blocks. For Markdown, don't remove-and-remount per token — use Markdown.get_stream(), which coalesces fast updates (~20/s) and re-renders only the last block. Pin to the bottom with container.anchor().
from textual import work
from textual.widgets import Markdown
from textual.containers import VerticalScroll
class Chat(App):
def compose(self) -> ComposeResult:
with VerticalScroll():
yield Markdown()
@work # async worker; never blocks the loop
async def stream_reply(self) -> None:
markdown_widget = self.query_one(Markdown)
self.query_one(VerticalScroll).anchor() # stick to bottom
stream = Markdown.get_stream(markdown_widget) # classmethod — pass the widget
try:
async for chunk in llm_client.stream(...):
await stream.write(chunk) # await: write AND stop are async
finally:
await stream.stop()For colored log scrollback instead of Markdown, use RichLog(highlight=True, markup=True, auto_scroll=True) + .write(...). The full streaming model (cancellation, exclusivity, RichLog vs anchored VerticalScroll, reference apps): references/agent-ui.md.
@work async. Blockingor sync calls (requests, time.sleep) → @work(thread=True) — a bare @work on a sync def raises WorkerDeclarationError. From a thread worker, touch the UI only via self.call_from_thread(fn, *args); async workers may update the UI between awaits.
mutate_reactive.on_mount/handlers; find widgets with query_one.async with app.run_test() as pilot: then await pilot.pause() beforeasserting (let pending messages drain). See references/testing.md.
These are pre-1.0 / 0.x and break (or silently misbehave) on 8.2.7:
| Reject | Use instead |
|---|---|
TextLog | RichLog (renamed) |
bare @work on a sync def | @work(thread=True) (else WorkerDeclarationError) |
self.items.append(x) expecting a watcher to fire | reassign, or self.mutate_reactive(Cls.items) |
Reactive(...) to declare state | reactive(...) / var(...) (Reactive is the base type — fine in annotations) |
self.view.dock(...) / edge= | dock: in TCSS (App.view is gone) |
Static(renderable=...) / .renderable | Static(content=...) / .content |
Select.BLANK as the empty value | Select.NULL (BLANK is now the bool False — silently breaks) |
Switch.action_toggle for the toggle binding | action_toggle_switch (the bound action) |
remove + remount Markdown per token | Markdown.get_stream(widget) + await stream.write/stop |
time.sleep / blocking requests in a handler | @work async (or @work(thread=True)) |
Confirmed against textual 8.2.7 / rich 15.0.0 / Python 3.14 (floor 3.9): TextLog is gone; a bare @work on a sync fn raises WorkerDeclarationError; in-place reactive mutation does not fire watchers; Markdown.get_stream is a classmethod and write/stop are awaitable; Select.NULL (not BLANK) is the no-selection sentinel; events.Load still exists (do not assume on_load was removed). The full deprecation map is in references/versioning.md — and scripts/verify.py re-runs these checks against your installed Textual after an upgrade.
references/architecture.md — DOM tree, compose/lifecycle, messages/events/@on, actions/BINDINGS, reactivity internals, workers (@work/call_from_thread/exclusivity).references/styling-and-layout.md — TCSS selectors, box model, dock, grid, fr/units, containers, hot-reload, themes/variables.references/widgets.md — full built-in catalog, "when to reach for each", custom-widget authoring (render vs compose).references/text-and-unicode.md — content markup, Content/from_markup, the Rich-vs-Textual boundary, wrapping/escaping (never f-string markup on untrusted input).references/agent-ui.md — streaming LLM/agent tokens in-process (get_stream/MarkdownStream, RichLog + anchor, cancellation); orchestration/tmux is out of scope.references/testing.md — Pilot, run_test, pilot.pause, pytest-textual-snapshot, asyncio_mode.references/ecosystem.md — Rich, textual-dev, pytest-textual-snapshot, textual-plotext, third-party widgets.references/versioning.md — pinned versions, Python floor, maintenance status, and the reject-list / deprecation map.references/web-deploy.md — textual serve / textual-serve / Textual Web; what works in the browser; the honest "self-host on a VM/container/PaaS, not edge/serverless" read.scripts/verify.py — re-run the load-bearing snippets against your installed Textual to confirm the skill still holds after an upgrade.~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.