Skip to content

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-tui and no contexts.toml exists
  • 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.com and VIRTUFIN_TUI_API_PORT=5002 are 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.ListWorkers through 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 workmanager and websocketmanager
  • THEN it SHALL build a separate DescriptorPool per service
  • AND SHALL NOT share pools between services with colliding package virtufin symbols

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 CreateWorker and 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 via SetTag

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 latest ListWorkers pass (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 Connect and 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) or StopPublish(conn_id)
  • AND the server-side topic field 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, and world_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 reconnecting to connected
  • 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 type and source

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 to scenario.index
  • AND SHALL reject LIVE and 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 from scenario.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 it type + 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_source accordingly (inline content base64-encoded) and pass it to CreateWorker

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 ScheduleTrigger with 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_PROVIDER env → 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 trading tab and infra panes (workers, connections, scripts, triggers, events) under an infra tab

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_version is 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 19 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_VERSION from versions.env into pyproject.toml before building