7

I have written a fancy custom bash prompt, and it works really well; I just have an issue when trying to run multiple commands separated by newlines. (I apologise for the length, but I hope my issue is clear)

TL;DR: getting the cursor position reads from stdin with throws out any data there. The data should not be thrown away, please advise.

Expected behaviour

When I am running a long running command that doesn't read from stdin (e.g. sleep 5) and type the next command+enter (eg. ls -lah<enter>), once it is completed bash starts reading from stdin, reads the command+enter and starts running it (ls -lah in this case). I will call this the "eager situation" instead of the "patient situation" of waiting for the next prompt to show up before typing. (examples below)

Default bash prompt example

With the default bash prompt (aka PS1) of something like PS1='[\u@\h \W]\$ ' it works as expected.

Default patient situation:

# wait for new prompt to show up and type new command
[me@machine ~]$ sleep 5; echo 'sleep done!'<enter>
sleep done!
[me@machine ~]$ ls -A /etc/skel<enter>
.bash_logout  .bash_profile  .bashrc
[me@machine ~]$ 

Default eager situation:

# we immediately start typing our next command to be run after we typed the sleep+echo
[me@machine ~]$ sleep 5; echo 'sleep done!'<enter>
ls -A /etc/skel<enter>
sleep done!
# bash now auto-fills the prompt because it reads it from the tty/stdin
# and immediately runs it (because it ends with a newline):
[me@machine ~]$ ls -A /etc/skel
.bash_logout  .bash_profile  .bashrc
[me@machine ~]$ 

Custom prompt bug

Custom patient situation

The patient situation works fine. No issues here.

Custom eager situation

