Files
Linux_post_install/lib/menu-lib.sh
T
Your Name 8ce54794ee
gates / consistency-and-conventions (push) Successful in 27s
fix: pos ai alias create aborts on empty system prompt — menu_ask_value --allow-empty
User report: pressing Enter on 'System prompt (empty = use built-in)'
silently returned to the menu — no alias created, and step labels read
[1/4] [2/4] in a 5-step flow.

Detective (pre-existing, not a 2026-09-06 regression): menu_ask_value's
documented contract is 'rc 1 = cancel, or empty answer with no default';
the step-4 call passed an empty default so the advertised empty answer
hit rc 1 and '|| return 0' aborted the flow. Same latent trap at the
alias-name step (empty-name warn/re-prompt was dead code). 11 other
call sites are correct (6 external rely on empty=cancel, 4 pass
defaults) — no global semantic change allowed.

Architect: opt-in --allow-empty flag on menu_ask_value (backward
compatible; empty+no-default -> rc 0 + empty value; genuine cancel/EOF
stays rc 1; default still wins). Builder: implemented in lib/menu-lib.sh
+ bin/pos-ai-alias (steps 1-2 relabeled /5, flag at the two approved
sites); 7-case smoke matrix PASS.

Tests: tests/t-menu-allow-empty.sh (30 checks) — semantics matrix
against the real menu_ask_value via non-TTY stdin, reader-contract
probes (empty-Enter rc 0 vs EOF rc 1), static guards on step labeling,
the exactly-2 flag call sites, edit-flow untouched, and a scope fence
over all pos-* tools. Pty E2E proven feasible (script -qec, 3 scenarios)
and documented in the Tester report; the E2E file itself remains a
follow-up.

Verified: make gen idempotent; make check OK; make lint 0 FAIL, 0 WARN;
make test 17 files / 299 checks / 0 fail / 0 skip (~49s); bash -n clean.
2026-09-07 02:04:30 -04:00

371 lines
15 KiB
Bash

