Run on macOS: bash 3.2, arrow keys, and honest session reporting

macOS ships bash 3.2, and the port used three things it does not have:
mapfile, fractional `read -t`, and GNU `stat -c`. The first two took the
interactive menu down - `mapfile: command not found` in the preset
picker, and an arrow key that parsed as a bare Escape, leaving `[C` in
the tty for zsh to report as a bad pattern.

read_key now reads the escape tail through the terminal itself: icanon
off with min 0 / time 1, so a plain read returns the moment a byte
arrives and gives up after ~0.1s. bash 3.2's `read -t 0` cannot be used
to poll for this - it reports nothing even with bytes buffered (checked
against 3.2.57). CSI sequences are consumed whole, so modified keys like
Ctrl+Right no longer leak their tail either. Verified interactively
under bash 3.2.57 in a pty: arrows, pgup/pgdn, home/end, enter,
backspace, and a bare Escape all parse, with no stray bytes in the tty.

Session listing reads /proc, which macOS does not have; it now says so
rather than silently reporting no running sessions - the answer that
gets people to switch out from under a live session. install.sh swaps
GNU `find -print -quit` for a glob, and the README notes both.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
smoido
2026-09-12 21:58:58 +03:00
co-authored by Claude Code
parent 69c7810345
commit 5a1af9f7e6
3 changed files with 107 additions and 15 deletions
+92 -12
View File
@@ -631,8 +631,15 @@ cm_self_session() {
}
# TSV: pid \t ppid \t tty \t busy \t cwd \t self \t parent-cmd
# Session discovery reads /proc, so it is Linux-only. Elsewhere the tool
# cannot see running sessions at all - which has to be said rather than
# silently reported as "none running", since that is the answer that gets
# people to switch out from under a live session.
cm_sessions_supported() { [ -d /proc/self ]; }
cm_session_rows() {
local self pid ppid tty cwd busy isself d
cm_sessions_supported || return 0
self="$(cm_self_session 2>/dev/null || true)"
local pids=() before=() after=() pp
@@ -700,6 +707,13 @@ cmd_sessions() {
return 0
fi
if ! cm_sessions_supported; then
head_ 'running sessions'
warn 'session control needs /proc, so it is Linux-only'
say 'on macOS, restart Claude Code yourself after a switch'
return 0
fi
if [ "${n:-0}" -eq 0 ]; then
head_ 'running sessions'
ok 'no Claude Code sessions running'
@@ -1481,21 +1495,74 @@ reapply_if_active() {
ui_interactive() { [ -t 0 ] && [ -t 1 ]; }
# Read one keypress, normalised to a word.
# 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 rest extra
local k
IFS= read -rsn1 k 2>/dev/null || return 1
if [ "$k" = $'\033' ]; then
IFS= read -rsn2 -t 0.05 rest 2>/dev/null || rest=''
case "$rest" in
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') IFS= read -rsn1 -t 0.05 extra 2>/dev/null; printf 'pgup' ;;
'[6') IFS= read -rsn1 -t 0.05 extra 2>/dev/null; printf 'pgdn' ;;
'[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
@@ -1649,7 +1716,8 @@ ui_pick_preset() {
local provider="$1" def names name
UI_PICKED=''
def="$(default_preset_for "$provider")"
mapfile -t names < <(presets_for_provider "$provider")
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
@@ -1707,10 +1775,10 @@ ui_pick_model() {
ui_edit_preset() {
local name="${1:-}"
if [ -z "$name" ]; then
local names; mapfile -t names < <(preset_names)
local names=() n
while IFS= read -r n; do names+=("$n"); done < <(preset_names)
[ "${#names[@]}" -gt 0 ] || { warn 'no presets'; return; }
ui_reset_items
local n
for n in "${names[@]}"; do
ui_add_item "$(printf '%-18s [%s]' "$n" "$(jget "$(preset_path "$n")" provider)")" \
"$(preset_summary_line "$(preset_path "$n")")"
@@ -1746,10 +1814,10 @@ ui_new_preset() {
ui_select 'new preset - which provider' '' || return
local provider="${provs[$UI_SEL]}"
local sibs; mapfile -t sibs < <(presets_for_provider "$provider")
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"
local s
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
@@ -2069,6 +2137,12 @@ cm_confirm_sessions() {
CM_SESSION_ACTION=none
CM_SESSION_ROWS=''
if ! cm_sessions_supported; then
printf '\n'
warn 'cannot list running sessions here (no /proc) - the switch will not wait for them'
return 0
fi
rows="$(cm_session_rows)"
n="$(printf '%s' "$rows" | grep -c . || true)"
[ "${n:-0}" -gt 0 ] || return 0
@@ -2143,6 +2217,12 @@ cm_project_dir() {
printf '%s/projects/%s' "$CM_SETTINGS_DIR" "$(printf '%s' "${1:-$PWD}" | sed 's|/|-|g')"
}
# Modification time as an epoch second. GNU stat spells it -c %Y, BSD/macOS
# stat spells it -f %m.
cm_file_mtime() {
stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0
}
cmd_repair_session() {
local target='' apply=0 reinject=1 scan_all=0 as_json=0 dir='' a file verdict age
for a in "$@"; do
@@ -2239,7 +2319,7 @@ print(' %-40s %s' % (os.path.basename(d['path'])[:-6], state))
# A transcript that is still being appended to belongs to a session that is
# still alive; truncating it underneath a running process helps nobody.
age=$(( $(date +%s) - $(stat -c %Y "$file" 2>/dev/null || echo 0) ))
age=$(( $(date +%s) - $(cm_file_mtime "$file") ))
if [ "$age" -lt 90 ] && [ "$apply" -eq 1 ]; then
err "that transcript was written to ${age}s ago - it looks live"
say 'close the session that owns it first'
+6 -3
View File
@@ -37,9 +37,12 @@ if [ ! -f "$SRC/claude-mode" ] || [ ! -f "$SRC/cm-json.py" ]; then
TMP="$(mktemp -d "${TMPDIR:-/tmp}/claude-mode-src.XXXXXX")"
printf 'no local checkout found - fetching source from %s\n' "$REPO_URL"
curl -fsSL "$REPO_URL/archive/master.tar.gz" | tar -xz -C "$TMP"
EXTRACTED="$(find "$TMP" -type f -name claude-mode -print -quit)"
[ -n "$EXTRACTED" ] || { fail 'archive did not contain linux/claude-mode - repo layout changed?'; exit 1; }
SRC="$(dirname "$EXTRACTED")"
# a plain glob rather than find -print -quit: BSD find (macOS) has no -quit
SRC=''
for _c in "$TMP"/*/linux/claude-mode "$TMP"/linux/claude-mode; do
[ -f "$_c" ] && { SRC="$(dirname "$_c")"; break; }
done
[ -n "$SRC" ] || { fail 'archive did not contain linux/claude-mode - repo layout changed?'; exit 1; }
fi
printf '\ninstalling claude-mode -> %s\n' "$ROOT"