runner

package
v0.21.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 26, 2026 License: MIT Imports: 16 Imported by: 0

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

Constants

This section is empty.

Variables

This section is empty.

Functions

func Capture added in v0.19.0

func Capture(cmd *exec.Cmd) tea.Cmd

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

func Go(label string, fn func(ctx context.Context, out io.Writer) error) tea.Cmd

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 GoWith added in v0.21.0

func GoWith(opts GoOptions) tea.Cmd

GoWith runs Go work with the given options. See Go.

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

func Next(msg tea.Msg) tea.Cmd

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

func Run(cmd *exec.Cmd) tea.Cmd

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).

func RunWith

func RunWith(opts Options) tea.Cmd

RunWith runs an interactive subprocess with the given options. See Options for the available knobs (notice, screen-clear).

func RunWithNotice

func RunWithNotice(cmd *exec.Cmd, notice string) tea.Cmd

RunWithNotice is shorthand for RunWith(Options{Cmd: cmd, Notice: notice}). The screen is cleared before the notice is printed.

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.

type Result

type Result struct {
	Cmd *exec.Cmd
	Err error
}

Result is delivered to your screen's Update when the subprocess exits. Cmd is the same *exec.Cmd you submitted (its ProcessState is populated by the OS); Err is non-nil when the process failed to start or exited with a non-zero status (the typical *exec.ExitError).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL