linux/claude-mode was 3,035 lines. It is now 177: the paths and flags, a loader, and the command dispatch. Everything else moved, verbatim, into twelve files under linux/lib/, one per concern - output, core, preflight, sessions, switch, catalogue, commands, doctor, presets, menu, setup, repair. The move was done by line range with a check that every original line landed in exactly one file; the only thing that changed place is the switch's running-sessions question, which now sits with session detection. The CLI finds lib/, cm-json.py and cm-vault.sh beside itself, following the ~/.local/bin symlink, so a checkout runs its own code rather than the installed version's (it used to mix the two). The key helper path written into settings.json is still the installed one. linux/install.sh ships bin/lib/, clearing old modules first so a removed one cannot linger. The package build copies linux/ recursively, which a flat copy would not. tests/cli/test_install.sh runs the real installer into a sandbox home: every file lands, the symlink runs, a reinstall keeps edited presets and drops a stale module. docs/architecture.md lists the modules. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
409 lines
15 KiB
Bash
409 lines
15 KiB
Bash
# 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 <title> <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
|
|
}
|