4

I have a script that takes an -i flag (→ $interactive) and if set, asks a yes/no-default-no question for each target, à la rm -i and others.

Zsh’s read -q is designed for this exact case—it accepts a single key and sets the variable to y if the key was y or Y, otherwise setting it to n.

My issue is that I’m doing the prompt in a loop. So, by itself, it would print my prompt repeatedly on the same line. That is, this code:

# moshPids is an array of pids
for p in $moshPids; do
  if [[ -n $interactive ]]; then
    read -q "answer?Kill $p? (N/y)"
    [[ "${answer}" == 'n' ]] && continue
  fi
  # do_kill set to print “killing $1” in debug
  do_kill $p
done

results in:

$ myCmd
Kill 123? (N/y)nKill 456? (N/y)nKill 789? (N/y)yKilling 789
$

There are many solutions to this—some in answers on this very site—such as including a newline in the prompt, as so:

for p in $moshPids; do
  if [[ -n $interactive ]]; then
    read -q "answer?Kill $p? (N/y)
"
    [[ "${answer}" == 'n' ]] && continue
  fi

  do_kill $p
done

which results in:

$ myCmd
Kill 123? (N/y)
nKill 456? (N/y)
nKill 789? (N/y)
yKilling 789
$

This seems ugly to me. Another solution is adding echo >&2 after the read command, which—at least at first—seems perfect:

$ myCmd
Kill 123? (N/y)n
Kill 456? (N/y)y
Killing 456
Kill 789? (N/y)n
$

However, if you accept the default with , you get blank lines ( not shown in actual output, but added to show input):

$ myCmd
Kill 123? (N/y)⏎

Kill 456? (N/y)y
Killing 456
Kill 789? (N/y)⏎

$

Criteria

So: I want a single-key response that:

  • Doesn’t result in multiple prompts/answers printed on a single line
  • Doesn’t result in blank lines when is pressed to accept defaults
  • Still preserves the behavior of read -q: sets answer to y if the key was y or Y; otherwise sets it to n.

Solution 1

Here’s my first solution:

for p in $moshPids; do
  if [[ -n $interactive ]]; then
    read -k1 "answer?Kill $p? (N/y)"
    [[ $answer != $'\n' ]] && echo >&2
    [[ "${answer}" =~ '[yY]' ]] || continue
  fi

  do_kill $p
done

Here, -k1 instead of -q is passed to read so that it instead gets the behavior of “read one character, any character”. That way, I can test for the character matching newline. If it doesn’t ($answer != $'\n'), I can just print the missing newline.

But now $answer will be set to any pressed key, not just to y or n. So we must check for y or Y ("${answer}" =~ '[yY]').

Is there a better way to handle this?

Solution 2

I've also thought about this, using stty calls to temporarily disable keyboard echoing:

for p in $moshPids; do
  if [[ -n $interactive ]]; then
    stty -echo
    read -q "answer?Kill $p? (N/y)"
    stty echo
    echo >&2
    [[ "${answer}" == 'n' ]] && continue
  fi

  do_kill $p
done

which, by disabling the visibility of the keyed response, may or may not be preferable depending on whether you're prioritizing pretty output or being able to see the input in scrollback. (In this case, of a yes/no question where the yes option always prints something, it seems fine to noecho. One could always add an echo -n "${answer}" after the read if desired, with some sort of expansion incantation to turn newline into a blank.)

It's a line longer than the first solution, but is perhaps easier to understand since it uses read -q so doesn't have to test against a regex, and unconditionally echos the newline.

But on the other hand, it does monkey with the terminal; I’ve put the two commands close enough to each other than there shouldn’t be too large a time window for command interruption to result in terminal insanity, but it is still a concern. (Also, for completeness, I should add a check that -i isn’t being called non-interactively—otherwise, the stty and read operations will fail.)

Are there any even better or more idiomatic solutions than the two I've presented? This seems like an obvious behavior that many "-i for interactive" commands (most written in something other than the shell, of course) follow.

2 Answers 2

2

Update:

From the manual:

read ...

-s Don't echo back characters if reading from the terminal.

And it works:

for p in $pids; do
  read -qs "?Kill $p? (N/y)"
  >&2 echo $REPLY
  case $REPLY in
    (y) do_kill $p ;;
  esac
done
  • read -q also provides a return status:
for p in $pids; do
  if read -qs "?Kill $p? (N/y)"; then
    >&2 echo $REPLY; do_kill $p  # answer was y or Y
  else
    >&2 echo $REPLY          # answer was not y or Y
  fi
done

in case you prefer that.


As far as handling an option -i which sets $interactive, take the test for $interactive outside of the for loop, eg:

interactive_kill() {
  for p in $@; do
    read -qs "?Kill $p? (N/y)"
    >&2 echo $REPLY
    case $REPLY in
      (y) do_kill $p ;;
    esac
  done
}

if [[ -n $interactive ]]; then
  interactive_kill $pids
else
  do_kill $pids   # if do_kill() needs exactly one arg wrap in a for loop
fi

Also:

  • To test for y or Y doesn't require =~ as [yY] is a shell pattern, so with [[ you can just use the = operator, or use [yY] in a case statement
  • In your first example the logic seems a bit warped:
[[ "${answer}" == 'n' ]] && continue
do_kill $p

when

if [[ $answer = y ]]; then
  do_kill $p
fi

is easier to read.

1
  • I’m blind; no idea how I missed -s or the return-value bit of -q. Thanks!
    – Trey
    Feb 15, 2020 at 18:02
0

I've also wanted the behavior you're describing and it's not straightforward. I wrote a helper function:

ask_yes_no() {
 read -qs "?$1 (y/N) "
 out=$?
 print $REPLY >&2
 return $out
}

# example usage
for thing in one two three; do
  if ask_yes_no "Do thing $thing?"; then
    echo "Do thing $thing"
  else
    echo "Nope"
  fi
done

Example output:

Do thing one? (y/N) n
Nope
Do thing two? (y/N) y
Do thing two
Do thing three? (y/N) y
Do thing three
1

You must log in to answer this question.

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