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/termioImportant
Requires Go 1.25 or later.
import "gopherly.dev/termio"
s := termio.System()
fmt.Fprintln(s.Out, "hello, world")- One type,
Streams, bundlesIn,Out, andErrOuttogether with TTY detection and terminal width. No setup needed for the common case. - Dependency-light core. The
gopherly.dev/termiopackage depends only ongolang.org/x/term. Color support is opt-in through thecolorprofilesubpackage. 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
Writerlatches its own first error. A broken pipe onErrOutdoes not affectOut, 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.
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.
- Quick start
- Sticky errors
- TTY detection
- Terminal width
- Raw stream access
- Testing
- Packages
- Comparison
- Development
- Contributing
- Community
- License
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)
}
}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.
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.
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.
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).
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).
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
}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| 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 |
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.
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- Fork the repository and create a feature branch.
- Make your changes, keeping commits small and focused.
- Run
nix run .#lintandnix run .#test-unitbefore opening a pull request. - Submit a PR against
main.
Join #gopherly on the Gophers Slack.
Apache 2.0, see LICENSE.