Mert Özen Further With Every Line
Developer Tools Terminal and Command Line — #02

Being Fast from the Keyboard: Line Editing Shortcuts, History Search, Tab Completion and CLI Completions

Mert Özen Nov 14, 2026 12 min 2 views
Being Fast from the Keyboard: Line Editing Shortcuts, History Search, Tab Completion and CLI Completions

Working with Ctrl-A/E/W/K/Y, Alt-. and Ctrl-R instead of the arrow keys brings most of your speed in the terminal; bash, zsh and PSReadLine share the same shortcut family. We show with a real experiment in three shells how history stores secrets, and cover the right history settings, .NET 10's dotnet completions script, ng completion and the fixes for two completion traps we hit in zsh.

Version note: The commands in this post were run on Ubuntu 24.04 with bash 5.2, zsh 5.9 and PowerShell 7.6.10 (PSReadLine 2.4.5), in a clean environment with .NET SDK 10.0.404, Node.js 24.22.3 and Angular CLI 22.2.7 installed. Key behaviour specific to Windows and macOS comes from the official documentation.

Guess First

You open three terminals: bash with Ubuntu's stock .bashrc, zsh with macOS's default settings, and PowerShell 7.6 with no configuration at all. In all three you type the same three lines and exit the shell. The first line starts with a space:

 export BOOKSTORE_API_TOKEN=abc123
export BOOKSTORE_API_TOKEN=abc123
curl -s -H "Authorization: Bearer abc123" localhost:5080/api/books

(In PowerShell the first two lines are $env:BOOKSTORE_API_TOKEN = "abc123".) In which shell's history file does the token show up? Which line do all three keep? The answer is in the middle of the post; each of the three shells does something unexpected.

Line Editing: Readline, ZLE and PSReadLine

Moving back character by character with the arrow keys while typing a command is the number one reason people are slow in the terminal. The layer that edits the line depends on the shell: bash uses the GNU Readline library, zsh its own ZLE, the Zsh Line Editor, and PowerShell the PSReadLine module. All three support the same family of shortcuts inherited from Emacs. The table below was compiled from the three shells' default bindings (the output of bind -p, bindkey and Get-PSReadLineKeyHandler):

KeyActionbash (Readline)zsh (ZLE)PSReadLine Emacs mode
Ctrl-A / Ctrl-EStart / end of linebeginning-of-line / end-of-linesameBeginningOfLine / EndOfLine
Alt-B / Alt-FOne word back / forwardbackward-word / forward-wordsameBackwardWord / ForwardWord
Ctrl-WDelete previous wordunix-word-ruboutbackward-kill-wordUnixWordRubout
Ctrl-UDelete left of cursorunix-line-discardkill-whole-line (whole line)BackwardKillInput
Ctrl-KDelete right of cursorkill-linekill-lineKillLine
Ctrl-YPaste back what was deletedyankyankYank
Alt-.Last argument of previous commandyank-last-arginsert-last-wordYankLastArg
Ctrl-LClear the screenclear-screenclear-screenClearScreen

Text deleted with Ctrl-W, Ctrl-U and Ctrl-K isn't lost; it goes to a kill ring and comes back with Ctrl-Y. If you need to run another command in the middle of a long dotnet test --filter ... line, cut the line with Ctrl-A, Ctrl-K, run your command, then bring it back with Ctrl-Y. In zsh, Ctrl-U deleting the whole line is a small difference from bash.

An important detail for Windows users: PSReadLine's default edit mode on Windows is not Emacs but Windows. In this mode Ctrl-A selects the whole line, Home/End go to the start and end, Ctrl+Backspace and Ctrl-W delete a word, Ctrl+Left/Right arrow jump words and Escape clears the line. Some of the bindings I saw after switching the same module to Windows mode and listing its keys:

PS> Set-PSReadLineOption -EditMode Windows
PS> Get-PSReadLineKeyHandler -Bound | Where-Object Key -in 'Ctrl+a','Home','Ctrl+w','Escape','Ctrl+r','F8','Ctrl+z' | Format-Table Key,Function
Key    Function
---    --------
Ctrl+w BackwardKillWord
Escape RevertLine
Ctrl+z Undo
Home   BeginningOfLine
F8     HistorySearchBackward
Ctrl+r ReverseSearchHistory
Ctrl+a SelectAll

If you use bash in WSL and PowerShell on Windows, instead of carrying two sets of muscle memory you can put Set-PSReadLineOption -EditMode Emacs in your profile. That is what I do; having Ctrl-A do the same thing everywhere is worth two minutes of getting used to.

On macOS, Alt shortcuts don't work by default because the Option key produces special characters. In Terminal.app you need "Use Option as Meta key" on the Keyboard tab of the profile settings; in iTerm2, choose "Esc+" for the left Option key under Keys in Profiles.

