Documentation
¶
Overview ¶
Package runner runs work from inside a Bubble Tea program, in three shapes that differ in what happens to the terminal and to the output.
- Run hands the real TTY to a subprocess, suspending the TUI for the duration. Right for $EDITOR, less, htop — and the reason its output is unrecoverable, since the subprocess owns the screen.
- Capture runs a subprocess without suspending anything, streaming its stdout and stderr back as messages while the TUI stays live.
- Go does the same for work that is not a subprocess at all — an HTTP call, an API request, a file write — streaming whatever the function writes to an io.Writer.
Capture and Go emit the identical message sequence, so everything downstream handles them the same way: the app shell logs both into the output console, counts each as one event, and offers both in the kill picker. See capture.go and gofunc.go.
This package imports nothing from tuilib, which is what makes it safe for anything to depend on. Its messages are deliberately neutral; pkg/app is what turns them into log records, because the log format and the source attribution are shell knowledge.
Run ¶
Run suspends the TUI (releasing the terminal so the subprocess can take over stdin/stdout/stderr), executes the command, then re-enters the alt-screen once the subprocess exits.
Use it for editors ($EDITOR), pagers (less, man), full-screen TUIs (htop, k9s), or one-shot interactive commands (ssh, kubectl exec). For the duration of the run the TUI is fully suspended — the subprocess owns the terminal.
Usage:
// dispatch from a screen's Update on some key:
cmd := exec.Command(os.Getenv("EDITOR"), "/tmp/scratch")
return s, runner.Run(cmd)
// receive the result on a later Update tick:
case runner.Result:
s.last = msg // msg.Cmd.ProcessState is populated; msg.Err is the run error
Index ¶
- func Capture(cmd *exec.Cmd) tea.Cmd
- func CaptureWith(opts CaptureOptions) tea.Cmd
- func Go(label string, fn func(ctx context.Context, out io.Writer) error) tea.Cmd
- func GoWith(opts GoOptions) tea.Cmd
- func Kill(m CaptureStarted) error
- func Next(msg tea.Msg) tea.Cmd
- func Run(cmd *exec.Cmd) tea.Cmd
- func RunWith(opts Options) tea.Cmd
- func RunWithNotice(cmd *exec.Cmd, notice string) tea.Cmd
- type CaptureOptions
- type CaptureStarted
- type Captured
- type CapturedLine
- type GoOptions
- type Options
- type Result
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func Capture ¶ added in v0.19.0
Capture runs cmd without handing the terminal over, streaming its stdout and stderr back as messages while the TUI stays live.
This is the counterpart to Run, not a mode of it. Run suspends the program and gives the subprocess the real TTY, which is right for an editor, a pager, or htop — and which means the output is gone the moment the TUI repaints. Capture is for the other kind of subprocess: the one whose output you actually want to read. Teeing a full-screen program would be meaningless, so the two cannot be the same call.
The message sequence is CaptureStarted, then a CapturedLine per line, then exactly one Captured. Each message carries the handle needed to ask for the next one — see Next — so the consumer drives the read at its own pace:
case runner.CaptureStarted, runner.CapturedLine:
return s, runner.Next(msg)
The app shell does this for you when app.Options.OutputKey is set, and forwards every message on to the active screen besides. Nothing here depends on tuilib: the shell translates these messages into log records, because the log format, the source attribution and the read-marker are shell knowledge, not runner knowledge.
Backpressure is real and deliberate: the stream buffers a bounded number of lines and a consumer that stops calling Next will eventually stall the subprocess rather than grow memory without limit.
func CaptureWith ¶ added in v0.19.0
func CaptureWith(opts CaptureOptions) tea.Cmd
CaptureWith runs a subprocess with the given options. See Capture.
func Go ¶ added in v0.21.0
Go runs fn on its own goroutine and streams whatever it writes back as the same messages a subprocess Capture produces: CaptureStarted, a CapturedLine per line, then exactly one Captured carrying fn's error.
This is Capture's counterpart for work that is not a subprocess — an HTTP call, a k8s API request, a file write. Everything downstream already knows how to handle it: the app shell logs the run into the output console, counts it as one event in the statusbar badge, and lists it in the kill picker, without a line of new code on either side.
return runner.Go("restart api", func(ctx context.Context, out io.Writer) error {
fmt.Fprintln(out, "scaling to 0")
return api.Restart(ctx, "api")
})
Cancellation is cooperative ¶
runner.Kill cancels the context. Whether that stops anything is up to fn: one that selects on ctx.Done() or hands ctx to an HTTP request stops promptly; one that ignores it runs to completion and its result is reported normally. There is no way to preempt a goroutine in Go, so the honest contract is "the request is delivered", not "the work is stopped".
Backpressure ¶
out shares the capture buffer, so a consumer that stops calling Next eventually blocks fn's next write rather than growing memory without limit — the same trade a subprocess capture makes.
func Kill ¶ added in v0.19.0
func Kill(m CaptureStarted) error
Kill stops the subprocess behind a CaptureStarted, and everything it spawned.
The "and everything it spawned" is the part that matters. Captures are usually a shell wrapping a build, and killing the shell alone leaves the compiler running — still writing into a pipe nobody reads, with no handle left to stop it by. On Unix the whole process group is signalled; see setProcessGroup, and capture_windows.go for what Windows can't do here.
Safe to call on a run that never started or has already exited — both report nil, since in either case there is nothing left to stop.
func Next ¶ added in v0.19.0
Next returns the command that reads the next message from the capture that msg belongs to, or nil for anything else — including Captured, after which there is nothing more to read.
func Run ¶
Run returns a tea.Cmd that suspends the program, runs cmd connected to the controlling terminal, and posts a Result when the subprocess exits. The screen is cleared before the subprocess starts (use RunWith with NoClear=true to opt out).
Plumbing the runner takes care of:
- Stdin/Stdout/Stderr default to os.Stdin/Stdout/Stderr (real TTY file descriptors) when not already set, so the subprocess gets direct terminal access and TIOCGWINSZ works.
- LINES and COLUMNS env vars are populated from the current terminal size, as a fallback for ncurses-style programs that miss the post-resume SIGWINCH on some terminal emulators (htop, top, less are the usual suspects).
Types ¶
type CaptureOptions ¶ added in v0.19.0
type CaptureOptions struct {
// Cmd is the subprocess to run. Required.
Cmd *exec.Cmd
// Label names the run in the log and in the kill picker. Defaults to
// the command's base name, which is also what the log uses as the
// line's Source — for captured output the honest answer to "what
// produced this line" is the command, not the screen that launched it.
Label string
// Tag is an opaque correlation token echoed on every message. See
// CaptureStarted.Tag.
Tag string
}
CaptureOptions configures CaptureWith.
type CaptureStarted ¶ added in v0.19.0
type CaptureStarted struct {
RunID int64
Label string
Cmd *exec.Cmd
// Tag is an opaque token the caller chose, echoed on every message from
// this run.
//
// It exists because a consumer tracking in-flight work needs to match a
// finished run back to whatever it launched it *for*, and neither Label
// nor RunID can do that: labels collide across targets, and the RunID is
// minted inside the command, after the caller has returned. The app shell
// uses it to release the right Exclusive gate.
Tag string
// Detail is the head line describing what is running: the command line
// for a subprocess, the label for a Go run.
//
// It exists because Cmd is nil for a Go run, and a consumer deriving the
// head from the command alone renders "(no command)" for every one of
// them. Empty on a message built by hand, so a consumer should fall back
// to whatever it did before.
Detail string
// contains filtered or unexported fields
}
CaptureStarted is delivered when a capture begins. It carries the *exec.Cmd so a consumer can retain a kill handle: nothing else in the sequence offers one, and by the time Captured arrives there is nothing left to signal.
type Captured ¶ added in v0.19.0
type Captured struct {
RunID int64
Label string
Tag string
Cmd *exec.Cmd
Err error
// contains filtered or unexported fields
}
Captured is delivered once, after the last CapturedLine, when the subprocess has exited. Err is the *exec.ExitError for a non-zero exit, or the start error when the process never ran.
type CapturedLine ¶ added in v0.19.0
type CapturedLine struct {
RunID int64
Label string
Tag string
Text string
Stderr bool
// contains filtered or unexported fields
}
CapturedLine is one line of subprocess output.
type GoOptions ¶ added in v0.21.0
type GoOptions struct {
// Label names the run in the log and in the kill picker. Defaults to
// "task".
Label string
// Detail is the head line for the run. Defaults to Label. Set it when
// the label is a short name but the head should say more.
Detail string
// Run is the work. Required; a nil Run reports a run that started and
// immediately finished, so a consumer's start/end bookkeeping stays
// symmetric.
Run func(ctx context.Context, out io.Writer) error
// Tag is an opaque correlation token echoed on every message from this
// run. See CaptureStarted.Tag.
Tag string
// Context is the parent for the run's cancellable context. Defaults to
// context.Background().
Context context.Context
}
GoOptions configures GoWith.
type Options ¶
type Options struct {
// Cmd is the subprocess to run. Required.
Cmd *exec.Cmd
// Notice, when non-empty, is printed once to stderr after the TUI
// suspends and before the subprocess starts. Use it for slow handoffs
// (kubectl exec, ssh, anything with a perceptible connect latency) so
// the user sees feedback instead of a blank gap. The subprocess is
// free to clear the screen on startup; that's fine, the goal is
// feedback during the handoff, not a persistent banner.
Notice string
// NoClear suppresses the screen clear that normally precedes the
// subprocess. By default the terminal is cleared so the alt-screen
// exit doesn't leave TUI artifacts visible during commands that
// don't repaint (sh -c, echo, short scripts). Set NoClear=true to
// preserve whatever was on the normal screen prior to the TUI.
NoClear bool
}
Options configures RunWith. The zero value clears the screen and prints no notice — the right defaults for typical interactive subprocesses.