Skip to content

Instantly share code, notes, and snippets.

@jacksluong
Last active December 29, 2025 05:19
Show Gist options
  • Select an option

  • Save jacksluong/744ee3e30f6fc05a5563353e6db28aca to your computer and use it in GitHub Desktop.

Select an option

Save jacksluong/744ee3e30f6fc05a5563353e6db28aca to your computer and use it in GitHub Desktop.
An interactive way to navigate directories in a terminal

icd: Interactive Change Directory

recording

How do you navigate in your terminal? Do you chain sequences of cd commands together, or copy a file path and paste it? Sometimes to a destination directory that's not adjacent to your current one?

icd might be the solution for you. It's a command that activates an interactive way to navigate directories smoothly and easily. Compatible with bash and zsh.

Fun fact: It even preserves the behavior of cd -, if you use that!

Controls

  • icd to activate
  • ↑/↓ to change the selected subdirectory
  • ← to move into the parent subdirectory (disallowed when in $HOME)
  • → to make the selected subdirectory the active one (i.e. cd into it)
  • ↵ (return) to exit navigation in the new active directory
  • Esc to cancel navigation, staying in the initial directory
  • Any characters (except backslash) to search for a specific substring

Installation

Download icd.sh and place it wherever you want. You can source this file in your .{zsh|bash}rc, or you can directly copy the contents in. The following commands can help with that (replace .zshrc with .bashrc as appropriate):

# source file from .zshrc
echo "source <path-to-file>" >> ~/.zshrc
# copy into .zshrc
tail -n +3 <path-to-file> >> ~/.zshrc

Personally, I've modified cd to automatically call icd when no arguments are supplied.

