Skip to content

Architecture

Data flow

flowchart LR
    subgraph TUI["virtufin-tui (your laptop)"]
        DA["Dashboard: 3 top tabs, config-driven sub-tabs"]
        subgraph ST["Shared DataPump"]
            W["WorkersStore"]
            C["ConnsStore"]
            TR["TriggersStore"]
            E["EventsStore"]
        end
        DA --> ST

        subgraph PAGES["Per-pane widgets (mounted one at a time)"]
            HP["HomePage"]
            IP["InstrumentsPage / OrderBookPage / PortfolioPage / PnLPage / RiskPage"]
            WP["WorkersPage / ConnectionsPage / ScriptsPage / TriggersPage / EventsPage"]
            CP["ConfigPage"]
            XP["Custom panes (~/.config/virtufin/tui/panes/)"]
        end
        WP --> W
        WP --> C
        WP --> TR
        WP --> E
    end

    subgraph BG["Background tasks (DataPump)"]
        P["Poller (2s interval): ListWorkers, ListConnections, ListTriggers"]
        S["Subscriber: Pubsub.Subscribe"]
    end
    P --> W
    P --> C
    P --> TR
    S --> E

    API["virtufin-api (cluster)<br/>Gateway.* / State.* / Pubsub.* RPCs<br/>(talks to Dapr pubsub + state)"]

    TUI -- "gRPC" --> BG
    BG -- "gRPC" --> API

Components

1. App (src/virtufin_tui/app.py)

The root App subclass. Holds a single TuiConfig (from contexts.toml), connects to the active context, and pushes the Dashboard screen. Also configures file-based logging (~/.local/state/virtufin/tui/tui.log) before anything else runs, since Textual owns the terminal in alt-screen mode -- anything written to stderr is invisible while the TUI is running.

2. Dashboard (src/virtufin_tui/screens/dashboard.py)

A Screen that owns the DataPump and renders a two-level tab bar: 3 top-level tabs (Home, Trading, Infra by default) and, for any tab with more than one pane, a sub-tab bar underneath. Only one pane widget is mounted at a time -- switching tabs unmounts the current one and mounts the next.

The tab/sub-tab structure is not hardcoded: it's read from pane_order in config.toml at runtime (see Custom panes). right/left cycle top-level tabs; 1-9 pick a sub-tab by position within the active tab.

Dashboard also routes a small set of generic keys (r, a, d, enter, space, /, c, s, n, w, t) to whichever action_* method the currently-mounted pane happens to implement (see Keybindings for the full per-pane mapping) -- check_action hides a key from the footer entirely if the current pane doesn't implement the corresponding method.

3. Data store (src/virtufin_tui/store.py)

DataPump runs two background tasks against the active TuiClient and populates four reactive stores:

Store Source Refresh
WorkersStore client.list_workers() 2s polling
ConnsStore client.list_connections() 2s polling
TriggersStore client.list_triggers() 2s polling
EventsStore Pubsub.Subscribe server-streaming Push, bounded deque (max 500)

The subscriber retries with exponential backoff (1s up to a 30s cap) on disconnect; events are not replayed on reconnect. The DataPump survives pane switches within a context; switching context (via the Config page) stops the old pump, opens a fresh gRPC channel, and starts a new one.

InstrumentsPage, OrderBookPage, PortfolioPage, PnLPage, and RiskPage (the Trading tab's sub-tabs) are, as of this writing, mostly placeholders not yet wired to a dedicated store or RPC -- they exist as the scaffolding for future trading-specific views.

4. Config (src/virtufin_tui/config.py)

Two independent files, both XDG-aware ($XDG_CONFIG_HOME, default ~/.config):

  • virtufin/api/contexts.toml -- the list of virtufin-api endpoints ("contexts": host/port/TLS/API key/etc.) and which one is active. See Contexts.
  • virtufin/tui/config.toml -- everything else: theme, event-topic presets, the tab/pane layout (pane_order), and display toggles. Loaded into a TuiUserConfig dataclass and round-tripped through tomlkit on save, so comments and formatting you haven't touched survive.

5. Client wrapper (src/virtufin_tui/client.py)

A thin async wrapper (TuiClient) around virtufin.api.client.ApiClient exposing the operations the TUI needs as plain async methods (no Textual dependency) -- workers, connections, triggers, pubsub subscribe, gRPC reflection browsing (the service browser).

6. Per-pane widgets (src/virtufin_tui/screens/*.py)

Every pane is a plain textual.widget.Widget (not a Screen) following the same duck-typed convention: __init__(self, stores, client, **kwargs), compose(), optionally on_mount() and whichever action_* methods it wants Dashboard's generic keys routed to.

Tab Sub-tab (pane id) File
Home (single pane, no sub-tabs) home_page.py
Trading instruments / orderbook / portfolio / pnl / risk / config instruments_page.py, orderbook_page.py, portfolio_page.py, pnl_page.py, risk_page.py, config_page.py
Infra workers / connections / scripts / triggers / events / config workers_page.py, connections_page.py, scripts_page.py, triggers_page.py, events_page.py, config_page.py

config is the same ConfigPage class mounted under both Trading and Infra by default. Detail popovers (WorkerDetail, ConnectionDetail), the action modal, and the service browser are pushed as modals/screens on top of the active pane.

Beyond the built-in pages, users can add their own panes and even new top-level tabs without touching the source tree -- see Custom panes.

Lifecycle

  1. virtufin-tui is invoked (from uvx, pip install, or local).
  2. app.main() configures file-based logging, then parses --config and --context flags.
  3. config.load_config() reads contexts.toml and applies env-var overrides (VIRTUFIN_TUI_API_HOST/_PORT/VIRTUFIN_TUI_CONTEXT).
  4. If no contexts are configured, the TUI prints an error and exits -- create contexts.toml by hand (see Contexts).
  5. Otherwise, VirtufinTuiApp.on_mount() builds a TuiClient, connects, and pushes the Dashboard screen, which starts the DataPump and mounts the first tab's first pane after a short delay.
  6. On q (quit), the TUI cancels background tasks, closes the gRPC channel, and exits 0.