Read From Pipe and Terminal in Same Go App

Created on Apr 12, 2025  /  Updated on Apr 12, 2025    #go   #tui  
Disclaimer: Views expressed in this software engineering blog are personal and do not represent my employer. Readers are encouraged to verify information independently.

Introduction

Hello, I wanted to create a small app that do fuzzy matching on what ever passed to it in the pipe. The app will take the input from the pipe, then enter terminal mode to interactive fuzzing.

But there is a problem, that took me a while to figure out how to solve it.

The problem

The problem is that once os.Stdin is used in pipe mode, it can’t be switched to terminal mode for interactive use.

package main

import (
	"bufio"
	"fmt"
	"io"
	"os"

	"golang.org/x/term"
)

func main() {
	var input []string
	scanner := bufio.NewScanner(os.Stdin)
	for scanner.Scan() {
		input = append(input, scanner.Text())
	}
	if err := scanner.Err(); err != nil {
		panic(fmt.Errorf("error reading input: %w", err))
	}

	oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
	if err != nil {
		panic(err)
	}
	defer term.Restore(int(os.Stdin.Fd()), oldState)

	screen := struct {
		io.Reader
		io.Writer
	}{os.Stdin, os.Stdin}
	_ = term.NewTerminal(screen, "")
}

running the application in pipe mode

λ /tmp/demo/ ls | go run .
panic: inappropriate ioctl for device

goroutine 1 [running]:
main.main()
        /tmp/tt/main.go:24 +0x2d3
exit status 2

Solution

The solution is to use /dev/tty instead of os.Stdin for terminal interactions. /dev/tty always refers to the terminal connected to the process, regardless of whether stdin is being used as a pipe.

package main

import (
	"bufio"
	"fmt"
	"io"
	"os"

	"golang.org/x/term"
)

func main() {
	var input []string
	scanner := bufio.NewScanner(os.Stdin)
	for scanner.Scan() {
		input = append(input, scanner.Text())
	}
	if err := scanner.Err(); err != nil {
		panic(fmt.Errorf("error reading input: %w", err))
	}

	tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
	if err != nil {
		panic(fmt.Errorf("failed to open /dev/tty: %w", err))
	}
	defer tty.Close()

	oldState, err := term.MakeRaw(int(tty.Fd()))
	if err != nil {
		panic(err)
	}
	defer term.Restore(int(tty.Fd()), oldState)

	screen := struct {
		io.Reader
		io.Writer
	}{tty, tty}

	t := term.NewTerminal(screen, "")

	var buf [1]byte
	for {
		_, err := screen.Read(buf[:])
		if err != nil {
			panic(err)
		}

		if buf[0] == 3 { // ASCII code 3 is Ctrl+C
			return
		}

		t.Write(buf[:])
	}
}

now if we run the application, it no longer panics and I can type

λ /tmp/demo/ ls | go run .
I am input!
Kanna Kamui

for unix based OS it's `tty`, likely for windows there is a similar concept. I don't use windows, so I don't know what it should be.

If you are interested in fuzzy app, which contains more advanced interactions feel free to look at play-fuzz project.