ptyx is a Go library that provides a simple, cross-platform API for managing pseudo-terminals (PTY) and terminal TTYs.
- Cross-Platform PTY: Simple API to spawn processes in a pseudo-terminal on macOS, Linux, BSDs (using
ptmx) and on Windows (usingConPTY). - TTY Control: Functions to control the local terminal, including setting raw mode, getting terminal size, and receiving resize notifications.
- I/O Bridge: A
Muxutility to easily connect the local terminal's stdin/stdout to the PTY session. - Zero External Dependencies: Relies only on the standard library and the official
golang.org/xpackages (sys,term).
ptyx acts as a bridge between a user's terminal and a pseudo-terminal (PTY), enabling interactive command-line applications. It abstracts away the platform-specific details of PTY/TTY management.
The architecture consists of three main components:
-
User Interfaces (Console): This represents the user's actual terminal.
ptyxcaptures input from itsstdin(Pipe In) and writes output to itsstdout(Pipe Out). -
Interface Handlers (ptyx): The core of the library, which manages the communication pipes. It ensures that data flows smoothly between the user's console and the underlying pseudo-terminal.
-
Pseudoterminal (Backend): The OS-level PTY implementation that runs the child process.
- On Windows, this is the ConPTY API.
- On Unix-like systems (macOS, Linux), this is a traditional
/dev/ptmxdevice.
go get github.com/safedep/ptyx# Interactive shell
go run ./cmd/shell
# Local spinner/progress (no PTY)
go run ./cmd/spinner
# Show terminal color support
go run ./cmd/color
# Test ANSI color passthrough by running the color demo in a PTY
go run ./cmd/passthrough
# Raw stdin echo
go run ./cmd/echo
# Capture and parse terminal output as events
go run ./cmd/event
# Send input to a program waiting in a PTY
go run ./cmd/scan
# Run a sequence of commands in a shell and handle interruption
go run ./cmd/sequence
# Run a shell inside a PTY, which itself runs inside a PTY
go run ./cmd/nested
# Resize bridge
go run ./cmd/resize -- /bin/sh
# Run an arbitrary command in a PTY
go run ./cmd/run -- bash -lc "echo hi; read -p 'press:' x; echo done"ptyx is designed to be simple to use. Here are a few examples showing how to accomplish common tasks.
This is the most basic use case: running a command in a pseudo-terminal and streaming its output.
package main
import (
"context"
"io"
"log"
"os"
"github.com/safedep/ptyx"
)
func main() {
// Spawn a command in a new PTY session.
// A context is used for cancellation.
s, err := ptyx.Spawn(context.Background(), ptyx.SpawnOpts{
Prog: "ping",
Args: []string{"8.8.8.8"},
})
if err != nil {
log.Fatalf("spawn failed: %v", err)
}
// Ensure the session is closed to clean up resources.
defer s.Close()
// Stream the PTY output to standard out.
go io.Copy(os.Stdout, s.PtyReader())
// Wait for the process to exit.
if err := s.Wait(); err != nil {
log.Printf("process wait failed: %v", err)
}
}For interactive applications like a terminal emulator, you need to connect the user's TTY to the PTY session. ptyx makes this easy.
The following example creates a complete, cross-platform interactive shell.
package main
import (
"context"
"fmt"
"log"
"runtime"
"github.com/safedep/ptyx"
)
func main() {
// 1. Get a handle to the local console/TTY.
c, err := ptyx.NewConsole()
if err != nil {
log.Fatalf("failed to create console: %v", err)
}
defer c.Close()
// 2. Enable virtual terminal processing for color support (especially on Windows).
c.EnableVT()
// 3. Set the TTY to raw mode to pass all key presses directly to the PTY.
st, err := c.MakeRaw()
if err == nil {
defer c.Restore(st)
}
// 4. Get the initial terminal size.
w, h := c.Size()
shell := "sh"
if runtime.GOOS == "windows" {
shell = "powershell.exe"
}
// 5. Spawn the shell in a new PTY with the correct dimensions.
s, err := ptyx.Spawn(context.Background(), ptyx.SpawnOpts{Prog: shell, Cols: w, Rows: h})
if err != nil {
log.Fatalf("failed to spawn: %v", err)
}
defer s.Close()
// 6. Create a multiplexer to bridge I/O between the local TTY and the PTY.
m := ptyx.NewMux()
if err := m.Start(c, s); err != nil {
log.Fatalf("failed to start mux: %v", err)
}
defer m.Stop()
// 7. Handle terminal resize events.
go func() {
for range c.OnResize() {
_ = s.Resize(c.Size())
}
}()
// 8. Wait for the PTY session to end.
if err := s.Wait(); err != nil {
if exitErr, ok := err.(*ptyx.ExitError); ok {
fmt.Printf("\nProcess exited with code %d\n", exitErr.ExitCode)
}
}
}You can gracefully terminate a PTY session by cancelling its context. A common use case is handling user interruptions (e.g., Ctrl+C).
package main
import (
"context"
"io"
"log"
"os"
"os/signal"
"github.com/safedep/ptyx"
)
func main() {
// Create a context that is cancelled when an interrupt signal is received.
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
log.Println("Spawning 'ping' process. Press Ctrl+C to terminate.")
s, err := ptyx.Spawn(ctx, ptyx.SpawnOpts{Prog: "ping", Args: []string{"8.8.8.8"}})
if err != nil {
log.Fatalf("spawn failed: %v", err)
}
defer s.Close()
go io.Copy(os.Stdout, s.PtyReader())
// Wait for the process to exit. This will block until the context is cancelled.
if err := s.Wait(); err != nil {
log.Printf("Process terminated: %v", err)
}
}type Console interface {
In() io.Reader
Out() io.Writer
Err() *os.File
IsATTYOut() bool
Size() (int, int)
MakeRaw() (RawState, error)
Restore(RawState) error
EnableVT()
OnResize() <-chan struct{}
Close() error
}
type Session interface {
PtyReader() io.Reader
PtyWriter() io.Writer
Resize(cols, rows int) error
Wait() error
Kill() error
Close() error
Pid() int
CloseStdin() error
}
type Mux interface {
Start(c Console, s Session) error
Stop() error
}
type SpawnOpts struct {
Prog string
Args []string
Env []string
Dir string
Cols int
Rows int
}
type ExitError struct {
ExitCode int
}
type RawState interface{}- Unix/macOS/WSL: full PTY support using openpty or /dev/ptmx.
- Windows: Full ConPTY session support, console VT, and resize.
MakeRaw switches the console into raw mode so key presses pass straight through
to the PTY, but unlike a plain term.MakeRaw it keeps output post-processing
(OPOST/ONLCR) enabled. A bare \n written directly to the terminal is
therefore still translated to \r\n, so log lines, spinners, and prompts that the
host program prints while raw mode is active start at the left edge instead of
"staircasing" diagonally down the screen.
The trade-off is that raw mode is not byte-transparent on output:
- Line-oriented output is unaffected. A child program's PTY output already
arrives
\r\n-terminated, so re-translation is a harmless no-op. - Full-screen TUI children may render incorrectly. Apps like
vim,htop, orlessemit bare\nas literal cursor movement; when their output is copied straight to this terminal, the added\rcan shift the cursor unexpectedly.
Input handling, Ctrl-C, echo suppression, and Restore are unaffected — only the
output newline translation differs.