# shellcheck shell=bash # linux/lib/menu.sh - the interactive terminal menu and its pickers. # # Part of the claude-mode CLI: sourced by linux/claude-mode, which sets the # CM_* paths, PY and JSON used here. Not meant to run on its own. # --------------------------------------------------------------------------- # Interactive UI # --------------------------------------------------------------------------- ui_interactive() { [ -t 0 ] && [ -t 1 ]; } # Read one keypress, normalised to a word. Escape sequences are consumed # whole: leaving `[C` behind in the tty is what made zsh report # "bad pattern: [C" after the menu exited. # Read the bytes that follow an Escape, up to two of them, and print them. # # There is no portable short-timeout `read` here. bash 3.2 - which is what # macOS ships - rejects fractional timeouts outright, and its `read -t 0` # availability poll reports nothing even when bytes are sitting in the buffer # (verified against 3.2.57), so polling cannot be used to tell a bare Escape # from the start of a sequence. # # On a tty the terminal itself answers this: with icanon off and min 0 / # time 1, a plain read returns the moment a byte arrives and gives up after # ~0.1s otherwise. So Escape costs 0.1s, not the full second `-t 1` would # take. Off a tty there is no such knob, so that path falls back to `-t 1`. cm_read_esc_tail() { local save out='' a raw=0 if [ -t 0 ] && save="$(stty -g 2>/dev/null)"; then stty -echo -icanon min 0 time 1 2>/dev/null raw=1 fi if [ "$raw" -eq 1 ]; then IFS= read -r a 2>/dev/null; else IFS= read -rsn1 -t 1 a 2>/dev/null || a=''; fi out="$a" case "$out" in 'O') # SS3 - one more byte is the key itself if [ "$raw" -eq 1 ]; then IFS= read -r a 2>/dev/null; else IFS= read -rsn1 -t 1 a 2>/dev/null || a=''; fi out="$out$a" ;; '[') # CSI - parameters, then one final byte. Consume the lot: a # half-eaten sequence is what leaves `[C` in the tty for the # shell to report as a globbing error. while [ "${#out}" -lt 8 ]; do if [ "$raw" -eq 1 ]; then IFS= read -r a 2>/dev/null; else IFS= read -rsn1 -t 1 a 2>/dev/null || a=''; fi [ -n "$a" ] || break out="$out$a" case "$a" in [0-9]|';'|'?'|'<'|'>'|'='|':'|' ') continue ;; *) break ;; esac done ;; esac [ "$raw" -eq 1 ] && stty "$save" 2>/dev/null printf '%s' "$out" } read_key() { local k IFS= read -rsn1 k 2>/dev/null || return 1 if [ "$k" = $'\033' ]; then local tail; tail="$(cm_read_esc_tail)" case "$tail" in '[A') printf 'up' ;; '[B') printf 'down' ;; '[C') printf 'right' ;; '[D') printf 'left' ;; '[H') printf 'home' ;; '[F') printf 'end' ;; '[5~') printf 'pgup' ;; '[6~') printf 'pgdn' ;; 'OA') printf 'up' ;; # SS3 variants, cursor mode 'OB') printf 'down' ;; 'OC') printf 'right' ;; 'OD') printf 'left' ;; '') printf 'esc' ;; *) printf 'other' ;; esac return 0 fi case "$k" in '') printf 'enter' ;; $'\177'|$'\b') printf 'backspace' ;; *) printf 'char:%s' "$k" ;; esac } # Selection state shared with the callers, so bash does not have to return # structured data from a function. UI_LABELS=(); UI_DETAILS=(); UI_ACCENTS=(); UI_SEL=-1 ui_reset_items() { UI_LABELS=(); UI_DETAILS=(); UI_ACCENTS=(); } ui_add_item() { UI_LABELS+=("$1"); UI_DETAILS+=("${2:-}"); UI_ACCENTS+=("${3:-$C_CYAN}"); } # ui_select <status> -> sets UI_SEL (-1 = cancelled) ui_select() { local title="$1" status="${2:-}" local n=${#UI_LABELS[@]} idx=0 cols first=1 i key detail UI_SEL=-1 [ "$n" -gt 0 ] || return 1 cols=$(term_cols) printf '\033[?25l' # hide cursor trap 'printf "\033[?25h"' RETURN while true; do if [ "$first" -eq 1 ]; then first=0; else printf '\033[%dA' $((n + 4)); fi printf '\033[2K\n' printf '\033[2K %s%s%s' "$C_BOLD$C_CYAN" "$title" "$C_RESET" [ -n "$status" ] && printf ' %s%s%s' "$C_DIM" "$status" "$C_RESET" printf '\n' printf '\033[2K %sup/down move enter select esc cancel%s\n' "$C_DIM" "$C_RESET" for ((i = 0; i < n; i++)); do if [ "$i" -eq "$idx" ]; then printf '\033[2K%s > %-*s%s\n' "$(printf '\033[7m')${UI_ACCENTS[$i]}" $((cols - 5)) "${UI_LABELS[$i]}" "$C_RESET" else printf '\033[2K %s\n' "${UI_LABELS[$i]}" fi done detail="${UI_DETAILS[$idx]}" printf '\033[2K %s%s%s\n' "$C_DIM" "${detail:0:$((cols - 8))}" "$C_RESET" key="$(read_key)" || { UI_SEL=-1; return 1; } case "$key" in up) idx=$(( (idx - 1 + n) % n )) ;; down) idx=$(( (idx + 1) % n )) ;; home) idx=0 ;; end) idx=$((n - 1)) ;; enter) UI_SEL=$idx; return 0 ;; esc) UI_SEL=-1; return 1 ;; char:q|char:Q) UI_SEL=-1; return 1 ;; char:[1-9]) local d="${key#char:}" [ "$d" -le "$n" ] && { UI_SEL=$((d - 1)); return 0; } ;; esac done } # ui_filter_select <title> <status> - same, plus a type-to-filter box. # Items come from UI_LABELS/UI_DETAILS; sets UI_SEL as an index into them. ui_filter_select() { local title="$1" status="${2:-}" local n=${#UI_LABELS[@]} query='' idx=0 off=0 rows=12 cols first=1 local -a match_idx UI_SEL=-1 [ "$n" -gt 0 ] || return 1 cols=$(term_cols) printf '\033[?25l' trap 'printf "\033[?25h"' RETURN while true; do match_idx=() local i lower_q; lower_q="$(printf '%s' "$query" | tr '[:upper:]' '[:lower:]')" for ((i = 0; i < n; i++)); do if [ -z "$query" ]; then match_idx+=("$i") else local lab; lab="$(printf '%s' "${UI_LABELS[$i]}" | tr '[:upper:]' '[:lower:]')" case "$lab" in *"$lower_q"*) match_idx+=("$i") ;; esac fi done local m=${#match_idx[@]} [ "$idx" -ge "$m" ] && idx=$(( m > 0 ? m - 1 : 0 )) [ "$idx" -lt "$off" ] && off=$idx [ "$idx" -ge $((off + rows)) ] && off=$((idx - rows + 1)) [ "$off" -lt 0 ] && off=0 if [ "$first" -eq 1 ]; then first=0; else printf '\033[%dA' $((rows + 6)); fi printf '\033[2K\n' printf '\033[2K %s%s%s' "$C_BOLD$C_CYAN" "$title" "$C_RESET" [ -n "$status" ] && printf ' %s%s%s' "$C_DIM" "$status" "$C_RESET" printf '\n' printf '\033[2K %stype to filter up/down move enter select esc cancel%s\n' "$C_DIM" "$C_RESET" printf '\033[2K %sfilter:%s %s_\n' "$C_WHITE" "$C_RESET" "$query" local r real for ((r = 0; r < rows; r++)); do local pos=$((off + r)) if [ "$pos" -ge "$m" ]; then printf '\033[2K\n'; continue; fi real=${match_idx[$pos]} if [ "$pos" -eq "$idx" ]; then printf '\033[2K%s > %-*s%s\n' "$(printf '\033[7m')$C_CYAN" $((cols - 5)) "${UI_LABELS[$real]}" "$C_RESET" else printf '\033[2K %s\n' "${UI_LABELS[$real]}" fi done if [ "$m" -eq 0 ]; then printf '\033[2K %s(no match)%s\n' "$C_DIM" "$C_RESET" printf '\033[2K\n' else printf '\033[2K %s%d of %d%s\n' "$C_DIM" $((idx + 1)) "$m" "$([ "$m" -ne "$n" ] && printf ' (filtered from %d)' "$n")$C_RESET" printf '\033[2K %s%s%s\n' "$C_DIM" "${UI_DETAILS[${match_idx[$idx]}]:0:$((cols - 8))}" "$C_RESET" fi local key; key="$(read_key)" || { UI_SEL=-1; return 1; } case "$key" in up) [ "$m" -gt 0 ] && idx=$(( (idx - 1 + m) % m )) ;; down) [ "$m" -gt 0 ] && idx=$(( (idx + 1) % m )) ;; pgup) idx=$(( idx - rows )); [ "$idx" -lt 0 ] && idx=0 ;; pgdn) idx=$(( idx + rows )); [ "$idx" -ge "$m" ] && idx=$(( m > 0 ? m - 1 : 0 )) ;; home) idx=0 ;; end) idx=$(( m > 0 ? m - 1 : 0 )) ;; enter) [ "$m" -gt 0 ] && { UI_SEL=${match_idx[$idx]}; return 0; } ;; esc) UI_SEL=-1; return 1 ;; backspace) query="${query%?}"; idx=0; off=0 ;; char:*) query="$query${key#char:}"; idx=0; off=0 ;; esac done } preset_summary_line() { "$PY" "$JSON" summary "$1" 2>/dev/null | sed -n '2p'; } # These two draw a UI *and* produce a value. They must not be called inside # $( ) - command substitution captures stdout, so the whole interface would be # swallowed into the variable and the user would see nothing happen. They # publish their result in UI_PICKED instead. UI_PICKED='' ui_pick_preset() { local provider="$1" def names name UI_PICKED='' # resolve_preset, not default_preset_for: the mark should land on whatever # `claude-mode <provider>` would actually pick, fallbacks included. def="$(resolve_preset "$provider" 2>/dev/null)" names=() while IFS= read -r name; do names+=("$name"); done < <(presets_for_provider "$provider") [ "${#names[@]}" -gt 0 ] || { err "no presets for '$provider'"; return 1; } ui_reset_items local i=0 defidx=0 for name in "${names[@]}"; do local label="$name" [ "$name" = "$def" ] && { label="$name (default)"; defidx=$i; } ui_add_item "$label" "$(preset_summary_line "$(preset_path "$name")")" i=$((i + 1)) done ui_select "preset for $provider" '' || return 1 UI_PICKED="${names[$UI_SEL]}" } ui_pick_model() { local pf="$1" tier="$2" current="$3" provider base UI_PICKED='' provider="$(jget "$pf" provider)"; base="$(jget "$pf" baseUrl)" ui_reset_items ui_add_item '<type an id manually>' 'enter any model id by hand' local ids=() id ctx a b st kind kind="$(prov_field "$provider" 10)" while IFS=$'\t' read -r id a b st; do [ -n "$id" ] || continue ids+=("$id") case "$kind" in openrouter) ui_add_item "$id" "context $a \$$b in / \$$st out per 1M" ;; lmstudio) ui_add_item "$id" "state: $a max context: $b" ;; ollama) ui_add_item "$id" "$a $b" ;; *) ui_add_item "$id" "$a" ;; esac done < <(provider_catalogue "$provider" "$base" "$(cm_preset_token "$pf")") if [ "${#ids[@]}" -gt 0 ]; then ui_filter_select "model for '$tier'" "current: $current" || return 1 if [ "$UI_SEL" -gt 0 ]; then UI_PICKED="${ids[$((UI_SEL - 1))]}"; return 0; fi fi printf '\n %scurrent %s : %s%s\n' "$C_DIM" "$tier" "$current" "$C_RESET" printf ' new model id for %s (blank = cancel): ' "$tier" local val; IFS= read -r val [ -n "$val" ] || return 1 UI_PICKED="$val" } ui_edit_preset() { local name="${1:-}" if [ -z "$name" ]; then local names=() n while IFS= read -r n; do names+=("$n"); done < <(preset_names) [ "${#names[@]}" -gt 0 ] || { warn 'no presets'; return; } ui_reset_items for n in "${names[@]}"; do ui_add_item "$(printf '%-18s [%s]' "$n" "$(jget "$(preset_path "$n")" provider)")" \ "$(preset_summary_line "$(preset_path "$n")")" done ui_select 'edit which preset' '' || return name="${names[$UI_SEL]}" fi local pf; pf="$(preset_path "$name")" while true; do ui_reset_items local tiers=() t v while IFS=$'\t' read -r t v; do tiers+=("$t") ui_add_item "$(printf '%-9s %s' "$t" "$v")" "change which model backs the '$t' tier" done < <("$PY" "$JSON" models "$pf") ui_select "$name [$(jget "$pf" provider)]" 'esc = done' || return local tier="${tiers[$UI_SEL]}" local cur; cur="$(tsv_field "$("$PY" "$JSON" models "$pf" | tsv_find "$tier")" 2)" ui_pick_model "$pf" "$tier" "$cur" || continue local val="$UI_PICKED" [ -n "$val" ] || continue "$PY" "$JSON" set-tier "$pf" "$tier" "$val" && ok "$name : $tier -> $val" reapply_if_active "$name" done } ui_new_preset() { ui_reset_items local provs=($(provider_ids)) p for p in "${provs[@]}"; do ui_add_item "$p" "$(mode_label "$p")"; done ui_select 'new preset - which provider' '' || return local provider="${provs[$UI_SEL]}" local sibs=() s while IFS= read -r s; do sibs+=("$s"); done < <(presets_for_provider "$provider") ui_reset_items ui_add_item '<blank>' "empty $provider preset - pick every model yourself" for s in "${sibs[@]}"; do ui_add_item "copy of $s" "$(preset_summary_line "$(preset_path "$s")")"; done ui_select 'start from' '' || return local choice=$UI_SEL printf '\n %snew %s preset%s\n' "$C_CYAN" "$provider" "$C_RESET" printf ' name (letters, digits, dash; blank = cancel): ' local name; IFS= read -r name [ -n "$name" ] || return valid_preset_name "$name" || { err "invalid name '$name'"; return; } [ -f "$(preset_path "$name")" ] && { err "preset '$name' already exists"; return; } if [ "$choice" -eq 0 ]; then "$PY" "$JSON" scaffold "$provider" > "$(preset_path "$name")" else cp "$(preset_path "${sibs[$((choice - 1))]}")" "$(preset_path "$name")" fi ok "created preset '$name' ($provider)" ui_edit_preset "$name" } ui_more_menu() { while true; do ui_reset_items ui_add_item 'status' 'show the full active configuration' ui_add_item 'edit presets' 'pick models per tier from the provider catalogue' ui_add_item 'new preset' 'create a preset - blank or copied from an existing one' ui_add_item 'doctor' 'verify auth, endpoint, model ids, context window' ui_add_item 'back' 'return to the mode menu' ui_add_item 'quit' '' ui_select 'claude-mode - more' "currently: $(state_mode)" || return 0 case "$UI_SEL" in 0) cmd_status ;; 1) ui_edit_preset ;; 2) ui_new_preset ;; 3) cmd_doctor ;; 4) return 0 ;; 5) return 2 ;; esac done } ui_menu() { if ! ui_interactive; then cmd_status; return; fi local cur preset cur="$(state_mode)"; preset="$(state_preset)" [ "$cur" = "anthropic" ] && preset='' show_banner "$cur" "$preset" while true; do cur="$(state_mode)"; preset="$(state_preset)" local desc="$cur" [ "$cur" != "anthropic" ] && [ -n "$preset" ] && desc="$cur / $preset" local choices=() m for m in "${MODES[@]}"; do [ "$m" != "$cur" ] && choices+=("$m"); done ui_reset_items for m in "${choices[@]}"; do ui_add_item "switch to $m" "$(mode_label "$m")" "$(mode_color "$m")" done ui_add_item 'more ...' 'status, presets, doctor' ui_select 'claude-mode' "currently: $desc" || return if [ "$UI_SEL" -eq "${#choices[@]}" ]; then ui_more_menu; [ $? -eq 2 ] && return continue fi local mode="${choices[$UI_SEL]}" if [ "$mode" = "anthropic" ]; then set_mode anthropic; return; fi ui_pick_preset "$mode" || continue [ -n "$UI_PICKED" ] || continue set_mode "$mode" "$UI_PICKED" return done }