cliout

package
v0.17.0 Latest Latest
Warning

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

Go to latest
Published: Sep 1, 2026 License: MIT Imports: 16 Imported by: 0

Documentation

Overview

Package cliout is the bubble-tea-free core of the interactive terminal output experience for long-running sdd commands. It carries the log pipe (a slog.Handler whose records flow over a bounded channel to a display consumer), an absolute-count progress reporter, and the durable-record policy that decides which ephemeral entries survive teardown.

Producers stay oblivious: handlers and finders log only through the context logger and never import this package. The CLI surface installs the pipe's handler when stderr is an interactive terminal and drives the transient view (in the sibling internal/cliout/tui package); otherwise it leaves the plain leveled stderr handler in place. This is the audience separation the terminal-experience architecture directive (d-cpt-mvb) draws — terminal-UI code out of the producer layers.

Index

Constants

This section is empty.

Variables

View Source
var (
	StyleLabel = styles.Heading // operation label
	StyleBody  = styles.Body    // message text

)

Footer chrome + log-level styles, drawn from the shared palette so the coordinator's output reads consistently with the presenters surface.

View Source
var ErrUserCancelled = errors.New("cancelled by user")

ErrUserCancelled is the sentinel a coordinator returns when the user interrupts the work (ctrl+c cancels the work context). The top-level CLI handler maps it to a calm "cancelled." message and exit 130 — no raw "context canceled" string ever reaches the user.

Functions

func IsInteractive

func IsInteractive(f *os.File) bool

IsInteractive reports whether f is an interactive terminal suitable for a transient TUI. It returns false for non-terminals (pipes, files, /dev/null, agent stdio) and when NO_COLOR is set — both take the plain leveled path, so agents and piped consumers never see an alt-screen program. term.IsTerminal is used rather than a file-mode check because special devices like /dev/null are character devices but not terminals, and bubble tea opens /dev/tty directly and fails in non-interactive contexts.

func PhaseLabel added in v0.17.0

func PhaseLabel(p model.Phase) string

PhaseLabel is the footer text for a phase. Rendering lives here, not on the domain type, so internal/model stays dependency-free display vocabulary.

func RenderCount added in v0.17.0

func RenderCount(p Progress) string

RenderCount formats the absolute progress count beside the spinner, e.g. "42/120 entries". Empty when no total is known and nothing has completed.

func RenderEntry added in v0.17.0

func RenderEntry(e LogEntry) string

RenderEntry formats one log entry as a styled line: level badge, message, then space-separated key=value attributes. It is the single line renderer for both the plain stderr path (dormant/armed) and the live tea.Printf path.

func WriteEntries

func WriteEntries(ctx context.Context, sink *slog.Logger, entries []LogEntry)

WriteEntries re-logs entries through the durable sink (typically the pre-swap slog.Default), reconstructing each record with its original level, message, and attributes. The sink's own level filter and formatting apply.

Types

type Coordinator added in v0.17.0

type Coordinator struct {
	// contains filtered or unexported fields
}

Coordinator owns the terminal-output lifecycle for one long-running command. It is the slog.Handler installed on the work context (once, via slogutils.WithLogger — no global logger swap), routing each record by state: plain to stderr while dormant, buffered while armed, and into the running program while live. The Recorder observes every record for the teardown re-emit, independent of what the display does.

func NewCoordinator added in v0.17.0

func NewCoordinator(cfg CoordinatorConfig) *Coordinator

NewCoordinator builds a dormant coordinator. Call SetStarter before Run.

func (*Coordinator) Interrupt added in v0.17.0

func (c *Coordinator) Interrupt()

Interrupt cancels the work context. Both the SIGINT handler (dormant/armed, terminal in cooked mode) and the live program's ctrl+c key path converge here; the resulting context.Canceled from work is translated to ErrUserCancelled.

func (*Coordinator) Run added in v0.17.0

func (c *Coordinator) Run(ctx context.Context, work func(context.Context) error) error

Run installs the coordinator as the context logger, launches work on its own goroutine, and drives the lifecycle: it starts the program if the armed debounce expires, or returns without one if work finishes first. On teardown it re-emits kept / fingers-crossed entries to the durable sink for footer-only views. A context.Canceled from work surfaces as ErrUserCancelled.

func (*Coordinator) SetStarter added in v0.17.0

func (c *Coordinator) SetStarter(s DisplayStarter)

SetStarter injects the live-program starter (provided by internal/cliout/tui).

type CoordinatorConfig added in v0.17.0

