Repository: junegunn/fzf (language: go)

Issue:
automatic tty redirection should not statically target /dev/tty, but the current pty/pts
### Checklist

- [x] I have read through the manual page (`man fzf`)
- [x] I have searched through the existing issues
- [ ] For bug reports, I have checked if the bug is reproducible in the latest version of fzf

### Output of `fzf --version`

0.59.0 (bbe1721)

### OS

- [x] Linux
- [ ] macOS
- [ ] Windows
- [ ] Etc.

### Shell

- [x] bash
- [x] zsh
- [x] fish

### Problem / Steps to reproduce

Since 0.53.0 fzf will automatically redirect execute actions to /dev/tty,

so that the usual hack

```sh
ls | fzf --bind 'space:execute:vim {} < /dev/tty >/dev/tty'  > selected
```

could be simplified, without worrying about any redirection

```sh
ls | fzf --bind `space:execute:vim {}' > selected
```

but the static /dev/tty is not universally understood/not always a working solution.
A more robust approach would be to query the current pty (e.g. with /usr/bin/tty), which would return something like /dev/pts/4

The more general solution, that also works with programs like emacsclient, should probably be equivalent to something like this:

```sh
CURRENT_TTY=$(tty)
ls | fzf --bind "space:execute:emacsclient -t -a \"\" -c {} < $CURRENT_TTY > $CURRENT_TTY" > selected
```


Discussion / hints:
Have you actually had the problem while using `emacsclient`? If so, can you describe what happens in that case?

It's odd because fzf writes directly to `/dev/tty`, so if it's not available, fzf shouldn't run at all, let alone `execute`.

https://github.com/junegunn/fzf/blob/ac32fbb3b2d58a7ba61a9a8b1c09420dc4870199/src/tui/light.go#L30
Yes emacsclient will always fail with simple /dev/tty redirection.
Reporting

> *error*: could not open file: /dev/tty


 I guess the problem is, that it connects to the emacsdaemon, which no longer has access to a controlling terminal, but still can make use of the concrete pty device node you can obtain by asking $(tty). This is probably also the reason why the non-deamon version of emacs will work in this case.

This is not specific to emacsclient though, it's just the most likely candidate for me to observe this problem. 
The point is, a controlling terminal is typically available, that's why /dev/tty usually works, but in some cases it will fail. On the other hand, accessing the correct unix.Ttyname(os.Stdin.Fd()) device node should work reliably.
Can you explain how I can test fzf in emacs? I've never used emacs before.
> unix.Ttyname(os.Stdin.Fd()) device node should work reliably.

Unfortunately, this approach can't be used. Because fzf is a filter, stdin is usually not the tty device.

```sh
$ ls | fzf

$ tty
/dev/ttys009

$ ls | tty
not a tty
```

We could make fzf check if a variable named `FZF_TTY` is set and use it instead of `/dev/tty`.
Ah right, sorry good point. Since fzf requires to have a controlling terminal, we could just keep using that to learn the pty, that we need to provide to the children 

```go
fd, err := unix.Open("/dev/tty", unix.O_RDWR, 0)
consoleDevice, err := unix.Ttyname(fd)
```
Can you show me how I can reproduce the problem and see if the patch helps? Like I said, no experience in Emacs.
Sure, i really should have added this to the first message already, sorry:

* First you need to start emacs as a daemon.

```sh
emacs --daemon
```

* launching emacslcient from fzf  with the actual pty works as exepcted:
emacs will allow you to edit a selected file after pressing space.
You can exit emacs with ctrl+(x,c). That is pressing and holding ctrl, then typing "x", then typing "c", while still holding ctrl.


```sh
CURRENT_TTY=$(tty)
ls | fzf --bind "space:execute:emacsclient -t -a \"\" -c {} < $CURRENT_TTY > $CURRENT_TTY" > selected
```

* trying to launch emacsclient with /dev/tty will fail, because emacsdaemon has no access to it.

```sh
ls | fzf --bind "space:execute:emacsclient  -t -a \"\" -c {} < /dev/tty > /dev/tty" > selected
*ERROR*: Could not open file: /dev/tty
```
Thanks, I can reproduce the problem. One other thing I noticed is that `CURRENT_TTY=$(tty)` approach doesn't really work nicely with `--tmux` option as shown below.

<img width="1710" alt="Image" src="https://github.com/user-attachments/assets/c47ab920-4d84-4445-942f-8b64c29e3529" />

So, fzf should get the device name after starting fzf inside tmux popup.

> fd, err := unix.Open("/dev/tty", unix.O_RDWR, 0)
> consoleDevice, err := unix.Ttyname(fd)

Does this code work? There is no `unix.Ttyname` function. I've tested the approach using our `ttyname` implementation slightly changing it to take a file descriptor (instead of using hard-coded 2).

https://github.com/junegunn/fzf/blob/6fa8295ac5cd271f25f431611c25944bed416120/src/tui/ttyname_unix.go#L15-L21

But I couldn't get it to work.
Ah sorry. Looks like I led you into a wrong direction. Getting the ttyname of /dev/tty will usually still return /dev/tty, instead of the actual pty. 

Instead when allocating a new PTY, everything seems to work as intended. Even with --tmux.


```diff
diff --git a/src/tui/light_unix.go b/src/tui/light_unix.go
index 76aac2eb..447c0a13 100644
--- a/src/tui/light_unix.go
+++ b/src/tui/light_unix.go
@@ -2,6 +2,15 @@
 
 package tui
 