cd() {
    if [[ $# -eq 0 ]]; then
        icd
    else
        builtin cd "$@"
    fi
}
#!/bin/bash
# `icd` to activate an interactive way to navigate through directories
# Note: for consistency, treat all arrays as 0-indexed
icd() {
# This code distinguishes between
# - visible directories (the ones currently shown on screen),
# - filtered directories (the ones matching the current search string), and
# - all directories (all subdirectories in the current directory)
local MAX_VISIBLE_DIRS=10 MAX_DIR_WIDTH=60 SEARCH_ICON="\uf422"
local SEARCH_PREFIX=" $(tput bold)${SEARCH_ICON}$(tput sgr0)$(tput el) "
local is_bash=$([[ -n $BASH ]] && echo true || echo false)
local is_zsh=$([[ -n $ZSH_NAME ]] && echo true || echo false)
# Expected output:
# - directory line (fixed line)
# - instructions line (fixed line)
# - search string line (fixed line)
# - subdirectories (all_dirs, each on their own line, with up to MAX_VISIBLE_DIRS shown at a time)
# - blank lines to fill up the rest of the screen as needed
## --- Helper functions ---
# Read a single keypress in zsh and translate it to a command name
zsh_key_input() {
read -sk1 key
if [[ $key = $'\e' ]]; then
read -sk2 -t 0.1 key
[[ -z $key ]] && key=$'\e' # no follow-up character means escape key was pressed
fi
translate_input "$key"
}
# Read a single keypress in bash and translate it to a command name
bash_key_input() {
IFS='' read -rsn1 key
if [[ $key = $'\e' ]]; then
read -rsn2 -t 0.1 key
[[ -z $key ]] && key=$'\e' # no follow-up character means escape key was pressed
fi
translate_input "$key"
}
# Read a single keypress and translate it (auto-detects shell)
read_key() {
if [[ $is_bash = true ]]; then
bash_key_input
else
zsh_key_input
fi
}
# Convert raw key input into readable command names (enter, backspace, arrow keys, etc.)
translate_input() {
# args: keyboard input
case $1 in
$'\n'|'') echo enter;;
$'\177'|$'\b') echo backspace;;
$'\e') echo escape;;
"[A") echo up;;
"[B") echo down;;
"[C") echo right;;
"[D") echo left;;
*) echo "$1";;
esac
}
# Get element at given index from array
# Args: index, array
index_array() {
local i=$1
shift 1
echo "${@:$((i+1)):1}" # only way for array indexing to work for both bash and zsh
# ${@:0:1} will return the function name
}
# Find the index of an element in an array, returns -1 if not found
# Args: element, array
index_of() {
local e=$1
shift 1
local i=0
for s in "$@"; do
if [[ $s = "$e" ]]; then
echo $i
return
fi
((i++))
done
echo -1
}
# Escape special regex characters so they're treated as literal text in search
# Args: string
local regex_chars='$^.?+*(){}[]/'
escape_regex() {
local str=$1 c=''
for ((i=0; i<${#regex_chars}; i++)); do
c=${regex_chars:$i:1}
str=${str//"$c"/\\$c}
done
echo "$str"
}
# Convert string to lowercase (works in both bash and zsh)
# Args: string
to_lowercase() {
local str=$1
if [[ $is_bash = true ]]; then
echo "$str" | tr '[:upper:]' '[:lower:]'
else
echo "${str:l}"
fi
}
## --- Rendering functions ---
# Move cursor to where the first visible dir should be displayed
cursor_to_first_visible_dir() {
tput rc; tput cud1; tput cud1; tput cud1
}
# Print a non-selected directory with scroll indicators if needed
# Args: see below
print_dir() {
local dir_name=$1
local is_first_visible_row=$2
local is_last_visible_row=$3
local can_scroll_up=$4
local can_scroll_down=$5
local prefix=" "
if [[ $is_first_visible_row = true ]] && [[ $can_scroll_up = true ]]; then
prefix="↑"
elif [[ $is_last_visible_row = true ]] && [[ $can_scroll_down = true ]]; then
prefix="↓"
fi
echo " ${prefix} $dir_name $(tput el)"
}
# Print the selected directory with highlight and scroll indicators if needed
# Args: see below
print_selected_dir() {
local dir_name=$1
local is_first_visible_row=$2
local is_last_visible_row=$3
local can_scroll_up=$4
local can_scroll_down=$5
local prefix=" "
if [[ $is_first_visible_row = true ]] && [[ $can_scroll_up = true ]]; then
prefix="↑"
elif [[ $is_last_visible_row = true ]] && [[ $can_scroll_down = true ]]; then
prefix="↓"
fi
echo " ${prefix} $(tput setab 7)$(tput setaf 0) $dir_name $(tput sgr0)$(tput el)"
}
# Render all visible directories with one highlighted as selected
# Precondition: cursor is at the first line of the visible dirs
# Precondition: there is at least one dir to show (otherwise this function should not be called)
# Args: see below
render_visible_dirs() {
local selected_vindex=$1 # index of selected dir in visible dirs array, not in full dirs array
local first_visible_dir_index=$2 # index of first visible dir in full dirs array
local num_filtered_dirs=$3
shift 3
local visible_dirs=("$@")
local can_scroll_up=false
local can_scroll_down=false
[[ $first_visible_dir_index -gt 0 ]] && can_scroll_up=true
[[ $((first_visible_dir_index + ${#visible_dirs[@]})) -lt $num_filtered_dirs ]] && can_scroll_down=true
local i=0
for dir in "${visible_dirs[@]}"; do
local is_first=false
local is_last=false
[[ $i -eq 0 ]] && is_first=true
[[ $i -eq $((${#visible_dirs[@]} - 1)) ]] && is_last=true
if [[ $i -eq $selected_vindex ]]; then
print_selected_dir "$dir" "$is_first" "$is_last" "$can_scroll_up" "$can_scroll_down"
else
print_dir "$dir" "$is_first" "$is_last" "$can_scroll_up" "$can_scroll_down"
fi
((i++))
done
}
# Display the current search string with cursor
# Postcondition: cursor is at the first line of the visible dirs
# Args: search string
render_search_string() {
tput rc; tput cud1; tput cud1; tput cuf 4
echo "$(tput setaf 3)${search_str}$(tput el)$(tput sgr0)_"
}
# Display the header showing current directory and keyboard controls
render_heading() {
tput rc
local pwd_str=$(pwd)
local lim_width=$(($(tput cols) - 20 - 5)) # 20 for "Change directory to ", 5 for buffer
[[ lim_width -gt MAX_DIR_WIDTH ]] && lim_width=$MAX_DIR_WIDTH
[[ ${#pwd_str} -gt $lim_width ]] && pwd_str="...${pwd_str:$((${#pwd_str} - lim_width + 3))}"
echo "$(tput smul)Change directory to $(tput bold)${pwd_str}$(tput sgr0)$(tput el)"
echo "$(tput dim)↑↓:select ←:parent →:enter ⏎:confirm ESC:cancel type:search$(tput sgr0)$(tput el)"
}
## --- Set up ---
# Initialize variables
local search_str='' prev_dir='' initial_pwd=$PWD initial_oldpwd=$OLDPWD
local num_visible_dirs=$(($(tput lines) - 3 - 1)) # 3 fixed lines (header + instructions + search), 1 for buffer
[[ $num_visible_dirs -gt $MAX_VISIBLE_DIRS ]] && num_visible_dirs=$MAX_VISIBLE_DIRS
# Initialize interface
tput civis
stty -echo
tput sc && render_heading
echo -e "$SEARCH_PREFIX"
for ((i=0; i<num_visible_dirs; i++)); do echo; done
tput cuu $((num_visible_dirs + 3))
tput sc
# Cleanup functions
cleanup_base() {
tput rc
tput ed
tput cnorm
stty echo
trap - INT
}
cleanup_confirm() {
cleanup_base
builtin cd $initial_pwd # support expected `cd -` behavior after exiting
builtin cd $OLDPWD
echo "Working directory changed: $(tput bold)$(pwd)$(tput el)$(tput sgr0)"
}
cleanup_cancel() {
cleanup_base
builtin cd $initial_oldpwd
builtin cd $initial_pwd # restore expected `cd -` behavior
echo "Restored working directory: $(tput bold)$(pwd)$(tput el)$(tput sgr0)"
return
}
trap 'cleanup_cancel; return' INT
## --- Main loop ---
while true; do
# Determine the subdirectories
local filtered_dirs=() all_dirs=()
local subdirs=$( (/bin/ls -F | grep /$ | sort -f) )
if [[ -n $subdirs ]]; then
[[ $is_bash = true ]] && IFS=$'\n' read -r -d '' -a all_dirs <<< "$subdirs"
[[ $is_zsh = true ]] && all_dirs+=("${(f)subdirs}")
fi
# Select previous dir (if left arrow key was pressed)
local selected_index=0
local prev_dir_index=$(index_of "$prev_dir" "${all_dirs[@]}")
[[ $prev_dir_index -ne -1 ]] && selected_index=$prev_dir_index;
local key_pressed=''
local first_visible_dir_index=$((selected_index - num_visible_dirs + 1))
[[ $first_visible_dir_index -lt 0 ]] && first_visible_dir_index=0
local do_rerender_dirs=true did_update_search=true
render_heading
# Loop while still in the same directory
while true; do
# Update filtered directories if search string changed
if [[ $did_update_search = true ]]; then
render_search_string "$search_str"
# Filter directories by search string
filtered_dirs=()
if [[ -z $search_str ]]; then
filtered_dirs=("${all_dirs[@]}")
else
for dir in "${all_dirs[@]}"; do
local dir_lowercase=$(to_lowercase "$dir")
local regex=$(to_lowercase "$search_str")
regex=$(escape_regex "$regex")
[[ "$dir_lowercase" =~ $regex ]] && filtered_dirs+=("$dir")
done
fi
# Handle special cases for empty results
local show_message=false
local message=""
if [[ ${#filtered_dirs[@]} -eq 0 ]]; then
show_message=true
if [[ ${#all_dirs[@]} -eq 0 ]]; then
message="No subdirectories (use ← to go to parent)"
else
message="No matches found"
fi
fi
local num_filtered_dirs=${#filtered_dirs[@]}
else
cursor_to_first_visible_dir
fi
did_update_search=false
# Rerender directories if needed
if [[ $do_rerender_dirs = true ]]; then
# Determine "scroll" position
if [[ $selected_index -lt $first_visible_dir_index ]]; then first_visible_dir_index=$selected_index
elif [[ $selected_index -ge $((first_visible_dir_index + num_visible_dirs)) ]]; then first_visible_dir_index=$((selected_index - num_visible_dirs + 1)); fi
# Print directories (or message if there are none)
if [[ $show_message = true ]]; then
echo " $(tput dim)${message}$(tput sgr0)$(tput el)"
tput ed
else
visible_dirs=( "${filtered_dirs[@]:$first_visible_dir_index:$num_visible_dirs}" )
render_visible_dirs $((selected_index - first_visible_dir_index)) $first_visible_dir_index $num_filtered_dirs "${visible_dirs[@]}"
tput ed
fi
fi
do_rerender_dirs=true
# Process user input
[[ $is_bash = true ]] && key_pressed=$(bash_key_input) || key_pressed=$(zsh_key_input)
case $key_pressed in
escape) cleanup_cancel; return;;
left) [[ $PWD != "$HOME" ]] && break;;
up) [[ $num_filtered_dirs -gt 0 ]] && { ((selected_index--)); [[ $selected_index -lt 0 ]] && selected_index=$((num_filtered_dirs - 1)); };;
down) [[ $num_filtered_dirs -gt 0 ]] && { ((selected_index++)); [[ $selected_index -ge $num_filtered_dirs ]] && selected_index=0; };;
right) [[ $num_filtered_dirs -gt 0 ]] && break;;
enter) break;;
'\') do_rerender_dirs=false;;
backspace)
search_str="${search_str%?}"
selected_index=0
did_update_search=true;;
*)
search_str+=$key_pressed
selected_index=0
did_update_search=true;;
esac
done
# cd accordingly
case $key_pressed in
right)
if [[ $num_filtered_dirs -gt 0 ]]; then
builtin cd "$(index_array "$selected_index" "${filtered_dirs[@]}")"; prev_dir=''
else
break # exit if there are no dirs to enter
fi;;
left) prev_dir=$(printf '%s/' "${PWD##*/}"); builtin cd ..;;
enter) break;;
esac
search_str=''
done
cleanup_confirm
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment