0

I learned after much frustration that having $EDITOR initially set to vim doesn't affect the main shell but does affect scripts and nothing seems to undo this initial state. Unsetting the env var, setting it to the empty string, using bindkey -e doesn't propagate to scripts. When I switch modes with bindkey -v and back, in the main shell, all keys behave as expected in both modes.

In a script, vi editing works fine, the same as the main shell, but after executing bindkey -e, input behaves oddly. Home and End do nothing but can interfere with subsequent keystrokes and Del prints ~.

If I test what's being sent by the keys, prefacing each with Ctrl + V, I get [OH^[OF^[[3~ in the main shell but [[H^[[F^[[3~ in the script. So two are different and behave differently, and one is the same but behaves differently.

Surely I shouldn't have to define keybindings just for a script to work portably. Why are they breaking?

Zsh is v5.9 and the script is just this:

#!/usr/bin/zsh
bindkey -e
vared -p 'x: ' -c x

I want to offer a prompt to edit a string with a pre-filled suggestion, and people expect those keys to work a certain way in a text input. Other Emacs sequences work, but it would be better not to have to teach non-technical users those.

Walf
  • 1,321
  • Those keys are not mapped by default maybe your OS binds them for interactive use in /etc/zsh/zshrc – Stéphane Chazelas Feb 05 '24 at 10:03
  • Thanks, @StéphaneChazelas. Those links explain a lot but seem to be about solving binding issues in the default interactive mode. Are you saying it's normal for zsh scripts to not inherit key bindings, and that each script is required to define its own? – Walf Feb 06 '24 at 02:03
  • You get zsh's key bindings, if you want additional key bindings you need to define them. If you want key bindings to affect all interactive or non-interactive invocations of zsh by any user of the system, you can put them in /etc/zsh/zshenv. Or ~/.zshenv for only your invocations. – Stéphane Chazelas Feb 06 '24 at 15:15
  • @StéphaneChazelas I assumed I'd get zsh's key bindings but that's not the behaviour I'm seeing. In the zle of vared in that script, those three keys (and possibly others I'm unaware of) behave differently to how they do in the parent zsh, from which I run the script. I really don't want to make permanent changes to a system as the script is intended to be portable. – Walf Feb 07 '24 at 03:24

1 Answers1

1

Home, End, Insert, Delete, PageUp, PageDown for those keyboards that have them and for those terminals that send some unique characters or sequences of characters when they're pressed are not bound by default in any of zsh's keymaps (emacs, vi-insert, vi-command..., see bindkey -l or the $keymaps array for the full list).

There's nothing bound to function keys, or those above or arrow keys or Tab, Backspace, Escape when combined with Shift or Control or Alt either (again for those terminals that do send unique sequences for those).

Arrow keys (UpDownLeftRight) are bound by default as most terminals send the same escape sequences upon those. That's almost always either ^[[A...^[[D or ^[OA...^[OD depending on the terminal and/or whether it's in keypad transmit mode (see smkx in terminfo(5)) or not.

You can see zsh's default key bindings in the various keymaps, by running zsh with the -f option (which skips system or user initialisation files except the zshenv ones) and run:

for m ($keymaps) bindkey -M $m | grep -H --label=$m .

The manual will also show you what widgets are found to which keys in the emacs/vicmd/viins keymaps.

If you pipe that loop above to grep '\^\[[[O][A-D]', you see, regardless of the value of $TERM:

% (for m ($keymaps) bindkey -M $m | grep -H --label=$m .) | grep '\^\[[[O][A-D]'
visual:"^[OA" up-line
visual:"^[OB" down-line
visual:"^[[A" up-line
visual:"^[[B" down-line
viopp:"^[OA" up-line
viopp:"^[OB" down-line
viopp:"^[[A" up-line
viopp:"^[[B" down-line
vicmd:"^[OA" up-line-or-history
vicmd:"^[OB" down-line-or-history
vicmd:"^[OC" vi-forward-char
vicmd:"^[OD" vi-backward-char
vicmd:"^[[A" up-line-or-history
vicmd:"^[[B" down-line-or-history
vicmd:"^[[C" vi-forward-char
vicmd:"^[[D" vi-backward-char
main:"^[OA" up-line-or-history
main:"^[OB" down-line-or-history
main:"^[OC" forward-char
main:"^[OD" backward-char
main:"^[[A" up-line-or-history
main:"^[[B" down-line-or-history
main:"^[[C" forward-char
main:"^[[D" backward-char
viins:"^[OA" up-line-or-history
viins:"^[OB" down-line-or-history
viins:"^[OC" vi-forward-char
viins:"^[OD" vi-backward-char
viins:"^[[A" up-line-or-history
viins:"^[[B" down-line-or-history
viins:"^[[C" vi-forward-char
viins:"^[[D" vi-backward-char
emacs:"^[OA" up-line-or-history
emacs:"^[OB" down-line-or-history
emacs:"^[OC" forward-char
emacs:"^[OD" backward-char
emacs:"^[[A" up-line-or-history
emacs:"^[[B" down-line-or-history
emacs:"^[[C" forward-char
emacs:"^[[D" backward-char

Both flavours of escape sequences for those 4 arrow keys are bound in most keymaps to the actions you usually expect them to have in the given contexts.

Those widgets are also bound to the usual emacs/vi keys as well (like ^B, ^F, ^P, ^N in emacs mode or h, j, k, l in vi-cmd mode).

You'll also find bindings of course for Esc, Tab, Backspace, Enter for which all terminals send very well known single control characters (though for Backspace, there are those that send BS and those that send DEL).

But you won't find anything about any other function key.

Here's a way for instance to get a summary of what is sent by the terminals known to the terminfo database upon pressing End:

$ (typeset -A count; for TERM (/usr/share/terminfo/*/*(.:t)) (( count[\$terminfo[kend]]++ )); typeset -p1 count)
typeset -A count=(
  [$'\M-\C-@O']=8
  ['']=1341
  [$'\M-\C-?\M-(']=6
  [$'\C-Ak\C-M']=1
  [$'\C-SI']=3
  [$'\C-[)4\C-M']=4
  [$'\C-[0']=8
  [$'\C-[F']=1
  [$'\C-[K']=4
  [$'\C-[OF']=99
  [$'\C-[T']=13
  [$'\C-[Y']=1
  [$'\C-[[146q']=23
  [$'\C-[[1~']=11
  [$'\C-[[220z']=21
  [$'\C-[[24;1H']=3
  [$'\C-[[4~']=139
  [$'\C-[[5~']=9
  [$'\C-[[8~']=28
  [$'\C-[[F']=49
  [$'\C-[[K']=1
  [$'\C-[[OF']=1
  [$'\C-[[U']=15
  [$'\C-[[Y']=16
  [$'\C-[[d']=1
  [$'\C-[_1\C-[\\']=1
  [$'\C-[k']=3
  [$'\C-[z']=13
  ['- @']=1
  ['-45~']=1
  ['-4~']=5
  [1!]=1
)

You'll see that for most terminals, what they send if any is not known. $'\C-[[4~' is the most common.

Delete nowadays commonly sends \e[3~, but sometimes DEL (^?) instead (the one most commonly sent upon backspace these days) and for many, that can be configured in the terminal emulator settings. Some terminal emulators can also be told what type keyboard to emulate and different sequences would be sent for those function keys. See for instance for xterm, quoting its manual:

-kt keyboardtype
This option sets the keyboardType resource. Possible values include: “unknown”, “default”, “legacy”, “hp”, “sco”, “sun”, “tcap” and “vt220”.

Now, a user will know what kind a keyboard, what terminal emulators and on what system they will use. An operating system vendor can also make a more educated guess than zsh (which has been used on thousands of different systems for over 30 years).

For instance, a distribution of Debian GNU/Linux for x86 PCs that tries to maintain a terminfo database relatively faithful to the few dozen terminal emulators it includes among its packages (most of them being xterm-like) can reasonably know what escape sequences are sent upon those few function keys commonly found on PC keyboards as long as users don't decide to change the configuration of their terminal emulators from the default and don't login remotely from alien operating systems.

So you'll find that Debian adds this to /etc/zsh/zshrc (the system customisation file for the interactive invocations of zsh):

# /etc/zsh/zshrc: system-wide .zshrc file for zsh(1).
#
# This file is sourced only for interactive shells. It
# should contain commands to set up aliases, functions,
# options, key bindings, etc.
#
# Global Order: zshenv, zprofile, zshrc, zlogin

READNULLCMD=${PAGER:-/usr/bin/pager}

An array to note missing features to ease diagnosis in case of problems.

typeset -ga debian_missing_features

if [[ -z "${DEBIAN_PREVENT_KEYBOARD_CHANGES-}" ]] && [[ "$TERM" != 'emacs' ]] then

typeset -A key
key=(
    BackSpace  "${terminfo[kbs]}"
    Home       "${terminfo[khome]}"
    End        "${terminfo[kend]}"
    Insert     "${terminfo[kich1]}"
    Delete     "${terminfo[kdch1]}"
    Up         "${terminfo[kcuu1]}"
    Down       "${terminfo[kcud1]}"
    Left       "${terminfo[kcub1]}"
    Right      "${terminfo[kcuf1]}"
    PageUp     "${terminfo[kpp]}"
    PageDown   "${terminfo[knp]}"
)

function bind2maps () {
    local i sequence widget
    local -a maps

    while [[ "$1" != "--" ]]; do
        maps+=( "$1" )
        shift
    done
    shift

    sequence="${key[$1]}"
    widget="$2"

    [[ -z "$sequence" ]] && return 1

    for i in "${maps[@]}"; do
        bindkey -M "$i" "$sequence" "$widget"
    done
}

bind2maps emacs             -- BackSpace   backward-delete-char
bind2maps       viins       -- BackSpace   vi-backward-delete-char
bind2maps             vicmd -- BackSpace   vi-backward-char
bind2maps emacs             -- Home        beginning-of-line
bind2maps       viins vicmd -- Home        vi-beginning-of-line
bind2maps emacs             -- End         end-of-line
bind2maps       viins vicmd -- End         vi-end-of-line
bind2maps emacs viins       -- Insert      overwrite-mode
bind2maps             vicmd -- Insert      vi-insert
bind2maps emacs             -- Delete      delete-char
bind2maps       viins vicmd -- Delete      vi-delete-char
bind2maps emacs viins vicmd -- Up          up-line-or-history
bind2maps emacs viins vicmd -- Down        down-line-or-history
bind2maps emacs             -- Left        backward-char
bind2maps       viins vicmd -- Left        vi-backward-char
bind2maps emacs             -- Right       forward-char
bind2maps       viins vicmd -- Right       vi-forward-char

# Make sure the terminal is in application mode, when zle is
# active. Only then are the values from $terminfo valid.
if (( ${+terminfo[smkx]} )) && (( ${+terminfo[rmkx]} )); then
    function zle-line-init () {
        emulate -L zsh
        printf '%s' ${terminfo[smkx]}
    }
    function zle-line-finish () {
        emulate -L zsh
        printf '%s' ${terminfo[rmkx]}
    }
    zle -N zle-line-init
    zle -N zle-line-finish
else
    for i in {s,r}mkx; do
        (( ${+terminfo[$i]} )) || debian_missing_features+=($i)
    done
    unset i
fi

unfunction bind2maps

fi # [[ -z "$DEBIAN_PREVENT_KEYBOARD_CHANGES" ]] && [[ "$TERM" != 'emacs' ]] [...]

As you can see above, it defines an associative array that maps key names to corresponding escape sequences. Those escape sequences are retrieved from the terminfo database which Debian happens to ship by default (though with a limited list of entries by default).

But because the terminfo database only gives the escape sequences that terminal send when in keypad transmit mode, you'll see that zshrc also tells ZLE to enter that mode when starting and leave it upon exiting.

That explains why you see different sequences when at the prompt (within ZLE) of an interactive zsh shell (where that zshrc is run) and when not.

Scripts and more generally non-interactive invocations of zsh don't read zshrc. Since zshrc is where you put all your customisations, aliases, functions... if scripts read them, that would be sure to break them¹.

Now vared also uses the ZLE, and when invoked in scripts where zshrc is not read, that means it won't get the bindings supplied by your operating system in /etc/zsh/zshrc.

So, if you like those bindings and you want them to be available to users of those scripts whether they are using the same operating system as you or not, you'll need to include them in your script by yourself.

Also beware that ZLE is in emacs or vi mode by default depending on whether $EDITOR or $VISUAL starts with vi or contains /vi or not, and you may also want to force them into one mode or another if you want consistent key bindings for users of your script. bindkey by default configures the bindings of the main keymap which is an alias to either emacs or viins depending on whether the emacs or vi mode is selected (with bindkey -e or bindkey -v).

Besides the Debian ones, you'll find many other suggestions of customisations around to bind the function keys including in combinations with Shift/Alt/Ctrl to mimic the behaviour of some other editors (on terminals that send unique sequences for those).

See for instance Zsh zle shift selection on Stack Overflow of oh-my-zsh's key bindings which you could get inspiration from.


¹ see how csh scripts usually have a #! /bin/csh -f shebang, where -f skips reading the ~/.cshrc to avoid them running into this kind of problem.