Running subprocesses¶
pkg/runner runs work from inside a Bubble Tea program in three shapes. They
differ in what happens to the terminal and to the output.
| Function | The TUI | The output | Use for |
|---|---|---|---|
runner.Run(cmd) |
suspended; the subprocess gets the real terminal | gone (the program owns the screen) | $EDITOR, less, htop, ssh, kubectl exec |
runner.Capture(cmd) |
stays live | streamed back line by line, into the console | builds, scripts, CLIs whose output you want to read |
runner.Go(label, fn) |
stays live | whatever fn writes to its io.Writer |
HTTP calls, API requests, file writes |
pkg/runner imports nothing from tuilib. The app shell turns its messages
into console records.
Handing over the terminal: Run¶
case tea.KeyMsg:
if msg.String() == "e" {
return s, runner.Run(exec.Command(os.Getenv("EDITOR"), s.path))
}
case runner.Result:
if msg.Err != nil {
return s, app.ErrorOf(msg.Err)
}
return s, s.reload()
Run suspends the alt-screen, connects the subprocess to the real terminal,
sets LINES and COLUMNS (for programs that miss the post-resume SIGWINCH),
clears the screen first, and posts a runner.Result when it exits.
| Variant | Use |
|---|---|
runner.RunWithNotice(cmd, "connecting…") |
Prints a line during a slow handoff (ssh, kubectl exec). |
runner.RunWith(runner.Options{Cmd, Notice, NoClear}) |
Full control; NoClear keeps the normal screen visible. |
Don't call tea.ExecProcess directly. The shell re-enables mouse reporting
after Run and after ctrl+z, which Bubble Tea's restore doesn't.
Capturing in the background: Capture¶
return s, runner.Capture(exec.Command("go", "build", "./..."))
The sequence is CaptureStarted, one CapturedLine per line (with Stderr
set for stderr), then exactly one Captured (with Err set on a non-zero
exit). Under the app shell, the shell drives the reads for you and forwards
every message on to your screen; with OutputKey set, every line also goes to
the output console. A screen that also wants the output on screen just
matches the messages:
case runner.CaptureStarted:
s.log.Clear()
case runner.CapturedLine:
s.log.Append(msg.Text)
case runner.Captured:
if msg.Err != nil {
return s, app.Error("build failed")
}
return s, app.Info("build succeeded")
Outside the shell, chain the reads yourself with runner.Next(msg) after each
CaptureStarted and CapturedLine. A capture nobody reads eventually stalls
the subprocess rather than buffering without limit.
runner.CaptureWith(runner.CaptureOptions{Cmd, Label, Tag}) sets the label
shown in the console and kill picker, and a tag echoed on every message.
runner.Kill(started) stops the process and everything it spawned (the
whole process group on Unix), so killing a shell that runs a compiler stops the
compiler too. In the console, x does this.
Severity comes from the exit status, never from stderr: many tools write progress to stderr.
Go functions: Go¶
return s, runner.Go("restart api", func(ctx context.Context, out io.Writer) error {
fmt.Fprintln(out, "scaling to 0")
return api.Restart(ctx, "api")
})
Go runs the function on its own goroutine and emits the same message
sequence as Capture, so the console, the badge and the kill picker treat it
the same way. Killing it cancels ctx; stopping is up to the function, so
pass ctx to your HTTP calls. runner.GoWith(runner.GoOptions{Label, Detail,
Run, Tag, Context}) is the long form. A Run action in the
action menu is executed this way.
Streaming anything else¶
For other producers (an SSE response, a tailed file, a websocket) use the same
shape Capture uses internally: one goroutine owns the source and pushes into
a channel; a tea.Cmd reads one item and returns it as a message; Update
appends it and returns the read command again. No goroutine touches a model
directly. Cap the buffer: logview defaults to 10,000 lines.
Suspend¶
ctrl+z suspends the program to the shell (app.Options.SuspendKey, off with
DisableSuspend). It's suppressed while a screen is capturing keys, and
ignored on Windows.
See examples/patterns/runner, examples/patterns/capture and
examples/shell/output.