type CoordinatorConfig struct {
	Policy     Policy
	Stderr     io.Writer
	StreamLogs bool
	Debounce   time.Duration
	// Progress, when set, arms the coordinator on its first published event so
	// the footer appears for progress-only work (no display-eligible log yet).
	Progress *Reporter
}

CoordinatorConfig configures a Coordinator. Debounce defaults to armDebounce when zero; Stderr defaults to a no-op writer when nil.

type DisplayStarter added in v0.17.0

type DisplayStarter interface {
	Start(backlog []LogEntry, live *LogConsumer) (unpainted []LogEntry, err error)
}

DisplayStarter runs the live terminal program. The coordinator owns the decision to start it (armed debounce expiry) and calls Start once, on the driver goroutine, so it blocks there until the program exits. backlog holds the display lines buffered before the program existed (its opening backlog); live delivers subsequent lines, and its Close signals end-of-work so the program quits. Start returns any lines it never painted (work finished before the first-paint gate) so the coordinator can print them plainly after teardown. Implemented by internal/cliout/tui — this interface keeps cliout free of any bubble tea import.

type FingersCrossed

type FingersCrossed struct {
	// Trigger is the level that arms the flush when observed.
	Trigger slog.Level
	// Tail is the number of most-recent entries (all captured levels) kept
	// for the flush.
	Tail int
}

FingersCrossed configures the failure-triggered tail flush.

type LogConsumer

type LogConsumer struct {
	// contains filtered or unexported fields
}

LogConsumer is the display end of a log pipe. Recv hands the next entry to the view loop; Close signals end-of-work so a drained Recv reports done.

func NewLogConsumer added in v0.17.0

func NewLogConsumer(capacity int) *LogConsumer

NewLogConsumer builds a consumer over a channel of the given capacity. The coordinator uses this to hand live lines to a running program; Offer feeds it.

func NewLogPipe

func NewLogPipe(capture slog.Leveler) (slog.Handler, *LogConsumer)

NewLogPipe builds a log pipe: a slog.Handler the caller installs for the operation, and a LogConsumer the display loop drains. capture is the floor level admitted into the pipe — the display may filter higher, and the policy recorder needs everything down to this floor (see Policy.CaptureFloor). Neither end references a bubble tea program, so there is no construction cycle between the pipe and the model that consumes it.

func (*LogConsumer) Close

func (c *LogConsumer) Close()

Close signals that no further entries will be produced. Idempotent. After Close and once the channel is drained, Recv returns ok=false.

func (*LogConsumer) Offer added in v0.17.0

func (c *LogConsumer) Offer(e LogEntry)

Offer sends an entry non-blocking: a full channel drops it rather than stalling the producer, bounding time coupling between producer and display.

func (*LogConsumer) Recv

func (c *LogConsumer) Recv() (LogEntry, bool)

Recv returns the next entry, or ok=false once work has finished and the channel is drained. Buffered entries are always preferred over the done signal so the tail isn't dropped at teardown.

type LogEntry

type LogEntry struct {
	Time    time.Time
	Level   slog.Level
	Message string
	Attrs   []slog.Attr
}

LogEntry is a snapshot of a slog.Record taken at Handle time. The record itself must not be retained past Handle, so the level, message, and the fully-accumulated attribute set (including any WithAttrs / WithGroup state) are copied out here. Rendered with structured styling at display time, not flattened to a string at capture.

type Policy

type Policy struct {
	// Display is the level floor for the live view — typically chattier than
	// the durable floor, since the view is transient.
	Display slog.Leveler

	// KeepAtOrAbove re-emits entries at or above this level to the durable
	// sink on teardown, independent of the live display level.
	KeepAtOrAbove slog.Level

	// FingersCrossed, when set, retains a tail of recent entries at all
	// captured levels and flushes them to the durable sink if an error
	// occurs — so the context around a failure survives even though it was
	// never shown.
	FingersCrossed *FingersCrossed
}

Policy decouples the durable record from the ephemeral display. The live view can be chatty (it vanishes on teardown), while only deliberately kept entries survive to the durable stderr sink.

func (Policy) CaptureFloor

func (p Policy) CaptureFloor() slog.Level

CaptureFloor is the minimum level the log pipe must admit so the policy can do its job: the display floor, the keep threshold, and — when fingers-crossed is armed — everything down to Debug, since a flush must be able to show context that was never displayed.

func (Policy) ShowInDisplay

func (p Policy) ShowInDisplay(level slog.Level) bool

ShowInDisplay reports whether an entry at the given level belongs in the live view.

type Progress

