Terminal UI
Purpose¶
The Terminal UI (virtufin-tui) is a Textual-based Python operator dashboard for the Virtufin control plane. It is a pure consumer of the existing virtufin-api gRPC surface — it runs no service-side code, adds no proto messages, and requires no service changes. It fills the operator role that kubectl plays for Kubernetes: interactive worker / connection / state / event management over a remote virtufin-api endpoint, with live CloudEvent streaming, multi-context switching, and an AI assistant.
Related specs:
- Cross-cutting — API-mediated pub/sub rule (the TUI talks only to virtufin-api, never Dapr directly).
- Pub/Sub Topics — the CloudEvent envelope and topic taxonomy the TUI's event log renders.
- Scenarios — the scenario registry triplet the TUI's scenario page edits.
- Worker Management — worker lifecycle, tags, and creation-time config the TUI drives.
- WebSocket Proxy — connection lifecycle, tags, and publishing the TUI drives.
Requirements¶
Requirement: Multi-Context Configuration¶
The TUI SHALL read its endpoint configuration from a TOML file under the
XDG config directory (~/.config/virtufin/api/contexts.toml on Linux/macOS,
%APPDATA%\virtufin\api\contexts.toml on Windows). The file SHALL contain a
[[contexts]] array; each entry MUST have name, api_host, api_port
fields and MAY have tls (bool), api_key, default_worker_topic,
worker_api_host, worker_api_port, description, and event_topics
(default ["state.change"]).
The active context is state, not config: it SHALL be persisted separately
at ~/.local/state/virtufin/api/state.toml (mode 0600). The TUI SHALL keep
the catalog-of-contexts vs. active-selection split for every user setting it
persists (contexts, LLM providers, themes, event topics). Active-context
resolution order SHALL be: CLI --context flag → $VIRTUFIN_TUI_CONTEXT
env var → persisted state file → first entry in the file.
Environment variables VIRTUFIN_TUI_API_HOST and VIRTUFIN_TUI_API_PORT
SHALL override the active context's host/port in memory when set.
Scenario: First-run with no config file¶
- WHEN the user runs
virtufin-tuiand nocontexts.tomlexists - THEN the TUI SHALL seed shipped default configs into
~/.config/virtufin/ - AND SHALL NOT overwrite existing files (seeded files are mode
0600)
Scenario: Env var override¶
- WHEN
VIRTUFIN_TUI_API_HOST=staging.virtufin.comandVIRTUFIN_TUI_API_PORT=5002are exported - THEN the TUI SHALL use those values for the active context, regardless of what is in
contexts.toml
Scenario: Switch context mid-session¶
- WHEN the user selects a different context from the config page
- THEN the TUI SHALL tear down the existing gRPC channel, DataPump tasks, and event subscription
- AND SHALL rebuild the client and restart polling/subscription against the new context
Requirement: Client Access via gRPC Reflection¶
The TUI SHALL invoke control-plane RPCs without vendored proto stubs, using
the API gateway's reflection RPCs (get_file_descriptor_protos_async) to load
message descriptors at runtime. Each backend service SHALL get its own
DescriptorPool (pools SHALL NOT be shared — workmanager.proto and
websocketmanager.proto both declare package virtufin with identically-
named messages). Repeated-message RPCs (ListWorkers, List, etc.) SHALL be
invoked via the raw reflection path, since grpc-gateway's JSON transcode does
not cover them.
Scenario: List workers via reflection¶
- WHEN the TUI polls the workers table
- THEN it SHALL call
WorkManager.ListWorkersthrough the reflection pipeline (invoke_raw_async), decoding the response from the dynamically-loaded message class
Scenario: One descriptor pool per service¶
- WHEN the TUI loads descriptors for
workmanagerandwebsocketmanager - THEN it SHALL build a separate
DescriptorPoolper service - AND SHALL NOT share pools between services with colliding
package virtufinsymbols
Requirement: Worker Operations Surface¶
The TUI SHALL expose worker CRUD, tagging, and lifecycle operations through
the keyboard. List/detail/create/tag/start/stop/delete SHALL be backed by the
WorkManager RPCs (ListWorkers, GetTags, GetWorkerHistory, CreateWorker,
SetTag, StartWorker, StopWorker, DeleteWorker). Destructive actions
(delete) SHALL require a confirmation modal. Tag changes SHALL be undoable for
2 seconds after the change.
Scenario: Create a worker¶
- WHEN the user invokes the new-worker flow and submits a worker with MIME type, topic, code source, and tags
- THEN the TUI SHALL call
CreateWorkerand the new worker SHALL appear in the workers table on the next poll
Scenario: Set a tag with undo¶
- WHEN the user sets a tag on a worker
- THEN the TUI SHALL call
SetTag, display an undo hint for 2 seconds, and undoing SHALL restore the prior value viaSetTag
Scenario: Delete with confirmation¶
- WHEN the user invokes the delete action on a worker
- THEN the TUI SHALL show a confirmation modal; on confirm it SHALL call
DeleteWorker; on cancel no action is taken
Scenario: Worker detail¶
- WHEN the user opens a worker's detail popover
- THEN the TUI SHALL aggregate
GetTags+GetWorkerHistory+ the creation-time config from the latestListWorkerspass (config is immutable after creation; there is no dedicated get-config RPC)
Requirement: WebSocket Connection Operations Surface¶
The TUI SHALL expose WebSocket connection management through the keyboard,
backed by the WebSocketManager RPCs (List, GetTags, SetTag, Connect,
Disconnect, SendRaw, StartPublish, StopPublish). SendRaw SHALL
base64-encode the message body (the proto bytes field requires base64 in the
JSON mapping). Tag changes SHALL be undoable for 2 seconds.
Scenario: Connect to a new WebSocket URL¶
- WHEN the user invokes the connect action with a URL and auto-reconnect preference
- THEN the TUI SHALL call
Connectand the new connection SHALL appear in the connections table on the next poll
Scenario: Send a raw message¶
- WHEN the user sends a JSON message to a connection
- THEN the TUI SHALL base64-encode the body and call
SendRaw(conn_id, message, content_type)
Scenario: Start and stop publishing¶
- WHEN the user starts or stops publishing on a connection
- THEN the TUI SHALL call
StartPublish(conn_id, topic)orStopPublish(conn_id) - AND the server-side
topicfield is the authoritative publishing signal
Scenario: Disconnect is not delete¶
- WHEN the user disconnects a connection
- THEN the TUI SHALL call
Disconnect(the socket closes, but the record MAY reappear if auto-reconnect is enabled — there is no true delete in the websocket service)
Requirement: Live CloudEvent Streaming¶
The TUI SHALL subscribe to Pubsub.Subscribe (the sole subscribe RPC) and
append incoming CloudEvents to the events log. The subscription SHALL be
parameterized by the context's event_topics (user-editable, persisted to TUI
state). On subscription failure the TUI SHALL retry with exponential backoff
(1s → 30s cap) and SHALL NOT replay missed events on reconnect. The event log
SHALL be a bounded append-only buffer (500 events) with a running total counter.
Scenario: Events flow into the log¶
- WHEN the TUI is connected and a CloudEvent is received on a subscribed topic
- THEN the event SHALL appear at the bottom of the events log, pretty-printed (type/source/id/topic/time + decoded payload)
Scenario: Change subscribed topics¶
- WHEN the user adds or removes topics via the events page
- THEN the TUI SHALL restart the subscription immediately with the new topic set
- AND SHALL persist the new topic set to TUI state
Scenario: Reconnect on gRPC failure¶
- WHEN the subscription drops
- THEN the TUI SHALL retry with exponential backoff (1s → 30s cap) and surface a backoff status; the event log SHALL be preserved across reconnects
Requirement: World Management¶
The TUI SHALL manage market-data "worlds" as named abstractions over WebSocket
connections. An atomic world SHALL be a connection tagged world=<name>;
a composite world SHALL be a statestore.world.<name> entry listing
component worlds (with DFS cycle detection before saving). World definitions
SHALL be loadable from per-file TOML configs under
~/.config/virtufin/tui/worlds/*.toml, each of type "websocket" (with
ws_url, optional handshake steps, reconnect) or "script" (with
script, execution_trigger, execution_function, return_type).
The canonical world-open sequence SHALL be: connect → tag
(world/universe/world_source) → wait for connected → replay handshake →
start publish → tag publishing → persist.
Scenario: Connect a world¶
- WHEN the user connects a world config
- THEN the TUI SHALL run the canonical open sequence and tag the connection with
world,universe, andworld_source
Scenario: Compose worlds¶
- WHEN the user composes two or more worlds into a composite
- THEN the TUI SHALL persist
statestore.world.<name>with its component list - AND SHALL reject a composition that would create a cycle
Scenario: Replay handshake on reconnect¶
- WHEN a world connection transitions from
reconnectingtoconnected - THEN the TUI SHALL replay the world's handshake (WebSocketManager never resends a handshake, initial or on reconnect)
Requirement: Strategy Management¶
The TUI SHALL manage strategies as named abstractions. An atomic strategy
SHALL be a worker tagged type=<name> (derived — no state entry); a
composite strategy SHALL be a statestore.strategy.<name> entry listing
component strategies (with DFS cycle detection before saving). Creating an
atomic strategy SHALL create a worker and tag it with type and source.
Scenario: Create an atomic strategy¶
- WHEN the user creates an atomic strategy
- THEN the TUI SHALL create a worker and tag it with the strategy
typeandsource
Scenario: Compose strategies¶
- WHEN the user composes strategies
- THEN the TUI SHALL persist
statestore.strategy.<name>with its component list and reject cycles
Requirement: Scenario Management¶
The TUI SHALL edit the scenario registry (per the Scenarios spec)
via the state store at scenario.<SCENARIO_ID> with the scenario.index set.
LIVE SHALL be reserved (bootstrapped once at app startup, non-fatal on
failure) and rejected as a user-supplied ID. Scenario IDs SHALL match
^[A-Z0-9_]{1,32}$. Deletion SHALL require a double confirmation and SHALL
mark deleted, drop the index entry, and purge the registry.
Scenario: Add a scenario¶
- WHEN the user adds a scenario with market/portfolio/strategy legs
- THEN the TUI SHALL persist the triplet to
scenario.<id>and add the ID toscenario.index - AND SHALL reject
LIVEand IDs not matching^[A-Z0-9_]{1,32}$
Scenario: Set the active scenario¶
- WHEN the user activates a scenario
- THEN the TUI SHALL set it active and refresh the trade status bar (which highlights when the active scenario is
LIVE)
Scenario: Delete a scenario¶
- WHEN the user deletes a scenario
- THEN the TUI SHALL require a double confirmation, then mark the entry
deleted, remove it fromscenario.index, and purge the registry entry
Requirement: State and Service Browser¶
The TUI SHALL provide a service browser that enumerates registered services
and methods via the gateway reflection RPCs (ListServices, ListMethods),
renders a JSON input form, and dispatches invocation through the gateway's
dynamic method dispatch. The browser SHALL expose raw JSON invocation only —
it SHALL NOT attempt schema-driven form generation for arbitrary methods.
Scenario: Browse services and methods¶
- WHEN the user opens the service browser
- THEN the TUI SHALL list services, then list a selected service's methods, and invoke a method by JSON payload
Requirement: Worker Registry and Schema-Driven Creation¶
The TUI SHALL support creating workers from a declarative registry at
~/.config/virtufin/tui/workers.toml. Each entry SHALL declare name,
mime_type, source_url (templated with {version} or {ref}),
config_schema (JSON Schema draft 2020-12), versions, and authentication.
The new-worker flow SHALL render a form from config_schema (strings, integers,
numbers, booleans, and enums; nested objects skipped with a warning). Source
authentication SHALL resolve a SourceCredential (scheme basic/bearer) —
"virtufin" SHALL resolve the org's server-resolved shared credential
(VIRTUFIN_REGISTRY_CREDENTIAL_NAME), otherwise a named personal credential
from credentials.toml.
Scenario: Create a worker from the registry¶
- WHEN the user picks a registered worker type
- THEN the TUI SHALL render a form from
config_schema, resolve the source credential, create the worker, and tag ittype+source
Scenario: Inline or versioned source¶
- WHEN the user supplies inline code, a relative file, a version dropdown, or a fixed URL as the code source
- THEN the TUI SHALL build the
code_sourceaccordingly (inline content base64-encoded) and pass it toCreateWorker
Requirement: Trigger Management¶
The TUI SHALL list, schedule, and delete cron/point-in-time triggers via the
TriggerService stubs (ListTriggers, ScheduleTrigger, DeleteTrigger). The
schedule modal SHALL support both cron and point-in-time modes with name,
target topic, schedule/due-time, repeat count, and TTL.
Scenario: Schedule a trigger¶
- WHEN the user schedules a trigger
- THEN the TUI SHALL call
ScheduleTriggerwith the chosen cron or point-in-time parameters
Requirement: AI Assistant¶
The TUI SHALL ship an AI chat tab that translates natural-language requests
into control-plane operations via OpenAI-compatible tool calling. The agent
logic SHALL be Textual-free (unit-testable) and SHALL have no privileged
path — every tool SHALL map 1:1 to an existing TuiClient/DataPump call.
The tool catalog SHALL include create/delete trigger, set event topics,
create/start/stop worker, add/connect/remove world. The agent SHALL run a
bounded loop (max 5 iterations) and SHALL never raise from tool execution
(errors returned as {"ok": false, "error": ...} for the model).
Scenario: Provider resolution¶
- WHEN the AI tab resolves a provider
- THEN it SHALL check
VIRTUFIN_TUI_AI_PROVIDERenv → persisted default → first ready provider → none - AND SHALL show a configure state (disabled input) when no provider resolves
Scenario: Tool execution never raises¶
- WHEN a tool call fails
- THEN the TUI SHALL surface the error to the model as a structured result rather than raising
Requirement: Custom Pane Extension¶
The TUI SHALL support user-defined panes dropped into
~/.config/virtufin/tui/panes/. Two kinds SHALL be supported: a script +
form pair (<name>.py + sibling <name>.form.json, run as a subprocess on
submit) and a live widget pane (<name>.py defining a module-level Pane
class with a (stores, client, **kwargs) constructor, mounted as a persistent
page). Files matching neither pattern SHALL be skipped with a warning.
Scenario: Discover panes¶
- WHEN the TUI resolves pane tabs
- THEN it SHALL scan
~/.config/virtufin/tui/panes/and map script+form pairs and live-widget classes to pages
Requirement: Dashboard and Pane Layout¶
The TUI SHALL render a two-level tab layout (top-level tabs, per-tab sub-tabs)
driven by an ordered pane spec. Built-in panes SHALL include home,
world, instruments, orderbook, portfolio, strategy, scenario,
pnl, risk, workers, connections, scripts, triggers, events,
config, and ai. The layout SHALL be user-customizable and SHALL migrate
newly-introduced default panes into existing user layouts without clobbering
user removals (a pane_order_version stamp drives a one-time migration).
Pages SHALL be Widget subclasses (not Screens) mounted into a shared page
body.
Scenario: Default pane order¶
- WHEN the TUI starts with no pane customization
- THEN the default order SHALL group trading panes (world, instruments, orderbook, portfolio, strategy, scenario, pnl, risk) under a
tradingtab and infra panes (workers, connections, scripts, triggers, events) under aninfratab
Scenario: Pane migration preserves user edits¶
- WHEN a new default pane is introduced in a later release
- THEN the migration SHALL insert it at its default position only if the user's
pane_order_versionis behind the new version - AND SHALL NOT re-add a pane the user previously removed
Requirement: Keyboard Dispatch¶
The TUI SHALL expose per-page actions via a single dashboard binding table,
with first-match-wins dispatch and footer visibility gated on whether the
current page implements the action. Navigation SHALL support right/left to
cycle top-level tabs (priority bindings) and 1–9 to switch sub-tabs.
? SHALL show the help overlay; q SHALL quit.
Scenario: Page-conditional bindings¶
- WHEN the user presses a bound key on a page that does not implement the action
- THEN the key SHALL be hidden from the footer help for that page
- AND SHALL dispatch to the first matching action when present
Requirement: Distribution as a Single Python Package¶
The TUI SHALL be distributed as a single pip-installable Python package built
with hatchling, exposing a virtufin-tui console script entry point that calls
virtufin_tui.app:main. It SHALL be runnable without installation via
uvx --from virtufin-tui virtufin-tui. The package SHALL depend on
textual[syntax], virtufin-api, grpcio, openai, pydantic, and
tomlkit. The version SHALL be pinned in versions.env as LIBRARY_VERSION.
Scenario: uvx run¶
- WHEN the user runs
uvx --from virtufin-tui virtufin-tui - THEN the TUI SHALL launch against the active context (or the config page if none is configured)
Scenario: Pinned version¶
- WHEN CI builds the package
- THEN it SHALL inject
LIBRARY_VERSIONfromversions.envintopyproject.tomlbefore building