# lib/menu-lib.sh — category-neutral interactive menu primitives.
#
# The generic half of the former share-lib interactive layer (Pattern B),
# extracted so any `pos` tool can share one interaction vocabulary: a tty
# guard, a looping boxed menu, a type-to-filter picker and a prompt with
# optional default. Display goes to stderr, results to stdout; reads are
# stdin-based and fail closed (EOF / no terminal → rc 1, never a hang),
# so the functions are safe under the dispatcher's logging tee and inside
# command substitution.
#
# Contracts (all of them, no exceptions):
# * Defines ONLY `menu_*` functions — sourcing never clobbers a tool's own
# helpers (same discipline as lib/notify.sh).
# * Requires common.sh to be sourced by the CALLER for colored output;
# CYAN/RESET get empty guarded fallbacks here so the lib also works
# standalone-sourced (plain text instead of color — never an error).
# * NEVER exits and never terminates the caller: every function returns,
# failures are signalled through the return code.
# * Display goes to stderr, results go to stdout — any function whose result
# is meant to be command-substituted prints ONLY the result on stdout.
# * Performs NO file writes of its own.
#
# Function index:
# menu_guard rc 0 iff stdin is a terminal
# menu_run <title> <item...> numbered menu loop → chosen index
# menu_pick <prompt> <item...> type-to-filter picker → chosen index
# menu_ask_value [--allow-empty] <label> [default] prompted value → entered text
# menu_read_value <label> raw-mode bracketed-paste reader
# menu_redraw internal redraw (menu_read_value only)
# ── Colors (guarded fallbacks; a sourced common.sh wins) ──────
CYAN="${CYAN:-}"
RESET="${RESET:-}"
# ── Terminal guard ────────────────────────────────────────────
# rc 0 iff stdin is a tty · rc 1 otherwise, with a one-line pointer to the
# scriptable subcommands. Tools that must fail hard without a TTY call this
# before entering the loop.
menu_guard() {
if [ -t 0 ]; then
return 0
fi
printf '[!] Interactive menu needs a terminal — use a subcommand instead (see --help).\n' >&2
return 1
}
# ── Numbered menu loop (firewall-precedent style) ──────────────
# Renders a section-box title + `%2d)` items + separator to stderr and reads
# `Choose: `. stdout carries the chosen index ONLY.
# rc 0 valid pick (index on stdout)
# rc 1 quit (`0`/`q`/`Q`), EOF, or no terminal — callers treat this as a
# clean menu exit (tools that must fail hard without a TTY call
# menu_guard themselves before entering the loop).
menu_run() {
local title="$1"; shift
local -a items=("$@")
if ! menu_guard; then
return 1
fi
local opt i
while true; do
{
echo
echo "${CYAN}════════════════════════════════════════════${RESET}"
echo "${CYAN} ${title}${RESET}"
echo "${CYAN}════════════════════════════════════════════${RESET}"
for ((i = 0; i < ${#items[@]}; i++)); do
printf ' %2d) %s\n' $((i + 1)) "${items[$i]}"
done
printf ' %2d) %s\n' 0 "Exit"
echo "----------------------------------------"
} >&2
if ! read -rp "Choose: " opt; then
return 1 # EOF — clean menu exit
fi
case "$opt" in
"") ;; # empty input → redraw
0 | q | Q) return 1 ;;
*)
if [[ "$opt" =~ ^[0-9]+$ ]] && (( opt >= 1 && opt <= ${#items[@]} )); then
echo "$opt"
return 0
fi
echo "Unknown choice." >&2
;;
esac
done
}
# ── Type-to-filter picker ──────────────────────────────────────
# Lists items on stderr; numeric choice → index (into the FULL item list) on
# stdout; non-numeric input filters case-insensitively and redisplays with a
# "-- N of M match 'text' --" banner; empty or `/` while filtered clears back
# to the full list; zero matches warn and redisplay.
# rc 0 picked · rc 1 back/cancel (`0`/`q`/`b`, EOF) — never out of bounds.
menu_pick() {
local prompt="${1:-Pick}"; shift
local -a items=("$@")
if [ "${#items[@]}" -eq 0 ]; then
return 1
fi
if ! [ -t 0 ]; then
printf '[!] Interactive picker needs a terminal.\n' >&2
return 1
fi
local filter="" ans i n total=${#items[@]}
local -a shown=() orig=()
while true; do
shown=()
orig=()
for ((i = 0; i < total; i++)); do
if [ -z "$filter" ] || [[ "${items[$i],,}" == *"${filter,,}"* ]]; then
shown+=("${items[$i]}")
orig+=("$((i + 1))")
fi
done
n=${#shown[@]}
{
echo
if [ -n "$filter" ]; then
printf -- "-- %d of %d match '%s' --\n" "$n" "$total" "$filter"
else
printf -- "-- %d available --\n" "$total"
fi
if [ "$n" -eq 0 ]; then
printf '[!] no matches — enter nothing or / to clear the filter\n' >&2
else
for ((i = 0; i < n; i++)); do
printf ' %2d) %s\n' $((i + 1)) "${shown[$i]}"
done
fi
} >&2
if ! read -rp "${prompt} [1-${n}], text=filter, 0=back " ans; then
return 1 # EOF — cancel
fi
case "$ans" in
"") [ -z "$filter" ] || filter="" ; continue ;;
"/") filter="" ; continue ;;
0 | q | Q | b | B) return 1 ;;
*[!0-9]*)
filter="$ans"
continue
;;
*)
if (( ans >= 1 && ans <= n )); then
echo "${orig[$((ans - 1))]}"
return 0
fi
echo "Unknown choice." >&2
;;
esac
done
}
# ── Raw-mode value reader (bracketed-paste safe) ───────────────
# Reads ONE value from the terminal in raw mode with bracketed paste enabled,
# so a multi-line CTRL+V paste is inserted LITERALLY — embedded newlines are
# data, never line terminators — and can never leak into the shell or a later
# prompt as leftover keystrokes. A plain bash `read` is line-oriented: it
# consumes only the first pasted line and the remaining lines sit in the tty
# queue, where the next prompt (or the shell after this script exits) treats
# them as input/commands. That is the paste bug this reader exists to prevent.
#
# Editing (single-line typing behaves like a normal prompt):
# Enter submit the value (outside a paste)
# Backspace/DEL delete the character before the cursor
# Left/Right move the cursor; Home/End jump to start/end
# Delete delete the character at the cursor
# Ctrl-U clear the whole value
# Ctrl-D (empty) EOF — cancel · Ctrl-C/Z/\ — cancel · Up/Down — ignored
# Inside a bracketed paste the above are inert: text (incl. newlines) is
# inserted verbatim until the paste-end marker; a real Enter then submits.
#
# Display goes to stderr so callers may command-substitute the result:
# rc 0 value on stdout · rc 1 cancel/EOF/non-tty.
menu_read_value() {
local label="$1"
local val="" state="" chunk="" ch="" esc="" seq="" esc_c=""
local paste=0 pos=0 submit=0 i=0 n=0
if ! state="$(stty -g 2>/dev/null)"; then
# not a terminal — plain stdin read; no paste protection is possible
IFS= read -r val || return 1
[ -n "$val" ] && printf '%s' "$val"
return 0
fi
if ! stty -icanon -echo -isig min 1 time 0 2>/dev/null; then
stty "$state" 2>/dev/null
IFS= read -r val || return 1
[ -n "$val" ] && printf '%s' "$val"
return 0
fi
local restore
restore() {
stty "$state" 2>/dev/null
printf '\033[?2004l' >&2
}
trap 'restore; trap - INT TERM; return 1' INT TERM
printf '\033[?2004h' >&2
printf '%s: ' "$label" >&2
# Next input byte as a 2-hex-digit string, returned via nameref. Uses
# dd|od, NOT bash's read builtin: read's tty path self-interrupts on an ETX
# byte even with ISIG disabled (SIGINTs the whole script on Ctrl-C, killing
# a cmdsubst caller). One dd per input burst (VMIN=1 returns all queued
# bytes), so pastes cost O(chunks), not O(per-byte forks). Runs in-place
# (never in a $( ) subshell) so its chunk/offset state persists.
# byte <hexvar> — rc 0 = byte in hexvar, rc 1 = EOF/short.
local byte
byte() {
local -n _hex="$1"
if [ "$i" -ge "$n" ]; then
chunk="$(dd bs=4096 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n')"
[ -n "$chunk" ] || return 1
n=${#chunk}
i=0
fi
_hex="${chunk:i:2}"
i=$((i + 2))
return 0
}
while byte ch; do
case "$ch" in
1b)
seq=""
while byte esc; do
printf -v esc_c '%b' "\\x$esc"
seq+="$esc_c"
case "$esc_c" in
[A-Za-z~]) break ;;
esac
done
case "$seq" in
'[200~') paste=1 ;;
'[201~') paste=0 ;;
'[C') [ "$pos" -lt "${#val}" ] && { pos=$((pos + 1)); menu_redraw "$label" "$val" "$pos"; } ;;
'[D') [ "$pos" -gt 0 ] && { pos=$((pos - 1)); menu_redraw "$label" "$val" "$pos"; } ;;
'[H' | '[1~') pos=0; menu_redraw "$label" "$val" "$pos" ;;
'[F' | '[4~') pos=${#val}; menu_redraw "$label" "$val" "$pos" ;;
'[3~')
if [ "$pos" -lt "${#val}" ]; then
val="${val:0:pos}${val:pos+1}"
menu_redraw "$label" "$val" "$pos"
fi
;;
'[A' | '[B') : ;; # up/down: no history — ignore
esac
;;
0a | 0d)
if [ "$paste" -eq 1 ]; then
# newline inside a paste is literal data (paste as text);
# echo the line break so CRLF pastes render at col 0
printf -v ch '%b' "\\x$ch"
val="${val:0:pos}${ch}${val:pos}"
pos=$((pos + 1))
printf '%s' "$ch" >&2
else
submit=1
break
fi
;;
7f | 08) # Backspace/DEL
if [ "$pos" -gt 0 ]; then
val="${val:0:pos-1}${val:pos}"
pos=$((pos - 1))
menu_redraw "$label" "$val" "$pos"
fi
;;
03 | 1a | 1c) # Ctrl-C / Ctrl-Z / Ctrl-\ — cancel
submit=0
break
;;
04) # Ctrl-D: EOF on empty → cancel
if [ -z "$val" ]; then
submit=0
break
fi
;;
15) # Ctrl-U: clear
val=""; pos=0
menu_redraw "$label" "$val" "$pos"
;;
*)
printf -v ch '%b' "\\x$ch"
val="${val:0:pos}${ch}${val:pos}"
pos=$((pos + 1))
if [ "$pos" -eq "${#val}" ]; then
printf '%s' "$ch" >&2 # append in place — fast path
else
menu_redraw "$label" "$val" "$pos"
fi
;;
esac
done
trap - INT TERM
restore
printf '\n' >&2
if [ "$submit" -eq 0 ]; then
return 1
fi
printf '%s' "$val"
return 0
}
# ── Internal: redraw the whole input block (menu_read_value only) ──
# The value may span several terminal rows (multiline paste); redraw clears
# below the block start and reprints label + value, then repositions the
# cursor to (row, col) of $3. Columns are counted in characters — wide CJK
# glyphs can be off by one column (display-only; the stored value is exact).
menu_redraw() {
local label="$1" val="$2" pos="$3"
local nl="" r="" c="" last="" ec="" d="" up=""
nl="${val//[^$'\n']/}"
[ "${#nl}" -gt 0 ] && printf '\033[%dA' "${#nl}" >&2
printf '\r\033[J' >&2
printf '%s: ' "$label" >&2
printf '%s' "$val" >&2
# target row/col of the cursor
last="${val:0:pos}"
r="${last//[^$'\n']/}"; r="${#r}"
last="${last##*$'\n'}"
c="${#last}"
# current cursor (end of block): end row = nl count; end col = after last
# newline (or 0 when the value ends with a newline)
ec=0; last="${val##*$'\n'}"
case "$val" in
*$'\n') ec=0 ;;
*) ec="${#last}" ;;
esac
[ "${#nl}" -gt "$r" ] && printf '\033[%dA' $(( ${#nl} - r )) >&2
d=$(( c - ec ))
if [ "$d" -gt 0 ]; then
printf '\033[%dC' "$d" >&2
elif [ "$d" -lt 0 ]; then
printf '\033[%dD' $(( -d )) >&2
fi
return 0
}
# ── Prompted value with optional default ───────────────────────
# Prints "<label> [<default>]: " and echoes the entered value or the default
# when the answer is empty. Uses the bracketed-paste-safe reader, so pasting
# text — including multi-line pastes — inserts it literally instead of letting
# leftover lines escape to the shell as commands.
# rc 0 value on stdout · rc 1 EOF/cancel, or empty answer with no default.
# With --allow-empty: empty answer with no default → rc 0 + empty value;
# only genuine cancel/EOF returns rc 1.
menu_ask_value() {
local allow_empty=0
if [ "${1:-}" = "--allow-empty" ]; then
allow_empty=1
shift
fi
local label="$1" def="${2:-}" val pr="$1"
[ -n "$def" ] && pr="$pr [$def]"
if ! val="$(menu_read_value "$pr")"; then
return 1 # EOF / cancel
fi
if [ -z "$val" ]; then
[ -n "$def" ] && { echo "$def"; return 0; }
[ "$allow_empty" -eq 1 ] || return 1
echo ""
return 0
fi
echo "$val"
}