type Progress struct {
	Done  int
	Total int
	Unit  string      // optional noun for rendering, e.g. "chunks"
	Note  string      // optional live status of the work in flight, e.g. "embedding 4 entries · 37 chunks"
	Phase model.Phase // active stage; drives the footer label and arms the coordinator
}

Progress is an absolute snapshot of how far an operation has come. Because every snapshot carries the running totals (not a delta), a dropped update self-corrects: the next one overwrites it with the true state.

func (Progress) Ratio

func (p Progress) Ratio() float64

Ratio returns Done/Total clamped to [0,1], or 0 when Total is unknown.

type Recorder

type Recorder struct {
	// contains filtered or unexported fields
}

Recorder accumulates the entries a Policy will re-emit to the durable sink on teardown. It is fed every entry that enters the pipe (Observe) and produces the re-emit list (Flush). Fully independent of the display loop and of bubble tea, so it is unit-testable on its own.

func NewRecorder

func NewRecorder(p Policy) *Recorder

NewRecorder builds a recorder for the given policy.

func (*Recorder) Failed

func (r *Recorder) Failed() bool

Failed reports whether the fingers-crossed flush is armed.

func (*Recorder) Flush

func (r *Recorder) Flush() []LogEntry

Flush returns the entries to re-emit to the durable sink, in arrival order and deduplicated. Always includes the kept entries; when fingers-crossed is armed it additionally includes the retained tail (all captured levels).

func (*Recorder) MarkFailed

func (r *Recorder) MarkFailed()

MarkFailed arms the fingers-crossed flush from outside the log stream — used when the operation returns an error without having logged at the trigger level.

func (*Recorder) Observe

func (r *Recorder) Observe(e LogEntry)

Observe records an entry that entered the pipe. Entries at or above the keep threshold are retained for re-emit; when fingers-crossed is armed the entry also enters the tail ring, and an entry at the trigger level arms the flush.

type Reporter

type Reporter struct {
	// contains filtered or unexported fields
}

Reporter receives absolute progress updates from the work goroutine and hands the latest to the display loop. It is a single-producer mailbox: the callbacks that drive it (OnBatchStart, OnEntryIndexed, …) fire sequentially from one goroutine, while the view loop is the sole consumer.

func NewReporter

func NewReporter() *Reporter

NewReporter builds a reporter with an empty mailbox.

func (*Reporter) Add

func (r *Reporter) Add(n int)

Add advances the completed count by n and publishes a snapshot.

func (*Reporter) Close

func (r *Reporter) Close()

Close signals that no further progress will be reported. Idempotent.

func (*Reporter) Notify added in v0.17.0

func (r *Reporter) Notify(fn func(Progress))

Notify registers a hook fired with each published snapshot — the coordinator uses it to arm on the first real progress event. Single-subscriber: a later call replaces the hook.

func (*Reporter) Recv

func (r *Reporter) Recv() (Progress, bool)

Recv returns the next progress snapshot, or ok=false once Close has been called and no snapshot is pending. A pending snapshot is preferred over the close signal so the final state isn't dropped.

func (*Reporter) SetNote added in v0.14.0

func (r *Reporter) SetNote(note string)

SetNote sets a short live description of the work currently in flight (e.g. "embedding 4 entries · 37 chunks") and publishes a snapshot. Pass "" to clear it. Independent of the count so the view can name what's being processed while the determinate bar tracks how much work remains.

func (*Reporter) SetPhase added in v0.17.0

func (r *Reporter) SetPhase(phase model.Phase)

SetPhase records the operation's active stage and publishes a snapshot. A non-empty phase is a display-worthy event: it declares real work, so it arms the coordinator even before any total is known (the footer label derives from it). Sticky until the next call — pass "" only to clear it.

func (*Reporter) SetTotal

func (r *Reporter) SetTotal(n int)

SetTotal records the operation's total work units and publishes a snapshot.

func (*Reporter) SetUnit

func (r *Reporter) SetUnit(unit string)

SetUnit sets the noun used when rendering the count (e.g. "chunks").

Directories

Path Synopsis
Package tui is sdd's bubble tea layer: the transient coordinator display for long-running commands (Interactive, which lets a cliout.Coordinator own the dormant → armed → live → done lifecycle so instant work never starts a program) and the reusable init input prompts (text, select, multi-select, confirm).
Package tui is sdd's bubble tea layer: the transient coordinator display for long-running commands (Interactive, which lets a cliout.Coordinator own the dormant → armed → live → done lifecycle so instant work never starts a program) and the reusable init input prompts (text, select, multi-select, confirm).

Jump to

Keyboard shortcuts

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