Skip to content

UI plugins

A UI plugin extends the terminal UI without a fork. The UI is a set of named slots, and every pane in it is a plugin that fills one slot. The built-in panes use the same mechanism, so a third-party plugin can sit next to them, come before them, or replace one of them.

A UI plugin ships in a plugin package with an entry point in the winslow.tui_plugins group (see How Winslow finds a plugin).

Write a UI plugin

A plugin declares a slot and a label, and builds one widget:

examples/winslow-sample-tui-plugin/winslow_sample_tui_plugin/dashboard.py
from textual.containers import Center
from textual.widgets import Static

from winslow.ui.plugin import Slots, UIPlugin


class SampleWidget(Center):
    def compose(self):
        yield Static("Hello from sample plugin!")


class SampleDashboardPlugin(UIPlugin):
    slot = Slots.DASHBOARD_WORKFLOWS
    label = "Sample"

    def create_widget(self, context):
        return SampleWidget()

create_widget returns any Textual widget. The context argument carries the state of the screen: the client and the value shapes it returns (see the payload rule):

The screen The context The useful attributes
Dashboard DashboardRenderContext client (the AppClient), descriptors
Workflow WorkflowRenderContext client, session, snapshot, roster, task_statuses
Task info modal TaskDetailRenderContext info, logs, client, task_key, root_dir, snapshots
Confirmation modal WorkflowConfirmationRenderContext workflow (the workflow name), form_values

The workflow context attributes:

  • client: the SessionClient of the session. It serves every read (roster(), history(), caches(), task_detail(task_key), ...) and accepts every action through submit(action).
  • session: the SessionInfo value: display name, instance name, status, the task status summary.
  • snapshot: the SessionSnapshot at compose time: task statuses by identity key, batch rows, the session log backlog, the cache names.
  • roster: one stub TaskInfo per task, in launch-filter order.
  • task_statuses: the {key: TaskStatus} mapping that the screen maintains.

info is a TaskInfo value. The task detail context opened from a history row also carries the captures of that batch: transient_snapshots and cache_snapshots, each a mapping from the phase name to the values the phase recorded.

A slot with one plugin shows the widget directly. A slot with two or more plugins becomes a tab bar, and label names each tab. When one slot of a row becomes tabbed, the other slots of that row become tabbed too, so the row keeps one visual line.

The payload rule

A render context, a Textual message and a session bus event carry values: the identity key of the task (Task.identity_key, a stable string), TaskInfo captures, and the model dataclasses of winslow.model. A pane reads through the context client and never holds a live core object. A pane built this way works the same on a remote client, because every payload can cross a process boundary.

The screen posts each message to the pane it concerns (bubble=False), and the handler on that pane receives it.

The messages of the workflow screen:

The message The payload
TaskStatusChanged key, status
ExecutionStatusChanged batch_uuid, task_key, status
TaskLogUpdated batch_uuid, task_key, line
BatchCreated, BatchCompleted info (a BatchInfo value)
TaskSelected task_info (a TaskInfo value)
CacheSelected card (a CacheInfo value)
CacheUpdated none: the pane repaints from a fresh caches() read
SessionEnded none: the session is archived, and a pane stops its timers

A pane keys its rows by the identity key, and it reads the current statuses from WorkflowRenderContext.task_statuses, the {key: TaskStatus} mapping that the screen maintains:

class StatusBoardPlugin(UIPlugin):
    slot = Slots.TASKS_PANE
    label = "Status Board"

    def create_widget(self, context):
        return StatusBoard(statuses=context.task_statuses)


class StatusBoard(Widget):
    @on(TaskStatusChanged)
    def refresh_row(self, event):
        self.rows[event.key].status = event.status

A pane that needs more than its messages carry reads through the client, for example context.client.task_detail(task_key) for the full capture of one task, or context.client.submit(RunTasks(keys=(key,))) for an action. Every client method takes values and returns values, so the same pane renders a local session and a remote one. This pane lists the roster, follows the status messages, and runs the highlighted task through the client:

from textual import on
from textual.containers import Vertical
from textual.widgets import Button, Label, ListItem, ListView

from winslow.actions import RunTasks
from winslow.ui.plugin import Slots, UIPlugin
from winslow.ui.workflow_events import TaskStatusChanged


class RunnerPlugin(UIPlugin):
    slot = Slots.TASKS_PANE
    label = "Runner"

    def create_widget(self, context):
        return Runner(context.client, context.roster, context.task_statuses)


class Runner(Vertical):
    def __init__(self, client, roster, statuses):
        super().__init__()
        self.client = client
        self.roster = roster
        self.statuses = dict(statuses)

    def compose(self):
        yield ListView(*(ListItem(Label(self.line(info))) for info in self.roster))
        yield Button("Run highlighted", id="run")

    def line(self, info):
        return f"{info.label}  {self.statuses[info.key].value}"

    @on(Button.Pressed, "#run")
    def run_highlighted(self):
        info = self.roster[self.query_one(ListView).index]
        ack = self.client.submit(RunTasks(keys=(info.key,)))
        if not ack.accepted:
            self.notify(ack.reason, severity="warning")

    @on(TaskStatusChanged)
    def repaint(self, event):
        self.statuses[event.key] = event.status
        view = self.query_one(ListView)
        for item, info in zip(view.children, self.roster):
            item.query_one(Label).update(self.line(info))