The problem lies in the start of the precmd() function. (I'm using this bash-preexec hook)

There I call a function that asks the terminal for the current cursor coordinates in order to know if I should print an extra newline, in case the output of the last-run command did not end in one. I got that function from another SO post: https://stackoverflow.com/a/52944692

If I disable this function, the issue does not occur.

function precmd() {
        # must be 1st
        previous_command_exit="${?}"

        # saves cursore coordinates to 2 variables: _cursor_col, _cursor_row
        # If I comment-out the call to _fetch_cursor_position, I can use the `eager situation` as expected.
        _fetch_cursor_position

        # add extra newline if command did not end with a newline
        [[ "${_cursor_col}" -gt 1 ]] && printf "\n"

        # ...
}

In my case it is defined like this:

_fetch_cursor_position() {
  local -a pos
  IFS='[;' read -p $'\e[6n' -d R -a pos -rs || echo >&2 "failed with error: $? ; ${pos[*]}"
  _cursor_row="${pos[1]}"
  _cursor_col="${pos[2]}"
}

The way this function works is like this (see the SO post for a better explaination):

  1. print the escape sequence ^[[6n to the terminal, this asks the terminal to report the cursor position. This is printed by read by (ab)using the -p prompt flag (see help read).
  2. The terminal reports the cursor position by "typing" ^[[y;xR in the terminal where:
    ^[[: The Esc character ^[ followed by a literal [ y: the row number from the top starting at 1 (can be >9)
    x: the column number from the left, starting at 1 (can be >9)
    R: a literal character R
  3. This value is then parsed by the read command by reading from stdin and assigned to the pos array:
    pos[0]: ^[ (we don't care about this one)
    pos2: value of y
    pos2: value of x

Possible solutions

The main thing is preventing the _fetch_cursor_position function from reading the already-input data that's in the tty/stdin. Since that's probably not possible, perhaps there is a way to save the current value of stdin, read the cursor position and then restore the stdin value. I've looked a bit into bash's coproc because that looked promising; but I'm not sure how that would work.

Something with bash redirections

I have a strong feeling that the solution lies in doing something "advanced" with bash's I/O redirections. But I only really ever use >/dev/null, &>/dev/null, 2>&1, |& and file/subshell redirections like < <(echo abc) and >myfile.txt, never with extra FD's directly.

The process in my mind is something like this:

  1. backup fd 0 to a new file descriptor (stdout/1 too?)
  2. open new stdin/stout to the terminal (/dev/tty / $(tty)?) so that they have empty ones to the exact same terminal (all the above without moving the cursor of course)
  3. ask the terminal for the position and parse the response
  4. restore the original

Output position to different FD

have the terminal somehow write the coordinate response not to stdin/0 but to a different FD, and have read read from there.

Subshell that doesn't have access to the stdin data that's already there.

When I use a slightly edited version of _fetch_cursor_position using subshell expansion, this also doesn't work because it inherits stdin/stout/stderr:

_echo_cursor_position() {
  local pos

  IFS='[;' read -p $'\e[6n' -d R -a pos -rs || echo >&2 "failed with error: $? ; ${pos[*]}"
  _cursor_row="${pos[1]}"
  _cursor_col="${pos[2]}"
  
  # this line is added, no other changes apart from the name
  printf "${_cursor_row} ${_cursor_col}"
}

function precmd() {
        # must be 1st
        previous_command_exit="${?}"

        #_fetch_cursor_position
        pos=( $(_echo_cursor_position) )
        _cursor_row="${pos[0]}"
        _cursor_col="${pos[1]}"

        # add extra newline if command did not end with a newline
        [[ "${_cursor_col}" -gt 1 ]] && printf "\n"

        # ...
}

Perhaps this would be an easier and/or more elegant solution, if this inheritance issue can be resolved.

What doesn't work

1: read without any redirections

cursor position is saved correctly, but eagerly typed command is gone.

IFS='[;' read -r -s -p $'\e[6n' -d 'R' __garbage __cursor_col __cursor_row

2: read with two redirects on /dev/tty

If I understand bash correctly, stdin/stdout(and stderr) are by default connected to the tty so this should not be different from 1, which it isn't.

IFS='[;' read -r -s -p $'\e[6n' -d 'R' __garbage __cursor_col __cursor_row </dev/tty >/dev/tty

from man 1 bash:

An interactive shell is one started without non-option arguments (unless -s is specified) and without the -c option whose standard input and error are both connected to terminals (as determined by isatty(3)), or one started with the -i option. PS1 is set and $- includes if bash is interactive, allowing a shell script or a startup file to test this state.

3: 2-stage redirect printf and read

Same result as 1 and 2

printf $'\e[6n' >/dev/tty
IFS='[;' read -r -s -d 'R' __garbage __cursor_col __cursor_row </dev/tty

4: using value of $(tty) instead of /dev/tty

Again, no differcence

local tty="$(tty)"
printf $'\e[6n' >"${tty}"
IFS='[;' read -r -s -d 'R' __garbage __cursor_col __cursor_row <"${tty}"

What works

0: The easy solution

Don't do all this stupid effort and just always print an extra newline.

I'd really like to not have to do this, from a perfectionist perspective.

1: use tmux

See answer by Kamil Maciorowski

I'm always using tmux, so this is a great pragmatic solution for me that only prints an extra newline when necessary.

If there is a bash-native/terminal-native solution that doesn't require tmux, I'd love to hear it.

4
  • Did you try writing to /dev/tty then reading from there for the get-the-cursor-position handling?
    – thrig
    Aug 19, 2022 at 21:07
  • not yet, I'll try that. something like read [stuff] >/dev/tty </dev/tty right?
    – techhazard
    Aug 20, 2022 at 6:15
  • I tried it directly to the read command inside the function, as well as on the place where I call the function: _fetch_cursor_position </dev/tty >/dev/tty. (not at the same time) this did not work.
    – techhazard
    Aug 20, 2022 at 6:56
  • I also tried splitting it into two lines: printf $'\e[6n' >/dev/tty and then read -d R -a pos -rs </dev/tty (without the -p $'\e[6n'). This also does not work
    – techhazard
    Aug 20, 2022 at 7:09

2 Answers 2

5

This is the problem:

The terminal reports the cursor position by "typing"…

To any program that reads from the terminal there is no difference between what you type and what the terminal "types".

Use a terminal that provides a separate channel for these kind of things. I use tmux (for other reasons). In tmux the following works:

_fetch_cursor_position() {
  local pos
  pos="$(tmux display-message -p -F '#{cursor_x} #{cursor_y}')"
  _cursor_row="${pos#* }"
  _cursor_col="${pos% *}"
  ((_cursor_row++))
  ((_cursor_col++))
}

The point is this function does not read from the stdin at all.

tmux counts rows/columns starting from 0. These ++ in my code are only to make the numbers you get compatible with the rest of your current code (which apparently expects counts starting from 1). If I was writing from scratch, I wouldn't use ++; I would write tests accordingly (e.g. -gt 0 instead of -gt 1 in precmd()).

1
  • This works really well, thanks! I'm still looking for a solution that doesn't require tmux (perhaps with saving stdin in a buffer somewhere?), but this is great :) Yes, the terminal row/cursor count is 1-based for some reason :/
    – techhazard
    Aug 22, 2022 at 7:26
0

I stripped the array from your code for simplicity and added cursor positioning for a reference.

_fetch_cursor_position() {
  echo -en "\e[25;10H"
  read -s -t 1 -d ' ' -p $'\e[6n ' >&2
  if [[ "$REPLY" ]]; then
    result=$(echo $REPLY | sed 's/^[/\\e/g')
    cat <<< "Cursor position in the terminal:
        $result"
  fi
}
_fetch_cursor_position

The output is

Cursor position in the terminal:  
\e[25;10R  

It correlates with the echo statement that positions the cursor.

Note that ^[ in the sed command is a hard (actual) Esc character; it is not a soft Esc (i.e., just a textual representation).  To enter an actual Esc,

  • in Vi/Vim, type Ctrl+V, Esc
  • in Emacs,  type Ctrl+Q, Esc.
4
  • if I remove the ? from -p $'\e[?6n' it works again and I get output of the cursor location. What is the function of the ? ? Reading man terminfo gets me ^? which is the escape sequence for DEL or %? as start of an if-then-else sequence. Neither of which seem to fit.
    – techhazard
    Aug 20, 2022 at 8:11
  • 1
    Ah, the echo -en "\e[25;10H" moves the cursor to row 25 column 10. tput cup 25 10 would have been more clear (to me at least).
    – techhazard
    Aug 20, 2022 at 8:51
  • Sorry @techhazard. I editted my answer to remove "?" from the ESC sequence. I should have referenced console_codes(4), but used ctlseqs.txt instead where I selected the wrong sequence for your purpose. Nonetheless, after correction, it works in console and terminal. Also, see \e[u and \e[s as possible help.
    – ptrck4193
    Aug 22, 2022 at 16:55
  • Thanks for clearing up the confusion. Your solution does not solve my problem hovever, and is more or less the same as the one I already had.
    – techhazard
    Aug 23, 2022 at 10:21

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .