tui#

import "github.com/jbcom/radioactive-ralph/internal/tui"

Package tui implements the read-only Bubble Tea client described in docs/superpowers/specs/2026-07-16-supervisor-architecture-design.md §7: “attach/detach is the wrong nomenclature; running the client simply shows the supervisor’s live state.” The client never writes to the store and never dispatches work — it only calls read methods on the supervisor’s IPC client and the shared store. Three drill-down levels (macro/meso/micro) navigate the same live snapshot.

Index#

func IsTerminal#

func IsTerminal() bool

IsTerminal reports whether stdout is attached to a real terminal. The client entry point (cmd/radioactive_ralph) MUST check this before calling Run: launching the Bubble Tea program against a pipe or in a non-interactive CI job would hang waiting for terminal input/output that will never arrive. When it returns false, callers should fall back to a plain status print instead of Run.

func Run#

func Run(ctx context.Context, source DataSource, opts Options) error

Run starts the read-only TUI against source and blocks until the operator quits or ctx is cancelled. Callers MUST verify stdout is a real terminal before calling Run (see IsTerminal) — Run itself does not guard against a non-tty stdout, so that a caller wanting to force a run against a pseudo-tty in an integration test still can.

type DataSource#

DataSource is every read the TUI needs, gathered behind one interface so Model can be driven by either the real supervisor client + shared store or an in-memory fake in tests. Every method here is READ-ONLY by contract: this is the enforcement point for the spec’s “read-only” guarantee — Model.Update never calls anything but these methods, and none of them may mutate durable state. (The real implementation, liveDataSource in live.go, is a thin wrapper: it forwards to *ipc.Client.Status/Attach and *store.Store’s existing List*/Get* read methods — it adds no new write surface of its own.)

type DataSource interface {
    // Status returns the supervisor's current status snapshot (worker
    // counts, task counts, recent heartbeat).
    Status(ctx context.Context) (ipc.StatusReply, error)

    // ListPlans returns the known plans for the project this client is
    // scoped to (empty projectID lists across all projects, matching
    // store.Store.ListPlans).
    ListPlans(ctx context.Context, projectID string) ([]store.Plan, error)

    // PlanProgress reports done/total step counts for one plan.
    PlanProgress(ctx context.Context, planID string) (orch.Progress, error)

    // ListTasks returns a plan's tasks.
    ListTasks(ctx context.Context, planID string) ([]store.Task, error)

    // ListProjectEvents returns the most recent events across the whole
    // project, most recent first.
    ListProjectEvents(ctx context.Context, projectID string, limit int) ([]store.Event, error)

    // ListTaskEvents returns the most recent events for one task, most
    // recent first.
    ListTaskEvents(ctx context.Context, planID, taskID string, limit int) ([]store.Event, error)

    // MaxEventID returns the highest event id for the client's project (0 if
    // none). The model reads it ONCE before the first attach to seed its resume
    // cursor, so it owns the cursor end-to-end: a reconnect resumes from the last
    // processed id even if the FIRST subscription ended before yielding any frame
    // (its internal seed would otherwise be forgotten and the reconnect would
    // skip the gap). An error is non-fatal — the model falls back to 0.
    MaxEventID(ctx context.Context) (int64, error)

    // Attach subscribes to the live event stream from afterID. fn is invoked
    // once per event frame until ctx is cancelled or the stream ends. afterID>0
    // RESUMES from a known cursor (a reconnect passes the last id it processed,
    // so events during the disconnect gap are not missed); afterID<=0 seeds from
    // the current max (an initial attach starts from "now", not full history).
    // Attach must not block Model's redraw loop — Run wires it up on its own
    // goroutine (see model.go).
    Attach(ctx context.Context, afterID int64, fn func(json.RawMessage) error) error
}

func NewLiveDataSource#

func NewLiveDataSource(runtimeDir string, st *store.Store, projectID string) DataSource

NewLiveDataSource builds the production DataSource: runtimeDir is the directory the supervisor’s socket lives under (xdg.StateRoot()), st is the shared store, and projectID scopes the plan/event reads.

type Model#

Model is the root tea.Model. It holds the current drill level, the read-only DataSource, and the last-fetched snapshot; Update handles key events and the periodic refresh tick, View delegates to the per-level renderer. Model never calls anything on DataSource except its documented read methods — see datasource.go’s DataSource doc comment for the read-only enforcement point.

type Model struct {
    // contains filtered or unexported fields
}

func NewModel#

func NewModel(ctx context.Context, source DataSource, projectID string) Model

NewModel constructs the root model. ctx bounds the whole TUI session — cancelling it (e.g. on SIGINT) unwinds any in-flight Attach goroutine.

func (Model) Init#

func (m Model) Init() tea.Cmd

Init starts the refresh loop. It fires an IMMEDIATE refresh tick rather than launching a fetch directly, so the very first gather goes through the same in-flight-guarded path as every periodic tick (Init returns a Cmd and cannot set m.fetching, so a direct fetch here could overlap the first periodic tick if the initial gather is slow).

func (Model) Update#

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd)

Update handles key events (arrows/enter to drill in, esc/backspace to drill out, q to quit) and the periodic refresh tick. This is the surface the model_test.go table tests exercise directly, injecting tea.KeyMsg values without a real terminal.

func (Model) View#

func (m Model) View() string

View delegates to the level renderer.

type Options#

Options configures Run.

type Options struct {
    // ProjectID scopes the macro view's plan list + event feed. Empty
    // lists across all projects.
    ProjectID string

    // Output/Input override the terminal streams tea.Program attaches
    // to; nil defaults to os.Stdout/os.Stdin. Tests that DO drive a real
    // tea.Program (none currently do — model_test.go drives Update
    // directly) would use this to avoid touching the real terminal.
    Output io.Writer
    Input  io.Reader
}

Generated by gomarkdoc