The ack of a submit is a value too: accepted and, for a refusal, the reason the session gave.

Two panes are local by nature and work without the client: the system resources pane describes the machine the widget runs on, and the dashboard log pane shows the log of the process the TUI runs in.

The slots

Each screen declares its slots on the Slots class. Press ctrl+g on a screen and Winslow covers every slot with its name, so you can see what each slot spans:

The dashboard, with each slot highlighted by the slot inspector

The dashboard slots and the built-in plugins in them:

The slot The built-in content
DASHBOARD_WORKFLOWS The workflow selector.
DASHBOARD_WORKFLOW_FORM The workflow form.
DASHBOARD_SESSIONS The session list, with a History tab.
DASHBOARD_LOGS The application logs.
DASHBOARD_RESOURCES The system resources.

The workflow screen, with each slot highlighted by the slot inspector

The workflow screen slots:

The slot The built-in content
TASKS_PANE The task list, with a History tab.
TASK_OVERVIEW The overview of the selected task.
WORKFLOW_LOGS The session logs.
WORKFLOW_RESOURCES The system resources.

Two slots live in modals, and not on a screen: TASK_DETAIL fills the task info modal, and WORKFLOW_CONFIRMATION fills the confirmation modal before a workflow starts.

Order the plugins in a slot

The priority value orders the plugins of one slot. A lower value comes first, and the first plugin is the first tab. Each built-in plugin starts at 5, and the next one in the same slot is one higher. The values 0 to 4 are reserved for a plugin that must come before the built-in plugins:

class SampleDashboardPlugin(UIPlugin):
    slot = Slots.DASHBOARD_WORKFLOWS
    label = "Sample"
    priority = 6      # After the built-in workflow selector.

Two plugins with the same priority order by their plugin name. Declare a priority instead of depending on that order.

Replace a built-in plugin

The replace argument evicts another plugin and takes its place. The target is the qualified plugin name (see The name of a plugin). A built-in plugin has the prefix builtin. This example puts a mood face on the system resources pane, and keeps the built-in stat widgets:

examples/winslow-sample-tui-plugin/winslow_sample_tui_plugin/mood.py
from textual.widgets import Static

from winslow.ui.builtin_plugins.common.system_resources import (
    SystemStats,
    CpuStat,
    MemoryStat,
)
from winslow.ui.builtin_plugins.dashboard.resources import (
    DashboardSystemResourcesPlugin,
)


def _face(pct):
    if pct < 30:
        return "😊"
    if pct < 70:
        return "😰"
    return "😡"


class MoodSystemStats(SystemStats):
    def compose(self):
        yield Static(_face(0.0), id="mood-face")
        yield from super().compose()

    def on_mount(self):
        self.set_interval(2, self._update_mood)

    def _update_mood(self):
        cpu = self.query_one(CpuStat).percentage
        mem = self.query_one(MemoryStat).percentage
        self.query_one("#mood-face", Static).update(_face(max(cpu, mem)))


class ResourcesMoodPlugin(DashboardSystemResourcesPlugin):
    label = "System Mood"
    replace = "builtin.dashboard-system-resources-plugin"

    def create_widget(self, context):
        return MoodSystemStats()

The plugin subclasses the built-in plugin, so it inherits the slot of its target. A replacement that declares its own slot must declare the same slot as its target. A different slot is an error, because a replacement changes the content of a pane and not the layout of the screen.

A complete example

The winslow-sample-tui-plugin package holds the two plugins of this page:

winslow-sample-tui-plugin/
├── pyproject.toml
└── winslow_sample_tui_plugin/
    ├── __init__.py
    ├── dashboard.py     # adds a tab
    └── mood.py          # replaces the system resources pane

Download this example

The pyproject.toml declares the entry points:

examples/winslow-sample-tui-plugin/pyproject.toml
[project]
name = "winslow-sample-tui-plugin"
version = "0.1.0"
description = "Sample Winslow plugin: two UI plugins"
requires-python = ">=3.12"
dependencies = ["winslow"]

[build-system]
requires = ["uv_build>=0.11.32,<0.12.0"]
build-backend = "uv_build"

[tool.uv.build-backend]
module-root = ""

[project.entry-points."winslow.tui_plugins"]
winslow-sample-tui-plugin = "winslow_sample_tui_plugin"

Install the package into the workflow project. An editable install keeps a local plugin live while you work on it:

uv add --editable ../winslow-sample-tui-plugin

The command adds the dependency and a local source to the pyproject.toml of the project:

pyproject.toml of the workflow project
[project]
dependencies = [
    "winslow-sample-tui-plugin",
]

[tool.uv.sources]
winslow-sample-tui-plugin = { path = "../winslow-sample-tui-plugin", editable = true }
pip install -e ../winslow-sample-tui-plugin

A plugin package can also add a command to the filter language (see Filter plugins).