+/*
+#define _XOPEN_SOURCE 600
+#include <fcntl.h>
+#include <unistd.h>
+#include <stdlib.h>
+#include <errno.h>
+*/
+import "C"
+
 import (
        "errors"
        "os"
@@ -42,8 +51,30 @@ func (r *LightRenderer) closePlatform() {
        r.ttyout.Close()
 }
 
+func open_pty() (processTTY string, err error) {
+
+       m, err := C.posix_openpt(C.O_RDWR | C.O_NOCTTY)
+       if m < 0 {
+               return "", err
+       }
+       defer C.close(m)
+       if res, err := C.grantpt(m); res < 0 {
+               return "", err
+       }
+       if res, err := C.unlockpt(m); res < 0 {
+               return "", err
+       }
+       processTTY = C.GoString(C.ptsname(m))
+       return processTTY, nil
+}
+
 func openTty(mode int) (*os.File, error) {
-       in, err := os.OpenFile(consoleDevice, mode, 0)
+       ptyname, err := open_pty()
+       if err != nil {
+               ptyname = consoleDevice
+       }
+
+       in, err := os.OpenFile(ptyname, mode, 0)
        if err != nil {
                tty := ttyname()
                if len(tty) > 0 {

```

The code above is mostly a copy of [pty_cgo.go](https://github.com/golang/go/blob/master/src/internal/testpty/pty_cgo.go) 
Thanks, is there any way we can do it without cgo? I remember we had moved away from using cgo for various reasons (e.g. trouble with cross compiling), and I don't want to bring it back.
Fallbacking to /dev/tty in case a ttyname can't be found would make it work for both fzf and fzf --tmux.

```diff
diff -ru ./src/tui/light_unix.go b/src/tui/light_unix.go
--- ./src/tui/light_unix.go	1969-12-31 19:00:01.000000000 -0500
+++ b/src/tui/light_unix.go	2025-04-13 11:07:53.380319509 -0400
@@ -43,14 +43,14 @@
 }
 
 func openTty(mode int) (*os.File, error) {
+	tty := ttyname()
+	if len(tty) > 0 {
+		if in, err := os.OpenFile(tty, mode, 0); err == nil && util.IsTty(in) {
+			return in, nil
+		}
+	}
 	in, err := os.OpenFile(consoleDevice, mode, 0)
 	if err != nil {
-		tty := ttyname()
-		if len(tty) > 0 {
-			if in, err := os.OpenFile(tty, mode, 0); err == nil {
-				return in, nil
-			}
-		}
 		return nil, errors.New("failed to open " + consoleDevice)
 	}
 	return in, nil
diff -ru ./test/test_exec.rb b/test/test_exec.rb
--- ./test/test_exec.rb	1969-12-31 19:00:01.000000000 -0500
+++ b/test/test_exec.rb	2025-04-13 14:23:10.215855096 -0400
@@ -380,7 +380,7 @@
   end
 
   def test_become_tty
-    tmux.send_keys "sleep 0.5 | #{FZF} --bind 'start:reload:ls' --bind 'load:become:tty'", :Enter
+    tmux.send_keys "sleep 0.5 | #{FZF} --bind 'start:reload:ls' --bind 'load:become:tty' 2>/dev/null", :Enter
     tmux.until { |lines| assert_includes lines, '/dev/tty' }
   end
``` 
I can confirm the changes suggested by @tlxpro solve the issue. Thanks!
Also the code stays nice without introducing cgo dependencies. 
@tlxpro  Thanks. Can you open a pull request? We can continue the discussion there.

Fix patch:
diff --git a/man/man1/fzf.1 b/man/man1/fzf.1
index b64b6100093..5d4d653eba4 100644
--- a/man/man1/fzf.1
+++ b/man/man1/fzf.1
@@ -228,6 +228,13 @@ e.g. \fB# Avoid rendering both fzf instances at the same time
      (sleep 1; seq 1000000; sleep 1) |
        fzf \-\-sync \-\-query 5 \-\-listen \-\-bind start:up,load:up,result:up,focus:change\-header:Ready\fR
 .RE
+.TP
+.B "\-\-no\-tty\-default"
+Make fzf search for the current TTY device via standard error instead of using
+\fB/dev/tty\fR. This option was added to avoid the problem when trying to open
+emacsclient from within fzf. Alternativly, you can change the default TTY
+device by setting \fB--tty-default=DEVICE_NAME\fR.
+
 .SS GLOBAL STYLE
 .TP
 .BI "\-\-style=" "PRESET"
diff --git a/src/options.go b/src/options.go
index c250fb59424..e94e82f9bd9 100644
--- a/src/options.go
+++ b/src/options.go
@@ -631,6 +631,7 @@ type Options struct {
 	MEMProfile        string
 	BlockProfile      string
 	MutexProfile      string
+	TtyDefault        string
 }
 
 func filterNonEmpty(input []string) []string {
@@ -730,6 +731,7 @@ func defaultOptions() *Options {
 		WalkerOpts:   walkerOpts{file: true, hidden: true, follow: true},
 		WalkerRoot:   []string{"."},
 		WalkerSkip:   []string{".git", "node_modules"},
+		TtyDefault:   tui.DefaultTtyDevice,
 		Help:         false,
 		Version:      false}
 }
@@ -2336,6 +2338,12 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
 			}
 		case "--no-tmux":
 			opts.Tmux = nil
+		case "--tty-default":
+			if opts.TtyDefault, err = nextString("tty device name required"); err != nil {
+				return err
+			}
+		case "--no-tty-default":
+			opts.TtyDefault = ""
 		case "--force-tty-in":
 			// NOTE: We need this because `system('fzf --tmux < /dev/tty')` doesn't
 			// work on Neovim. Same as '-' option of fzf-tmux.
diff --git a/src/proxy.go b/src/proxy.go
index daeb680b13b..47c414741b1 100644
--- a/src/proxy.go
+++ b/src/proxy.go
@@ -145,7 +145,7 @@ func runProxy(commandPrefix string, cmdBuilder func(temp string, needBash bool)
 					env = elems[1:]
 				}
 				executor := util.NewExecutor(opts.WithShell)
-				ttyin, err := tui.TtyIn()
+				ttyin, err := tui.TtyIn(opts.TtyDefault)
 				if err != nil {
 					return ExitError, err
 				}
diff --git a/src/terminal.go b/src/terminal.go
index 7d826fa3be7..4ed3019b07f 100644
--- a/src/terminal.go
+++ b/src/terminal.go
@@ -381,6 +381,7 @@ type Terminal struct {
 	slab               *util.Slab
 	theme              *tui.ColorTheme
 	tui                tui.Renderer
+	ttyDefault         string
 	ttyin              *os.File
 	executing          *util.AtomicBool
 	termSize           tui.TermSize
@@ -809,7 +810,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
 	// when you run fzf multiple times in your Go program. Closing it is known to
 	// cause problems with 'become' action and invalid terminal state after exit.
 	if ttyin == nil {
-		if ttyin, err = tui.TtyIn(); err != nil {
+		if ttyin, err = tui.TtyIn(opts.TtyDefault); err != nil {
 			return nil, err
 		}
 	}
@@ -817,7 +818,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
 		if tui.HasFullscreenRenderer() {
 			renderer = tui.NewFullscreenRenderer(opts.Theme, opts.Black, opts.Mouse)
 		} else {
-			renderer, err = tui.NewLightRenderer(ttyin, opts.Theme, opts.Black, opts.Mouse, opts.Tabstop, opts.ClearOnExit,
+			renderer, err = tui.NewLightRenderer(opts.TtyDefault, ttyin, opts.Theme, opts.Black, opts.Mouse, opts.Tabstop, opts.ClearOnExit,
 				true, func(h int) int { return h })
 		}
 	} else {
@@ -833,7 +834,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
 			effectiveMinHeight += borderLines(opts.BorderShape)
 			return util.Min(termHeight, util.Max(evaluateHeight(opts, termHeight), effectiveMinHeight))
 		}
-		renderer, err = tui.NewLightRenderer(ttyin, opts.Theme, opts.Black, opts.Mouse, opts.Tabstop, opts.ClearOnExit, false, maxHeightFunc)
+		renderer, err = tui.NewLightRenderer(opts.TtyDefault, ttyin, opts.Theme, opts.Black, opts.Mouse, opts.Tabstop, opts.ClearOnExit, false, maxHeightFunc)
 	}
 	if err != nil {
 		return nil, err
@@ -967,6 +968,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
 		keyChan:            make(chan tui.Event),
 		eventChan:          make(chan tui.Event, 6), // start | (load + result + zero|one) | (focus) | (resize)
 		tui:                renderer,
+		ttyDefault:         opts.TtyDefault,
 		ttyin:              ttyin,
 		initFunc:           func() error { return renderer.Init() },
 		executing:          util.NewAtomicBool(false),
@@ -4042,7 +4044,7 @@ func (t *Terminal) executeCommand(template string, forcePlus bool, background bo
 	t.executing.Set(true)
 	if !background {
 		// Open a separate handle for tty input
-		if in, _ := tui.TtyIn(); in != nil {
+		if in, _ := tui.TtyIn(t.ttyDefault); in != nil {
 			cmd.Stdin = in
 			if in != os.Stdin {
 				defer in.Close()
@@ -4051,7 +4053,7 @@ func (t *Terminal) executeCommand(template string, forcePlus bool, background bo
 
 		cmd.Stdout = os.Stdout
 		if !util.IsTty(os.Stdout) {
-			if out, _ := tui.TtyOut(); out != nil {
+			if out, _ := tui.TtyOut(t.ttyDefault); out != nil {
 				cmd.Stdout = out
 				defer out.Close()
 			}
@@ -4059,7 +4061,7 @@ func (t *Terminal) executeCommand(template string, forcePlus bool, background bo
 
 		cmd.Stderr = os.Stderr
 		if !util.IsTty(os.Stderr) {
-			if out, _ := tui.TtyOut(); out != nil {
+			if out, _ := tui.TtyOut(t.ttyDefault); out != nil {
 				cmd.Stderr = out
 				defer out.Close()
 			}
diff --git a/src/tui/light.go b/src/tui/light.go
index 4f5ae555b4e..eb3de09853d 100644
--- a/src/tui/light.go
+++ b/src/tui/light.go
@@ -28,7 +28,7 @@ const (
 	maxInputBuffer  = 1024 * 1024
 )
 
-const consoleDevice string = "/dev/tty"
+const DefaultTtyDevice string = "/dev/tty"
 
 var offsetRegexp = regexp.MustCompile("(.*?)\x00?\x1b\\[([0-9]+);([0-9]+)R")
 var offsetRegexpBegin = regexp.MustCompile("^\x1b\\[[0-9]+;[0-9]+R")
@@ -146,8 +146,8 @@ type LightWindow struct {
 	wrapSignWidth int
 }
 
-func NewLightRenderer(ttyin *os.File, theme *ColorTheme, forceBlack bool, mouse bool, tabstop int, clearOnExit bool, fullscreen bool, maxHeightFunc func(int) int) (Renderer, error) {
-	out, err := openTtyOut()
+func NewLightRenderer(ttyDefault string, ttyin *os.File, theme *ColorTheme, forceBlack bool, mouse bool, tabstop int, clearOnExit bool, fullscreen bool, maxHeightFunc func(int) int) (Renderer, error) {
+	out, err := openTtyOut(ttyDefault)
 	if err != nil {
 		out = os.Stderr
 	}
@@ -271,7 +271,7 @@ func (r *LightRenderer) getBytesInternal(buffer []byte, nonblock bool) ([]byte,
 	c, ok := r.getch(nonblock)
 	if !nonblock && !ok {
 		r.Close()
-		return nil, errors.New("failed to read " + consoleDevice)
+		return nil, errors.New("failed to read " + DefaultTtyDevice)
 	}
 
 	retries := 0
diff --git a/src/tui/light_unix.go b/src/tui/light_unix.go
index 76aac2eb096..02fbf43637b 100644
--- a/src/tui/light_unix.go
+++ b/src/tui/light_unix.go
@@ -42,26 +42,35 @@ func (r *LightRenderer) closePlatform() {
 	r.ttyout.Close()
 }
 
-func openTty(mode int) (*os.File, error) {
-	in, err := os.OpenFile(consoleDevice, mode, 0)
-	if err != nil {
+func openTty(ttyDefault string, mode int) (*os.File, error) {
+	var in *os.File
+	var err error
+	if len(ttyDefault) > 0 {
+		in, err = os.OpenFile(ttyDefault, mode, 0)
+	}
+	if in == nil || err != nil || ttyDefault != DefaultTtyDevice && !util.IsTty(in) {
 		tty := ttyname()
 		if len(tty) > 0 {
 			if in, err := os.OpenFile(tty, mode, 0); err == nil {
 				return in, nil
 			}
 		}
-		return nil, errors.New("failed to open " + consoleDevice)
+		if ttyDefault != DefaultTtyDevice {
+			if in, err = os.OpenFile(DefaultTtyDevice, mode, 0); err == nil {
+				return in, nil
+			}
+		}
+		return nil, errors.New("failed to open " + DefaultTtyDevice)
 	}
 	return in, nil
 }
 
-func openTtyIn() (*os.File, error) {
-	return openTty(syscall.O_RDONLY)
+func openTtyIn(ttyDefault string) (*os.File, error) {
+	return openTty(ttyDefault, syscall.O_RDONLY)
 }
 
-func openTtyOut() (*os.File, error) {
-	return openTty(syscall.O_WRONLY)
+func openTtyOut(ttyDefault string) (*os.File, error) {
+	return openTty(ttyDefault, syscall.O_WRONLY)
 }
 
 func (r *LightRenderer) setupTerminal() {
diff --git a/src/tui/light_windows.go b/src/tui/light_windows.go
index f29e018c906..fd5cc1427f0 100644
--- a/src/tui/light_windows.go
+++ b/src/tui/light_windows.go
@@ -76,12 +76,12 @@ func (r *LightRenderer) closePlatform() {
 	windows.SetConsoleMode(windows.Handle(r.inHandle), r.origStateInput)
 }
 
-func openTtyIn() (*os.File, error) {
+func openTtyIn(ttyDefault string) (*os.File, error) {
 	// not used
 	return nil, nil
 }
 
-func openTtyOut() (*os.File, error) {
+func openTtyOut(ttyDefault string) (*os.File, error) {
 	return os.Stderr, nil
 }
 
diff --git a/src/tui/ttyname_unix.go b/src/tui/ttyname_unix.go
index d0350a0bc4c..9655aa98c3d 100644
--- a/src/tui/ttyname_unix.go
+++ b/src/tui/ttyname_unix.go
@@ -44,11 +44,11 @@ func ttyname() string {
 }
 
 // TtyIn returns terminal device to read user input
-func TtyIn() (*os.File, error) {
-	return openTtyIn()
+func TtyIn(ttyDefault string) (*os.File, error) {
+	return openTtyIn(ttyDefault)
 }
 
 // TtyIn returns terminal device to write to
-func TtyOut() (*os.File, error) {
-	return openTtyOut()
+func TtyOut(ttyDefault string) (*os.File, error) {
+	return openTtyOut(ttyDefault)
 }
diff --git a/src/tui/ttyname_windows.go b/src/tui/ttyname_windows.go
index dfe89eb32d2..dbe97739ddd 100644
--- a/src/tui/ttyname_windows.go
+++ b/src/tui/ttyname_windows.go
@@ -11,11 +11,11 @@ func ttyname() string {
 }
 
 // TtyIn on Windows returns os.Stdin
-func TtyIn() (*os.File, error) {
+func TtyIn(ttyDefault string) (*os.File, error) {
 	return os.Stdin, nil
 }
 
 // TtyOut on Windows returns nil
-func TtyOut() (*os.File, error) {
+func TtyOut(ttyDefault string) (*os.File, error) {
 	return nil, nil
 }


Test patch: