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/Tailplus 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 detachedtea.WithInput(), keys driven viaSend(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