Last Argument, Last Command, Last Directory: !!, !$, Alt-. and cd -

History expansion in bash and zsh substitutes pieces of previous commands before running what you typed. The ones you'll use most:

$ mkdir -p src/Bookstore.Api/Ordering
$ cd !$
cd src/Bookstore.Api/Ordering
$ cd -
/home/ada/bookstore
$ dotnet test --filter "FullyQualifiedName~Isbn"
...
Test summary: total: 6, failed: 0, succeeded: 6, skipped: 0, duration: 0.3s
$ !! --no-build
dotnet test --filter "FullyQualifiedName~Isbn" --no-build
...

!$ is the last argument of the previous command, !! the whole previous command. The shell prints the expanded form before running it, so you see what runs. cd - goes back to the previous directory; it works the same in PowerShell 7. The interactive counterpart of !$ is Alt-.: each press brings the last argument of a previous command, repeated presses go to older commands, and you see and can edit the result before running it. In PSReadLine, Alt-. is bound to YankLastArg in both modes.

By the way, the output above holds a surprise: the FullyQualifiedName~Isbn filter ran 6 tests, not 4. The ~ operator means "contains" and ignores case; test names like BookCatalogTests.Find_returns_book_by_isbn matched too. If you are targeting a single class, give the full name:

$ dotnet test --no-build --filter "FullyQualifiedName~Bookstore.Api.Tests.IsbnTests" | tail -2
Passed!  - Failed:     0, Passed:     4, Skipped:     0, Total:     4, Duration: 6 ms - Bookstore.Api.Tests.dll (net10.0)

Searching History: Ctrl-R and Predictions

Instead of walking through history one entry at a time with the up arrow, press Ctrl-R and type part of the command you are looking for. The three shells' screens:

