byob-go-cli

Embed a Bubble Tea TUI without forking signal ownership

byob-lifecycle.4 concurrencycontextlifecycleprogress

Problem: a full-screen or inline Bubble Tea view inside a CLI that already owns Ctrl-C via signal.NotifyContext (byob-lifecycle.2) creates two signal owners. tea.NewProgram installs its own handler by default, so Ctrl-C becomes a tea.Interrupt inside the TUI and the background work never sees context cancellation — the screen closes but the job keeps running, or the job dies but the screen hangs.

Idea: keep exactly one cancellation path. Construct the Program with tea.WithoutSignalHandler() so the existing NotifyContext stays the sole signal owner, and invert control: the worker runs in a goroutine writing its result to a 1-buffered channel while the TUI blocks the foreground. The exit ordering is quit → cancel → wait — the UI returns when the user quits, the orchestration cancels the context (a no-op if the worker already finished), then drains the result channel so the process never exits mid-operation. The worker goroutine recovers panics into the result and always signals the UI done, so a dead worker can't hang the screen.

Two more field-tested rules: gate TUI activation on a TTY — both stdin (keys in) and stderr (render out), not the stdout reflex — with a --no-tui escape hatch, and render to ErrOut — the TUI is chatter, not data (byob-iostreams.3). And because an inline renderer clears its frame on quit, error paths should re-emit a captured tail of the pane to the real stderr so early notices survive teardown.

Tradeoffs:

  • Control inversion means the TUI never owns process lifetime; all exit-code mapping stays in the top-level runner (byob-errors.1). That's the point, but it forces a narrow UI seam (Run / Done / Tail plus the redirected IO handles) between the view and the orchestration.
  • The worker's output must be redirected into the view (a tee'd IOStreams) or it corrupts the frame — which is what makes the pane-tail re-emit necessary on error paths.
  • Testability falls out: with the loop function and the UI both injected, the orchestration is a pure function unit-tested against fakes; the real Program runs headless under tea.WithoutRenderer() with a detached tea.WithInput(), keys driven via Send (byob-testing.6).

Design

// Condensed from a field CLI's run orchestration.
type liveUI interface {
    LoopIO() *iostreams.IOStreams // redirected streams for the worker
    Run() error                   // blocks until the user quits
    Done()                        // mark finished; UI stays up for review
    Tail() []string               // pane lines captured at teardown
}

func orchestrate(ctx context.Context, ui liveUI, work workFn, realErr io.Writer) error {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel()

    resultCh := make(chan result, 1) // buffered: worker never blocks on it
    go func() {
        defer ui.Done() // LIFO: fires after recover, so a panic still unblocks the UI
        defer func() {
            if r := recover(); r != nil {
                resultCh <- result{err: fmt.Errorf("worker panicked: %v", r)}
            }
        }()
        out, err := work(ctx, ui.LoopIO())
        resultCh <- result{out: out, err: err}
    }()

    uiErr := ui.Run()
    cancel()          // quit → cancel: unwind a still-running worker
    res := <-resultCh // → wait: never exit mid-operation

    if uiErr != nil || res.err != nil {
        for _, line := range ui.Tail() { // inline frame cleared on quit;
            fmt.Fprintln(realErr, line)  // re-emit notices on error paths
        }
    }
    if uiErr != nil {
        return uiErr
    }
    return mapResult(res.out, res.err)
}

// Program construction: the one-cancellation-path half.
opts := append([]tea.ProgramOption{
    tea.WithOutput(ios.ErrOut),      // TUI is chatter (byob-iostreams.3)
    tea.WithInput(os.Stdin),         // keys from the real stdin
    tea.WithoutSignalHandler(),      // NotifyContext stays the only owner
}, extra...) // tests append WithoutRenderer + a detached WithInput