agentsclimarketplace

Textual

Skill vinsonconsulting/claude-skill-foundry/skills/tui/textual

A scoring harness for Claude Agent Skills. Each carded skill ships a Skill Card: a SkillSpector security scan and trigger evals, generated and gated in CI.

Install
npx -y skills add vinsonconsulting/claude-skill-foundry --skill textual

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

What its author says it does

Copied from the file, not written here

Use this skill when building or debugging a Python terminal UI (TUI) with Textual (Textualize's framework): `App`/`Screen`/`Widget`, `compose()`, `reactive`/`watch_`, `@work` workers, Textual CSS / TCSS (`dock`, `grid`, `fr` units), widgets like `RichLog`, `Markdown`, `DataTable`, `Input`, `TextArea`, `Tree`, `ListView`; testing with `Pilot` / `pytest-textual-snapshot`; `textual serve` / Textual Web deploy; Rich renderables inside Textual; or streaming LLM/agent tokens into a terminal and agentic-CLI front-ends. Triggers: Textual, Textualize, TUI, terminal UI, `@work`, TCSS, `RichLog`, `query_one`, Pilot, `textual serve`. Writes current 8.x code, not stale pre-1.0 patterns. Not for: Rust TUIs (→ Ratatui sibling), Go TUIs (→ Bubble Tea sibling), plain non-interactive CLI output (Click/argparse/Typer) with no live UI, or agent-session orchestration / tmux multiplexing. Note: browser deploy via `textual serve` (the same Textual app served to a browser) IS in scope; a general React/HTML web-app request is not.

SKILL.md

15.0 KB, as published. Nobody here has run it

Textual

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.

Mental model

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 — the application and event loop; App().run() (or await run_async()). Holds screens, handles input, owns the @work workers.
  • Screen — a full-window container you push/pop; the default screen hosts your compose(). Modals/dialogs are screens.
  • Widget — a node in the DOM tree. Leaf widgets draw themselves (render()); compound widgets yield children (compose()).
  • DOM + TCSS — widgets form a tree you query with query_one/query (CSS selectors) and style with TCSS. Mutating a widget's reactive state schedules a repaint.

App, compose, and lifecycle

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_mounton_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.

Reactivity

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 change

The 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 change

Setting 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.

Messages and events

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.delta

Call event.stop() to halt bubbling. references/architecture.md has the event taxonomy and the BINDINGSaction_* flow.

Layout with Textual CSS

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.

Custom widgets: 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.

Streaming LLM/agent output (the hero use-case)

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.

Golden rules

  • Never block the event loop. Async I/O (httpx, async LLM SDKs) → @work async. Blocking or 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.
  • A list/dict reactive won't fire watchers on in-place change — reassign or mutate_reactive.
  • compose() runs once. Mutate the live DOM in on_mount/handlers; find widgets with query_one.
  • Test with Pilot. async with app.run_test() as pilot: then await pilot.pause() before asserting (let pending messages drain). See references/testing.md.

Stale patterns to reject

These are pre-1.0 / 0.x and break (or silently misbehave) on 8.2.7:

RejectUse instead
TextLogRichLog (renamed)
bare @work on a sync def@work(thread=True) (else WorkerDeclarationError)
self.items.append(x) expecting a watcher to firereassign, or self.mutate_reactive(Cls.items)
Reactive(...) to declare statereactive(...) / var(...) (Reactive is the base type — fine in annotations)
self.view.dock(...) / edge=dock: in TCSS (App.view is gone)
Static(renderable=...) / .renderableStatic(content=...) / .content
Select.BLANK as the empty valueSelect.NULL (BLANK is now the bool False — silently breaks)
Switch.action_toggle for the toggle bindingaction_toggle_switch (the bound action)
remove + remount Markdown per tokenMarkdown.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.

Reference map

  • 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.mdtextual 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.

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.