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:
@@ -82,6 +82,15 @@ Secrets have no DPAPI equivalent here, so the vault picks the best backend
|
||||
available and says which one it chose: macOS Keychain, libsecret, `pass`, or a
|
||||
0600 file that is honestly labelled as unencrypted.
|
||||
|
||||
It runs on the **bash 3.2 that macOS ships** — no Homebrew bash needed. The
|
||||
menu reads arrow keys through the terminal's own timing (`min 0 time 1`) rather
|
||||
than a fractional `read` timeout, which 3.2 rejects, and the port avoids
|
||||
`mapfile` and GNU-only `stat`/`find` flags for the same reason.
|
||||
|
||||
Session listing reads `/proc`, so it is **Linux-only**. On macOS a switch says
|
||||
it cannot check for running sessions instead of claiming there are none — so
|
||||
restart Claude Code yourself after switching.
|
||||
|
||||
### Omarchy bar widget
|
||||
|
||||
```bash
|
||||
|
||||
+92
-12
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user