Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ WORKDIR /home/tester

# Copy the script and tests
COPY --chown=tester:tester bash-autosuggestions.sh /home/tester/.local/share/bash-autosuggestions/bash-autosuggestions.sh
COPY --chown=tester:tester test-automated.sh /home/tester/test-automated.sh

# Seed bash history with realistic commands so suggestions appear immediately
RUN mkdir -p /home/tester/.local/share/bash-autosuggestions && \
Expand Down
96 changes: 48 additions & 48 deletions bash-autosuggestions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
#
# KEYBINDINGS:
# → (Right Arrow) / End Accept full suggestion
# Alt+→ / Alt+F Accept next word
# Ctrl+→ / Ctrl+← / Alt+F Accept next word
# ↑ (Up Arrow) Next suggestion (cycle)
# ↓ (Down Arrow) Previous suggestion (cycle)
# Ctrl+] Dismiss suggestion
Expand Down Expand Up @@ -56,7 +56,7 @@ _bas_collect_candidates() {
local line prev_line=""
local contextual_matches=()
local regular_matches=()

# Read history in reverse (most recent first)
while IFS='' read -r line; do
# Strip leading spaces and history number
Expand All @@ -70,7 +70,7 @@ _bas_collect_candidates() {
eval "[[ \"\$line\" == $BASH_AUTOSUGGEST_HISTORY_IGNORE ]]" 2>/dev/null && continue
fi
seen[$line]=1

# For match_prev_cmd strategy: check if prev_line matches _bas_prev_cmd
if [[ "$BASH_AUTOSUGGEST_STRATEGY" == "match_prev_cmd" && -n "$_bas_prev_cmd" ]]; then
if [[ "$prev_line" == "$_bas_prev_cmd" ]]; then
Expand All @@ -85,13 +85,13 @@ _bas_collect_candidates() {
(( ${#_bas_candidates[@]} >= BASH_AUTOSUGGEST_MAX_CANDIDATES )) && break
fi
fi

# Track previous line for context matching
if [[ -n "$line" ]]; then
prev_line="$line"
fi
done < <(HISTTIMEFORMAT='' builtin history | tac)

# For match_prev_cmd: combine contextual matches first, then regular
if [[ "$BASH_AUTOSUGGEST_STRATEGY" == "match_prev_cmd" ]]; then
_bas_candidates=("${contextual_matches[@]}" "${regular_matches[@]}")
Expand Down Expand Up @@ -190,14 +190,14 @@ _bas_show_current_candidate() {
_bas_clear_ghost
if (( _bas_cand_idx >= 0 && _bas_cand_idx < ${#_bas_candidates[@]} )); then
local suffix="${_bas_candidates[$_bas_cand_idx]:${#_bas_typed}}"

# Store for async display
_bas_pending_ghost="$suffix"
_bas_pending_counter=""
if (( ${#_bas_candidates[@]} > 1 )); then
_bas_pending_counter=" [$((_bas_cand_idx+1))/${#_bas_candidates[@]}]"
fi

_bas_show_ghost_after_redisplay
fi
}
Expand All @@ -213,21 +213,21 @@ _bas_show_ghost_after_redisplay() {
if [[ -n "$_bas_pending_ghost" ]]; then
_bas_ghost="$_bas_pending_ghost"
_bas_showing=1

# Launch background job and immediately disown to remove from job table
{
# Minimal delay to let readline finish cursor positioning
read -t 0.001 < /dev/null 2>/dev/null || true

# Print ghost and reposition cursor
printf '\e[%sm%s\e[0m\e[2m%s\e[0m\e[%dD' \
"$BASH_AUTOSUGGEST_HIGHLIGHT" "$_bas_pending_ghost" "$_bas_pending_counter" \
"$((${#_bas_pending_ghost} + ${#_bas_pending_counter}))" > /dev/tty
} &>/dev/null &

# Remove job from job table immediately
disown -a 2>/dev/null

_bas_pending_ghost=""
_bas_pending_counter=""
fi
Expand All @@ -236,39 +236,39 @@ _bas_show_ghost_after_redisplay() {
_bas_self_insert_and_update() {
local char="$1"
_bas_clear_ghost

# Insert character at cursor position
local line="$READLINE_LINE"
local pos="$READLINE_POINT"
local before="${line:0:$pos}"
local after="${line:$pos}"

local new_line="${before}${char}${after}"
local new_point=$((pos + 1))

# Only show suggestions if typing at end of line and enabled
if [[ -z "$after" ]] && (( _bas_enabled )); then
local typed="${before}${char}"
_bas_typed="$typed"

if [[ -n "$typed" && ${#typed} -ge $BASH_AUTOSUGGEST_MIN_LENGTH ]]; then
_bas_collect_candidates "$typed"

if (( ${#_bas_candidates[@]} > 0 )); then
_bas_cand_idx=0
local suffix="${_bas_candidates[0]:${#typed}}"

# Store the ghost to show it AFTER readline redraws
_bas_pending_ghost="$suffix"
_bas_pending_counter=""
if (( ${#_bas_candidates[@]} > 1 )); then
_bas_pending_counter=" [$((_bas_cand_idx+1))/${#_bas_candidates[@]}]"
fi

# Update readline variables - this causes a redisplay
READLINE_LINE="$new_line"
READLINE_POINT=$new_point

# Schedule ghost display to happen after this handler returns
# Use PROMPT_COMMAND to display on next prompt cycle
# Store for display after readline finishes
Expand All @@ -282,7 +282,7 @@ _bas_self_insert_and_update() {
_bas_cand_idx=-1
_bas_typed="${before}${char}"
fi

# Update readline variables
READLINE_LINE="$new_line"
READLINE_POINT=$new_point
Expand All @@ -292,29 +292,29 @@ _bas_backspace_handler() {
# Delete character before cursor
local line="$READLINE_LINE"
local pos="$READLINE_POINT"

if (( pos > 0 )); then
local before="${line:0:$((pos-1))}"
local after="${line:$pos}"

READLINE_LINE="${before}${after}"
READLINE_POINT=$((pos - 1))

# Clear state immediately - call _bas_clear_ghost to clear visible ghost
_bas_clear_ghost

# Only show suggestions if at end of line and enabled
if [[ -z "$after" ]] && (( _bas_enabled )); then
local typed="$before"
_bas_typed="$typed"

if [[ -n "$typed" && ${#typed} -ge $BASH_AUTOSUGGEST_MIN_LENGTH ]]; then
_bas_collect_candidates "$typed"

if (( ${#_bas_candidates[@]} > 0 )); then
_bas_cand_idx=0
local suffix="${_bas_candidates[0]:${#typed}}"

# Store for async display
_bas_pending_ghost="$suffix"
_bas_pending_counter=""
Expand Down Expand Up @@ -362,32 +362,32 @@ _bas_delete_handler() {

_bas_kill_word_handler() {
_bas_clear_ghost

# Kill word backward from cursor
local line="$READLINE_LINE"
local pos="$READLINE_POINT"
local before="${line:0:$pos}"
local after="${line:$pos}"

# Remove trailing spaces then word
before="${before%"${before##*[! ]}"}"
before="${before%"${before##* }"}"

READLINE_LINE="${before}${after}"
READLINE_POINT=${#before}

# Only show suggestions if at end of line and enabled
if [[ -z "$after" ]] && (( _bas_enabled )); then
local typed="$before"
_bas_typed="$typed"

if [[ -n "$typed" && ${#typed} -ge $BASH_AUTOSUGGEST_MIN_LENGTH ]]; then
_bas_collect_candidates "$typed"

if (( ${#_bas_candidates[@]} > 0 )); then
_bas_cand_idx=0
local suffix="${_bas_candidates[0]:${#typed}}"

# Store for async display
_bas_pending_ghost="$suffix"
_bas_pending_counter=""
Expand All @@ -412,7 +412,7 @@ _bas_kill_word_handler() {

_bas_kill_line_handler() {
_bas_clear_ghost

READLINE_LINE=""
READLINE_POINT=0
_bas_typed=""
Expand All @@ -428,15 +428,15 @@ _bas_cycle_next() {
# Multiple suggestions available - cycle through them
_bas_cand_idx=$(( (_bas_cand_idx + 1) % ${#_bas_candidates[@]} ))
_bas_clear_ghost

# Prepare ghost for display
local suffix="${_bas_candidates[$_bas_cand_idx]:${#_bas_typed}}"
_bas_pending_ghost="$suffix"
_bas_pending_counter=""
if (( ${#_bas_candidates[@]} > 1 )); then
_bas_pending_counter=" [$((_bas_cand_idx+1))/${#_bas_candidates[@]}]"
fi

# Display after handler returns
trap '_bas_show_ghost_after_redisplay; trap - RETURN' RETURN
elif (( ${#_bas_candidates[@]} == 1 )); then
Expand All @@ -460,15 +460,15 @@ _bas_cycle_prev() {
# Multiple suggestions available - cycle through them
_bas_cand_idx=$(( (_bas_cand_idx - 1 + ${#_bas_candidates[@]}) % ${#_bas_candidates[@]} ))
_bas_clear_ghost

# Prepare ghost for display
local suffix="${_bas_candidates[$_bas_cand_idx]:${#_bas_typed}}"
_bas_pending_ghost="$suffix"
_bas_pending_counter=""
if (( ${#_bas_candidates[@]} > 1 )); then
_bas_pending_counter=" [$((_bas_cand_idx+1))/${#_bas_candidates[@]}]"
fi

# Display after handler returns
trap '_bas_show_ghost_after_redisplay; trap - RETURN' RETURN
elif (( ${#_bas_candidates[@]} == 1 )); then
Expand Down Expand Up @@ -522,10 +522,10 @@ _bas_accept_word() {
local remaining="${full:${#_bas_typed}}"

local word="" i=0 len=${#remaining}
while (( i < len )) && [[ "${remaining:$i:1}" == " " ]]; do
while (( i < len )) && [[ "${remaining:$i:1}" == " " || "${remaining:$i:1}" == "." ]]; do
word+="${remaining:$i:1}"; (( i++ ))
done
while (( i < len )) && [[ "${remaining:$i:1}" != " " ]]; do
while (( i < len )) && [[ "${remaining:$i:1}" != " " && "${remaining:$i:1}" != "." ]]; do
word+="${remaining:$i:1}"; (( i++ ))
done

Expand All @@ -548,8 +548,8 @@ _bas_accept_word() {
fi
else
local pos=$READLINE_POINT len=${#READLINE_LINE}
while (( pos < len )) && [[ "${READLINE_LINE:$pos:1}" != " " ]]; do (( pos++ )); done
while (( pos < len )) && [[ "${READLINE_LINE:$pos:1}" == " " ]]; do (( pos++ )); done
while (( pos < len )) && [[ "${READLINE_LINE:$pos:1}" != " " && "${READLINE_LINE:$pos:1}" != "." ]]; do (( pos++ )); done
while (( pos < len )) && [[ "${READLINE_LINE:$pos:1}" == " " || "${READLINE_LINE:$pos:1}" == "." ]]; do (( pos++ )); done
READLINE_POINT=$pos
fi
}
Expand All @@ -575,14 +575,14 @@ _bas_enter_handler() {
_bas_candidates=()
_bas_cand_idx=-1
fi

# Save current command for match_prev_cmd strategy
_bas_prev_cmd="$READLINE_LINE"

# Unbind our handler and rebind to accept-line for THIS press
bind '"\C-m": accept-line'
bind '"\C-j": accept-line'

# Inject Enter to trigger the now-bound accept-line
# Use ANSI sequence to inject key into input buffer
# \x1b[200~ starts bracketed paste, then send \r, then \x1b[201~ ends it
Expand All @@ -600,15 +600,15 @@ _bas_rebind_enter() {
_bas_setup() {
# Disable job control monitoring to prevent "[1] 359" messages from background jobs
set +m

# Accept / dismiss
bind -x '"\e[C": _bas_accept_full'
bind -x '"\eOC": _bas_accept_full'
bind -x '"\e[F": _bas_accept_end'
bind -x '"\eOF": _bas_accept_end'
bind -x '"\e[4~": _bas_accept_end'
bind -x '"\e[1;3C": _bas_accept_word'
bind -x '"\e\e[C": _bas_accept_word'
bind -x '"\e[1;5C": _bas_accept_word'
bind -x '"\e[1;5D": _bas_kill_word_handler'
bind -x '"\ef": _bas_accept_word'
bind -x '"\C-]": _bas_dismiss'

Expand Down