termio

package module
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Jun 5, 2026 License: Apache-2.0 Imports: 5 Imported by: 0

README

termio

CI codecov Go Reference Go Report Card License Slack

termio is a small, dependency-light bundle of terminal I/O primitives for Go CLI programs. It gives you a standard way to handle input, output, and error output, with TTY detection and optional color adaptation built in.

go get gopherly.dev/termio

[!IMPORTANT] Requires Go 1.25 or later.

import "gopherly.dev/termio"

s := termio.System()
fmt.Fprintln(s.Out, "hello, world")

Why termio

  • One type, Streams, bundles In, Out, and ErrOut together with TTY detection and terminal width. No setup needed for the common case.
  • Dependency-light core. The gopherly.dev/termio package depends only on golang.org/x/term. Color support is opt-in through the colorprofile subpackage. Import the color adapter only when you need it; callers that do not import it never compile the charmbracelet dependency.
  • Per-stream sticky errors. Each Writer latches its own first error. A broken pipe on ErrOut does not affect Out, and vice versa. No mainstream Go I/O bundle does this.
  • FD preservation. Writer.Fd() returns the original file descriptor even after the color policy and error wrapper are stacked on top. Libraries like bubbletea, glamour, and lipgloss can detect the terminal through the wrapper without type assertions.
  • Designed for testing. Swap in buffer-backed streams with one call. No daemon or OS dependency required.

How it works

flowchart LR
    caller["your code"] --> streams["Streams"]
    streams --> in_f["In: io.Reader (no wrapping)"]
    streams --> out_f["Out: *Writer"]
    streams --> errout_f["ErrOut: *Writer"]

    subgraph out_chain ["Out write chain"]
        cp_out["ColorPolicy.Apply (optional)"] --> sticky_out["sticky error latch"]
        sticky_out --> raw_out["raw io.Writer"]
    end

    subgraph err_chain ["ErrOut write chain"]
        cp_err["ColorPolicy.Apply (optional)"] --> sticky_err["sticky error latch (independent)"]
        sticky_err --> raw_err["raw io.Writer"]
    end

    out_f --> out_chain
    errout_f --> err_chain
    out_f -. "Fd()" .-> fd_out["original FD"]
    errout_f -. "Fd()" .-> fd_err["original FD"]

    style caller fill:#6c63ff,stroke:#4a42d4,color:#fff
    style streams fill:#2d9cdb,stroke:#1a7ab5,color:#fff
    style in_f fill:#a0d2db,stroke:#6fb3bf,color:#1a1a2e
    style out_f fill:#27ae60,stroke:#1e8c4d,color:#fff
    style errout_f fill:#e74c3c,stroke:#c0392b,color:#fff
    style cp_out fill:#f39c12,stroke:#d68910,color:#fff
    style cp_err fill:#f39c12,stroke:#d68910,color:#fff
    style sticky_out fill:#8e44ad,stroke:#6c3483,color:#fff
    style sticky_err fill:#8e44ad,stroke:#6c3483,color:#fff
    style raw_out fill:#1abc9c,stroke:#148f77,color:#fff
    style raw_err fill:#1abc9c,stroke:#148f77,color:#fff
    style fd_out fill:#95a5a6,stroke:#7f8c8d,color:#fff
    style fd_err fill:#95a5a6,stroke:#7f8c8d,color:#fff

The core package (gopherly.dev/termio) has one non-stdlib dependency: golang.org/x/term, used for TTY detection and terminal width queries.

The colorprofile subpackage (gopherly.dev/termio/colorprofile) is the only place github.com/charmbracelet/colorprofile appears. Import it only when you need color adaptation.

The termiotest subpackage (gopherly.dev/termio/termiotest) provides buffer-backed helpers for unit tests.

Contents

  1. Quick start
  2. Sticky errors
  3. TTY detection
  4. Terminal width
  5. Raw stream access
  6. Testing
  7. Packages
  8. Comparison
  9. Development
  10. Contributing
  11. Community
  12. License

Quick start

Without color
package main

import (
    "fmt"
    "os"

    "gopherly.dev/termio"
)

func main() {
    s := termio.System()

    if s.IsInteractive() {
        fmt.Fprintln(s.Out, "running interactively")
    }

    fmt.Fprintf(s.Out, "terminal width: %d\n", s.TerminalWidth())

    if err := s.Err(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}
With color adaptation
package main

import (
    "fmt"
    "os"

    "gopherly.dev/termio"
    "gopherly.dev/termio/colorprofile"
)

func main() {
    policy := colorprofile.Detect(os.Stdout, os.Environ())
    s := termio.System(termio.WithColorPolicy(policy))

    // ANSI sequences are passed through, downsampled, or stripped
    // depending on what the terminal supports.
    fmt.Fprintln(s.Out, "\x1b[32mgreen\x1b[0m or plain, depending on the terminal")

    // Reuse the detected level without re-running detection — pass it to
    // Charm libraries (lipgloss, glamour) or branch on it directly.
    if policy.Profile() == colorprofile.TrueColor {
        fmt.Fprintln(s.Out, "full 24-bit color available")
    }
}

colorprofile.Detect reads environment variables like NO_COLOR, COLORTERM, and TERM to pick the right color level automatically. The policy applies to both Out and ErrOut. Use colorprofile.From to force a specific level (e.g. from a --color flag) instead of detecting it.

Custom streams

Pass your own readers and writers when you want to redirect output, for example to a log file or a test buffer:

s := termio.New(os.Stdin, logFile, os.Stderr)

Nil arguments are safe: a nil reader returns io.EOF on every read; a nil writer discards all bytes.

Sticky errors

Each Writer tracks its own first write error. Once a write fails, every subsequent call to that Writer returns the latched error immediately without forwarding any bytes. The two output streams never share error state.

package main

import (
    "fmt"
    "io"

    "gopherly.dev/termio"
)

func main() {
    // Out writes to a writer that always fails. ErrOut writes to io.Discard.
    s := termio.New(nil, &alwaysFailWriter{}, io.Discard)

    fmt.Fprintln(s.Out, "this will fail")    // error latched on Out
    fmt.Fprintln(s.ErrOut, "this is fine")   // ErrOut is unaffected

    fmt.Println("Out.Err:", s.Out.Err())        // prints the error
    fmt.Println("ErrOut.Err:", s.ErrOut.Err()) // prints <nil>

    // Streams.Err joins both stream errors with errors.Join.
    if err := s.Err(); err != nil {
        fmt.Println("combined:", err)
    }
}

Writer.Fd() returns termio.InvalidFd (the maximum uintptr value) when the underlying stream is not backed by a real file descriptor, for example when a bytes.Buffer is used in tests.

TTY detection

s := termio.System()

// IsInteractive returns true when both stdin and stdout are terminals.
// Use it to decide whether to show interactive prompts.
if s.IsInteractive() {
    // show a prompt
}

// Check each stream individually when you need finer control.
fmt.Println(s.IsStdinTTY())
fmt.Println(s.IsStdoutTTY())
fmt.Println(s.IsStderrTTY())

TTY status is detected once at construction from the underlying file descriptor. You can override it after construction with SetStdinTTY, SetStdoutTTY, and SetStderrTTY. This is mainly useful in tests to exercise interactive code paths with buffer-backed streams (see Testing).

Terminal width

s := termio.System()
width := s.TerminalWidth()

TerminalWidth returns the column width of the terminal connected to Out. When Out is not a terminal or the size cannot be read, it returns termio.DefaultWidth (80).

Raw stream access

RawIn, RawOut, and RawErrOut return the unwrapped streams passed to New or System. Use them when you need to bypass the Writer wrapper, for example to hand the original *os.File to a library that requires a concrete type:

s := termio.System()
f, ok := s.RawOut().(*os.File)
if ok {
    // use f directly
}

Testing

The termiotest subpackage provides buffer-backed streams and returns the underlying bytes.Buffer values directly, so you can assert on output in one line:

import "gopherly.dev/termio/termiotest"

func TestMyCommand(t *testing.T) {
    s, _, out, _ := termiotest.New()

    myCommand(s)

    assert.Equal(t, "expected output\n", out.String())
}

All three streams from termiotest.New() report as non-TTY. When the code under test branches on TTY detection, use termiotest.NewTTY() instead. It sets all three TTY flags to true so IsInteractive() returns true:

func TestInteractivePrompt(t *testing.T) {
    s, _, out, _ := termiotest.NewTTY()

    myInteractiveCommand(s)

    assert.Contains(t, out.String(), "prompt:")
}

You can also override flags individually when you need a specific combination:

s, _, _, _ := termiotest.New()
s.SetStdinTTY(false)
s.SetStdoutTTY(true)  // stdout is a TTY but stdin is not

Packages

Package Import path Purpose
termio gopherly.dev/termio core primitives (Streams, Writer, ColorPolicy)
colorprofile gopherly.dev/termio/colorprofile opt-in charmbracelet color adapter; Policy type with Detect, From, and Profile()
termiotest gopherly.dev/termio/termiotest buffer-backed test helpers

Comparison

termio is a narrow primitive, not a full CLI framework. It does not include a pager, progress bars, prompts, or color themes.

The properties that distinguish it from similar packages:

Property termio gh iostreams Docker streams glab iostreams
Per-stream sticky errors yes no no no
FD preserved through wrapper yes yes no no
Standalone importable yes no (app module) no (CalVer coupling) no (internal/)
Core dep footprint x/term only go-gh + colorable + isatty moby/term + logrus isatty + colorable + termenv + huh

When to use termio: you want a small, neutral I/O bundle with no framework coupling, independent error state per stream, and honest dependency footprint.

When to use something else: if you need built-in color themes, a pager, progress spinners, or interactive prompts, gh's iostreams or glab's iostreams are more complete. They carry their respective framework dependencies, but for framework-coupled projects that is usually not a problem.

[!NOTE] The comparison table above was verified against live source code in June 2026.

Development

This project uses Nix for a reproducible development environment and task runner.

# enter the dev shell
nix develop

# or with direnv
direnv allow

# format Go files
nix run .#fmt

# tidy go.mod / go.sum
nix run .#tidy

# run the linter
nix run .#lint

# run unit tests with race detector
nix run .#test-unit

# lint Markdown
nix run .#lint-md

Contributing

  1. Fork the repository and create a feature branch.
  2. Make your changes, keeping commits small and focused.
  3. Run nix run .#lint and nix run .#test-unit before opening a pull request.
  4. Submit a PR against main.

Community

Join #gopherly on the Gophers Slack.

License

Apache 2.0, see LICENSE.

Documentation

Overview

Package termio provides a dependency-light bundle of terminal I/O primitives for Go CLI programs.

Overview

Streams is the central type. It bundles an input reader (In) and two output writers (Out and ErrOut) together with TTY detection and terminal width querying. Call System to get streams backed by the real OS file descriptors:

s := termio.System()
fmt.Fprintln(s.Out, "hello, world")

Architecture

The three streams are independent: Out and ErrOut are *Writer values rather than plain io.Writer values. Each Writer layers three concerns on top of its raw stream:

  • Color adaptation: an optional ColorPolicy intercepts writes and strips, translates, or passes through ANSI sequences. When no policy is configured, bytes reach the raw stream unmodified.
  • Sticky errors: the first write error is latched per stream. An error on Out does not affect ErrOut, and vice versa.
  • FD preservation: Writer.Fd returns the original file descriptor, so downstream libraries (bubbletea, glamour, lipgloss) can detect the terminal even when they receive the wrapped value.

The core package depends only on golang.org/x/term. Color adaptation is opt-in and lives in the gopherly.dev/termio/colorprofile sub-package; callers that do not need color never compile that dependency.

Quick start — with color adaptation

import (
    "os"
    "gopherly.dev/termio"
    "gopherly.dev/termio/colorprofile"
)

s := termio.System(
    termio.WithColorPolicy(colorprofile.Detect(os.Stdout, os.Environ())),
)
fmt.Fprintln(s.Out, "\x1b[32mgreen\x1b[0m or plain, depending on the terminal")

Quick start — without color

s := termio.System()  // no color dep compiled
fmt.Fprintln(s.Out, "plain output")

Testing

The gopherly.dev/termio/termiotest package provides buffer-backed helpers that return the underlying *bytes.Buffer values for assertion:

s, _, out, _ := termiotest.New()
fmt.Fprintln(s.Out, "test output")
assert.Equal(t, "test output\n", out.String())

Exported surface

Type          Description
Streams       central I/O bundle (In, Out, ErrOut)
Writer        stream wrapper (color + sticky error + FD)
ColorPolicy   interface for ANSI color adaptation
Option        functional option for New / System

Constructor   Description
System        streams from os.Stdin / os.Stdout / os.Stderr
New           streams from caller-supplied readers/writers

Option        Description
WithColorPolicy  inject a ColorPolicy into Out and ErrOut

Constant      Description
DefaultWidth  fallback column width (80) when width is unknown
InvalidFd     sentinel Fd value (^uintptr(0)) for non-file streams

Index

Examples

Constants

View Source
const DefaultWidth = 80

DefaultWidth is the terminal column width assumed when the underlying stream is not a terminal or its size cannot be determined.

View Source
const InvalidFd = ^uintptr(0)

InvalidFd is the sentinel value returned by Writer.Fd when the underlying stream is not backed by a real file descriptor (for example, when a bytes.Buffer is supplied in tests). It equals ^uintptr(0), the maximum uintptr value, which never refers to a valid OS descriptor on any supported platform.

Variables

This section is empty.

Functions

This section is empty.

Types

type ColorPolicy

type ColorPolicy interface {
	// Apply wraps w with a writer that enforces this policy's color
	// translation rules. The returned writer must forward all writes to
	// w, potentially transforming ANSI sequences in transit. Apply must
	// not retain w beyond the lifetime of the returned writer.
	Apply(w io.Writer) io.Writer
}

ColorPolicy adapts ANSI escape sequences for a specific terminal capability. Implementations intercept writes and strip, translate, or pass through color codes based on what the destination terminal supports.

The core termio package does not ship a built-in implementation; callers who need color adaptation import gopherly.dev/termio/colorprofile and pass the result of [colorprofile.Detect] via WithColorPolicy. When no policy is configured, writes reach the raw stream unmodified.

Implementors must be safe for concurrent use from multiple goroutines.

type Option

type Option func(*config)

Option configures a Streams at construction time via New or System. Use the With* helpers to build options; do not implement Option directly.

func WithColorPolicy

func WithColorPolicy(p ColorPolicy) Option

WithColorPolicy sets the ColorPolicy applied to Out and ErrOut during Writer construction. When not set (or set to nil), writes reach the raw stream unmodified.

Example using the bundled colorprofile adapter:

import "gopherly.dev/termio/colorprofile"

s := termio.System(
    termio.WithColorPolicy(colorprofile.Detect(os.Stdout, os.Environ())),
)

type Streams

type Streams struct {
	// In is the input stream. It is the user-supplied [io.Reader] (typically
	// [os.Stdin]) and is not wrapped. Treat as read-only after construction.
	In io.Reader

	// Out is the primary output stream. Writes are color-adapted (when a
	// [ColorPolicy] is set), the first error is latched, and the original
	// FD is preserved for terminal-detection by downstream libraries.
	Out *Writer

	// ErrOut is the diagnostics stream. It has the same wrapping policy as
	// Out, but its sticky error is independent.
	ErrOut *Writer
	// contains filtered or unexported fields
}

Streams bundles the three standard I/O channels a CLI program needs — input, output, and diagnostics — together with terminal capability detection. It is the central type of the termio package.

Out and ErrOut are *Writer values rather than plain io.Writer values: the concrete type surfaces per-stream sticky-error state and FD preservation without type assertions. Color adaptation is injected via WithColorPolicy; when no policy is configured the raw stream is used directly. An error on Out does not affect ErrOut, and vice versa.

Streams is safe to construct concurrently with other operations but is not safe for concurrent mutation via the SetXxxTTY methods.

func New

func New(in io.Reader, out, errOut io.Writer, opts ...Option) *Streams

New returns a Streams over the supplied streams. Pass *os.File values for production code (TTY detection works against the real file descriptor). For tests, prefer gopherly.dev/termio/termiotest.New, which also returns the underlying buffers for assertion.

Nil arguments are replaced with safe no-op streams: a reader that returns io.EOF immediately and a writer that discards all bytes.

Example

ExampleNew shows how to supply your own readers and writers. This is the pattern used in tests and in code that needs to redirect output.

package main

import (
	"fmt"

	"gopherly.dev/termio/termiotest"
)

func main() {
	s, _, out, errOut := termiotest.New()

	fmt.Fprintln(s.Out, "product output")        //nolint:errcheck
	fmt.Fprintln(s.ErrOut, "diagnostic message") //nolint:errcheck

	fmt.Print("out: ", out.String())
	fmt.Print("err: ", errOut.String())
}
Output:
out: product output
err: diagnostic message

func System

func System(opts ...Option) *Streams

System returns a Streams backed by os.Stdin, os.Stdout, and os.Stderr. TTY status and terminal width are detected against the real file descriptors. Apply functional options to configure color adaptation.

Example

ExampleSystem demonstrates the zero-config constructor. In production code this connects to os.Stdin, os.Stdout, and os.Stderr with TTY detection performed against the real file descriptors.

package main

import (
	"fmt"

	"gopherly.dev/termio/termiotest"
)

func main() {
	// For reproducible example output we use termiotest.New() here; real
	// programs would call termio.System().
	s, _, out, _ := termiotest.New()

	fmt.Fprintln(s.Out, "hello from termio") //nolint:errcheck

	fmt.Print(out.String())
}
Output:
hello from termio

func (*Streams) Err

func (s *Streams) Err() error

Err returns the first write error encountered by either Out or ErrOut, or nil when all writes have succeeded so far. When both streams have latched errors, both are reported joined with errors.Join.

func (*Streams) IsInteractive

func (s *Streams) IsInteractive() bool

IsInteractive reports whether both stdin and stdout are terminals. When true, the program may safely display interactive prompts.

func (*Streams) IsStderrTTY

func (s *Streams) IsStderrTTY() bool

IsStderrTTY reports whether the diagnostics stream is a terminal.

func (*Streams) IsStdinTTY

func (s *Streams) IsStdinTTY() bool

IsStdinTTY reports whether the input stream is a terminal.

func (*Streams) IsStdoutTTY

func (s *Streams) IsStdoutTTY() bool

IsStdoutTTY reports whether the primary output stream is a terminal.

func (*Streams) RawErrOut

func (s *Streams) RawErrOut() io.Writer

RawErrOut returns the unwrapped diagnostics stream supplied to New or System.

func (*Streams) RawIn

func (s *Streams) RawIn() io.Reader

RawIn returns the unwrapped input stream supplied to New or System.

func (*Streams) RawOut

func (s *Streams) RawOut() io.Writer

RawOut returns the unwrapped output stream supplied to New or System.

func (*Streams) SetStderrTTY

func (s *Streams) SetStderrTTY(v bool)

SetStderrTTY overrides the cached stderr TTY status. See Streams.SetStdinTTY for typical usage.

func (*Streams) SetStdinTTY

func (s *Streams) SetStdinTTY(v bool)

SetStdinTTY overrides the cached stdin TTY status. Use in tests to test interactive code paths with a buffer-backed stream.

func (*Streams) SetStdoutTTY

func (s *Streams) SetStdoutTTY(v bool)

SetStdoutTTY overrides the cached stdout TTY status. See Streams.SetStdinTTY for typical usage.

func (*Streams) TerminalWidth

func (s *Streams) TerminalWidth() int

TerminalWidth returns the column width of the controlling terminal, or DefaultWidth when stdout is not a terminal or its size cannot be queried.

type Writer

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

Writer is a stream wrapper that layers three concerns on top of a raw io.Writer:

  • Color adaptation: an optional ColorPolicy intercepts writes and strips, translates, or passes through ANSI sequences.
  • Sticky error: the first write error is latched and returned by all subsequent writes, preventing silent partial output. The error is independent per Writer, so an error on Out does not affect ErrOut.
  • FD preservation: Writer.Fd returns the file descriptor of the original underlying stream, letting downstream libraries (bubbletea, glamour, lipgloss) detect the terminal even when they receive a wrapped value.

Writer satisfies io.Writer and interface{ Fd() uintptr }. Obtain a Writer via New or System; do not construct one directly.

Writer is not safe for concurrent use by multiple goroutines.

func (*Writer) Err

func (w *Writer) Err() error

Err returns the first write error recorded by this Writer, or nil when all writes have succeeded so far. The error is latched: once set it is returned by every subsequent Writer.Write call and remains accessible via Err.

Example

ExampleWriter_Err illustrates the per-stream sticky error. After a write fails, the error is latched on that Writer only — the other stream continues to work normally.

package main

import (
	"errors"
	"fmt"
	"io"

	"gopherly.dev/termio"
)

func main() {
	errWriter := &alwaysFailWriter{}
	s := termio.New(nil, errWriter, io.Discard)

	fmt.Fprintln(s.Out, "this will fail") //nolint:errcheck

	fmt.Println("Out.Err:", s.Out.Err())
	fmt.Println("ErrOut.Err:", s.ErrOut.Err())
}

// alwaysFailWriter returns an error on every write call.
type alwaysFailWriter struct{}

func (a *alwaysFailWriter) Write(_ []byte) (int, error) {
	return 0, errors.New("always fails")
}
Output:
Out.Err: always fails
ErrOut.Err: <nil>

func (*Writer) Fd

func (w *Writer) Fd() uintptr

Fd returns the file descriptor of the underlying stream, or InvalidFd when the stream is not backed by a real OS file descriptor (for example, when a bytes.Buffer was supplied in tests).

The returned value is a bare uintptr so that the Go garbage collector does not treat it as a live reference to the os.File, matching the contract of os.File.Fd. Callers that need to call syscalls must ensure the File (or the Streams that owns this Writer) remains open for the duration of the operation.

func (*Writer) Write

func (w *Writer) Write(p []byte) (int, error)

Write writes p to the underlying stream. If a previous write recorded an error, Write returns that error immediately without forwarding the bytes.

Write implements io.Writer.

Directories

Path Synopsis
Package colorprofile provides a termio.ColorPolicy implementation backed by github.com/charmbracelet/colorprofile.
Package colorprofile provides a termio.ColorPolicy implementation backed by github.com/charmbracelet/colorprofile.
Package termiotest provides *termio.Streams test helpers with buffer- backed streams and convenience accessors for the underlying buffers.
Package termiotest provides *termio.Streams test helpers with buffer- backed streams and convenience accessors for the underlying buffers.

Jump to

Keyboard shortcuts

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