(reverse-i-search)`stat': git status -s
bck-i-search: stat_
PS /home/ada/bookstore> git status -s
bck-i-search: stat_

The first line is bash, the second zsh, the last two PowerShell (bash and zsh show the first match on the same line, PowerShell on the line above). Pressing Ctrl-R again goes to an older match, Enter runs it, Ctrl-G (Escape in PowerShell) leaves the search. Ctrl-S searches forward, but in most Linux terminals it first freezes the terminal, because flow control (ixon) is on in the PTY:

$ stty -a | grep -o -- ' -*ixon'
 ixon

If you pressed Ctrl-S and the screen froze, Ctrl-Q unfreezes it. With stty -ixon in .bashrc, Ctrl-S works as forward search.

PowerShell has an extra feature here: predictive IntelliSense, which suggests how to complete the line you are typing based on history and plugins. With PowerShell 7.6.10 and PSReadLine 2.4.5 I found it on by default in a terminal that supports VT:

PS> Get-PSReadLineOption | Select-Object PredictionSource, PredictionViewStyle
PredictionSource PredictionViewStyle
---------------- -------------------
HistoryAndPlugin          InlineView

InlineView shows the suggestion in a dim colour to the right of the cursor, and the right arrow accepts it. Switch to ListView with F2 and the suggestions appear as a list under the line. What ListView showed when I typed dot:

PS /home/ada/bookstore> dot
<-/1>                                                       <History(1)>
> dotnet --version                                             [History]

A similar suggestion comes to zsh with the zsh-autosuggestions plugin; we install it in the next post.

History Settings and Keeping Secrets Out

The answer to the opening guess: the contents of the history files after typing the same three lines in the three shells and exiting. bash ran with Ubuntu's stock .bashrc, zsh with the three history lines from macOS's /etc/zshrc, PowerShell with no configuration:

== bash
export BOOKSTORE_API_TOKEN=abc123
curl -s -H "Authorization: Bearer abc123" localhost:5080/api/books
exit
== zsh
 export BOOKSTORE_API_TOKEN=abc123
export BOOKSTORE_API_TOKEN=abc123
curl -s -H "Authorization: Bearer abc123" localhost:5080/api/books
exit
== pwsh
curl -s -H "Authorization: Bearer abc123" localhost:5080/api/books
Get-Date -Format yyyy
exit

bash skipped the line starting with a space thanks to HISTCONTROL=ignoreboth in Ubuntu's .bashrc. zsh skipped nothing: macOS's /etc/zshrc sets HISTFILE, HISTSIZE=2000 and SAVEHIST=1000, but doesn't enable HIST_IGNORE_SPACE. PowerShell ignored the space and wrote neither $env:...TOKEN line: PSReadLine doesn't save lines that contain password, asplaintext, token, apikey or secret. The one line all three saved was the curl command with a real token in it, because no default rule recognises Bearer.

The settings I use, for all three shells. bash (~/.bashrc):

# Large history, no duplicates, skip lines starting with a space
HISTSIZE=50000
HISTFILESIZE=100000
HISTCONTROL=ignoreboth:erasedups
HISTIGNORE='*Bearer *'
# Don't overwrite on exit, append after every command
shopt -s histappend
PROMPT_COMMAND="history -a${PROMPT_COMMAND:+; $PROMPT_COMMAND}"
stty -ixon 2>/dev/null

zsh (~/.zshrc):

HISTFILE=~/.zsh_history
HISTSIZE=50000
SAVEHIST=50000
setopt HIST_IGNORE_SPACE HIST_IGNORE_ALL_DUPS HIST_REDUCE_BLANKS
setopt SHARE_HISTORY EXTENDED_HISTORY
# if this returns false, the line is not written to history
zshaddhistory() {
    [[ $1 != *(Authorization:|Bearer )* ]]
}

PowerShell ($PROFILE):

Set-PSReadLineOption -HistoryNoDuplicates -MaximumHistoryCount 20000
Set-PSReadLineOption -AddToHistoryHandler {
    param([string]$line)
    if ($line -match 'Authorization:|Bearer\s') {
        return $false
    }
    # Keep the default filter (token, password...)
    return [Microsoft.PowerShell.PSConsoleReadLine]::GetDefaultAddToHistoryOption($line)
}

The last line in PowerShell matters: when you set AddToHistoryHandler, you replace PSReadLine's default sensitive-data filter. If you apply your own rule and don't hand the rest to GetDefaultAddToHistoryOption, $env:...TOKEN lines start landing in history again. I repeated the same experiment with these settings; all three files kept only git status -s, dotnet --version and exit. zsh's EXTENDED_HISTORY adds a timestamp to each line (: 1794627661:0;git status -s), and SHARE_HISTORY makes all open zsh windows share history immediately. The closest thing in bash is history -a in PROMPT_COMMAND; when you open a new window you see the other windows' commands.

Tab Completion: the Shell's Own Completion

Tab completion has two layers. The first is the shell itself: command names, file paths, variables. The second is program-specific rules: completion functions that know git's branch names or dotnet's subcommands. This second layer doesn't come by itself.

In bash this is the job of the bash-completion package. It is installed on Ubuntu desktop, but usually missing from minimal installs and containers: sudo apt install bash-completion. In zsh the completion system is built in but has to be switched on:

autoload -Uz compinit && compinit
# menu you can move through with arrows after pressing Tab twice
zstyle ':completion:*' menu select
# lowercase input also matches uppercase
zstyle ':completion:*' matcher-list 'm:{a-z}={A-Za-z}'

In PowerShell, tab completion works without any setup and completes cmdlet names, parameters and file paths. In Windows mode Tab cycles through the options one by one; Ctrl+Space (shown as Ctrl+@ in a Linux terminal) opens all options as a menu. I always bind Tab to the menu, and the up/down arrows to searching history by the prefix I typed:

Set-PSReadLineKeyHandler -Key Tab -Function MenuComplete
Set-PSReadLineKeyHandler -Key UpArrow -Function HistorySearchBackward
Set-PSReadLineKeyHandler -Key DownArrow -Function HistorySearchForward

After the last two lines, typing dotnet and pressing the up arrow brings only older commands that start with dotnet. That is even faster than Ctrl-R.

Completions for dotnet, ng and git

With the .NET 10 SDK, the dotnet CLI can generate its own completion script: dotnet completions script prints a script for bash, zsh, fish, nushell and PowerShell. If you don't give the shell name, it looks at the SHELL variable; if the variable is missing, it stops with an error message. The line Microsoft recommends for PowerShell:

dotnet completions script pwsh | Out-String | Invoke-Expression

I confirmed it works by triggering completion by hand:

PS> (TabExpansion2 "dotnet test --fi" 16).CompletionMatches.CompletionText
--filter
PS> (TabExpansion2 "dotnet pack" 11).CompletionMatches.CompletionText
pack
package

In bash, the line source <(dotnet completions script bash) completed dotnet te + Tab to dotnet test and --fi + Tab to --filter. In zsh, however, the zsh script generated by SDK 10.0.404 completes subcommands correctly but fails on the options of dotnet build and dotnet test:

% dotnet test --fi<Tab>
_arguments:comparguments:327: invalid argument: /nologo[Run test(s), without displaying Microsoft Testplatform banner]

The cause is that the script passes Windows-style option names like /nologo and /v to zsh's _arguments function in a form it doesn't accept. A working fix for this version is to turn on zsh's bash completion compatibility layer and use the bash script:

autoload -Uz bashcompinit && bashcompinit
if (( $+commands[dotnet] )); then
    source <(dotnet completions script bash 2>/dev/null)
fi

With this setting, dotnet test --fi + Tab completed to --filter in zsh again. When you move to a new SDK version, try the zsh script again; if it has been fixed, you no longer need bashcompinit.

The Angular CLI offers completion itself the first time you use it. Running an ng command in a fresh home:

Would you like to enable autocompletion? This will set up your terminal so pressing TAB while typing Angular CLI commands will show possible options and autocomplete arguments. (Enabling autocompletion will modify configuration files in your home directory.) Yes
Appended `source <(ng completion script)` to `/tmp/hn/.bashrc`. Restart your terminal or run the following to autocomplete `ng` commands:
    source <(ng completion script)

and it appended these three lines to the end of .bashrc:

if [[ $- == *i* ]]; then
  source <(ng completion script)
fi

According to the Angular documentation, ng completion supports only bash and zsh; on Windows it works with Git Bash or with bash/zsh inside WSL, and there is no PowerShell support. I found a trap here: ng completion script decides which shell to generate a script for by looking at the SHELL variable, not at the shell you are currently in. If your login shell is bash and you open zsh and run this line, zsh gets the bash script and fails with command not found: complete. The fix is to set the variable for that one command:

if (( $+commands[ng] )); then
    source <(SHELL=${commands[zsh]} ng completion script)
fi

With this setting ng gen + Tab completed to ng generate, and ng generate co + Tab showed the component and config options.

git completion comes by itself in zsh (the _git function ships with zsh); in bash the git package puts its own script into bash-completion. PowerShell has no completion for git; TabExpansion2 "git sw" came back empty. We solve that with the posh-git module in the PowerShell post.

In Production

History files are plain text and live in your home folder: ~/.bash_history for bash, ~/.zsh_history for zsh, and for PowerShell $env:APPDATA\Microsoft\Windows\PowerShell\PSReadLine\ConsoleHost_history.txt on Windows and ~/.local/share/powershell/PSReadLine/ on Linux and macOS. These files end up in laptop backups, dotfiles repos and screen recordings shared for support. If you accidentally wrote a secret into history, revoke the secret and issue a new one first; deleting it from history is the second step (history -d in bash, editing the file in PowerShell).

A secret you pass as a command-line argument doesn't only stay in history: while the process runs, other users on the same machine can see the arguments with ps, and CI logs can print the command line. For commands that need a token, read the value from a file or a secret store (curl -H @headers.txt, read -s, Get-Secret in PowerShell). On the .NET side, User Secrets for development secrets are covered in the ASP.NET Core Web API series post on configuration and the options pattern.

Completion scripts run in every new shell. In this environment dotnet completions script zsh took about 60 ms and ng completion script 130 ms; in the next post I measure startup time and show how to cache them.

Common Pitfalls

The first is thinking you've configured history but writing the setting into the wrong file. If you put HISTCONTROL in .bash_profile, it has no effect in non-login shells; the file table from the first post applies here too.

The second is losing history when several bash windows are open at once. Without histappend, the last window to close overwrites the file and the commands you typed in the other windows are gone.

The third is zsh silently switching the line editor to vi mode when $EDITOR or $VISUAL contains vim. I tried it in this environment: with EDITOR=vim zsh started with bindkey -A viins main, without the variable with bindkey -A emacs main. If Ctrl-A doesn't work, put bindkey -e in .zshrc.

The fourth is writing an AddToHistoryHandler in PowerShell without calling the default and turning off the sensitive-data filter; covered above. The fifth is blaming the shell when the VS Code integrated terminal or Windows Terminal grabs a shortcut for itself: Windows Terminal uses shortcuts starting with Ctrl+Shift, so if a key doesn't work, check the terminal's settings first.

A Small Experiment

★☆☆ In the Bookstore repo, type dotnet test --filter "FullyQualifiedName~Isbn" without touching the arrow keys, then go to the start with Ctrl-A, move onto --filter with Alt-F, cut with Ctrl-K and paste back with Ctrl-Y. Try the same in PowerShell in both Emacs and Windows modes.

★★☆ Add the history settings from the post to your own shell and repeat the three-line experiment from the start. Check which lines remain in the history file. In PowerShell, write the AddToHistoryHandler without the GetDefaultAddToHistoryOption call and see the difference.

★★★ Set up the dotnet, ng and git completions. Then, using only Tab and history, type these three commands: dotnet test --filter "FullyQualifiedName~Bookstore.Api.Tests.IsbnTests", ng generate component catalog/book-list inside web/bookstore-web, and switching to an existing branch with git switch. Count how many keys you pressed, and count again a week later.

You have started using what the shell already offers. In the next post we configure zsh for good: the layout of .zshrc, aliases and functions, plugins, a prompt showing the git branch and the .NET version, and a shell that still opens fast despite all of it.