Skip to content
Merged
5 changes: 5 additions & 0 deletions PROJECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -378,3 +378,8 @@ _Last updated: 2026-08-24 (review-triage session)_
- **Decisions parked for PM** (also listed in projects/DECISIONS.md Open): #55 v1/v2 ADR, #54 wide-char strategy, #57 hover scope.
- **Review verdict worth keeping:** the `$()`/exit-code/fd-3 contract, Docker 3.2/4.4/5.x matrix, and load-bearing gotcha comments are the differentiators — don't trade them away while hardening. fd-3 fix (#48) must keep the persistent-fd design, only make the number configurable.
- **Next**: worker picks up #41 → #42 → #43 → #44 in order (`claude --cwd ~/lib/fissible/shellframe "Work on issue #41"`), then #55 ADR before touching #46/#53 so consolidations go the right direction. Cut v0.6.0 when M5 closes.
- **Launch-blocker fixes session (2026-08-24)**: All four Phase 8 launch blockers closed — [#41](https://github.com/fissible/shellframe/issues/41) (app-runtime `declare -F` guards + NEXT assertion, exit 1 with diagnostic), [#42](https://github.com/fissible/shellframe/issues/42) (all 40 `%b` specifiers across confirm/alert/table/action-list converted to `%s`; behavior-neutral for `$'..'` byte constants, immune to literal-backslash color overrides), [#43](https://github.com/fissible/shellframe/issues/43) (INT→130/TERM→143 split traps; cleanup runs exactly once), [#44](https://github.com/fissible/shellframe/issues/44) (EOF discriminator in `_shellframe_shell_read_key`: rc≤128+empty = EOF → clean quit, shellframe_shell returns 1; rc>128 = timeout tick). Suite: **1527/1527 across 49 files**, stable ×3.
- **Cross-repo (fissible/ptyunit), commit `96d6f04` on branch `hardening/review-2026-08`**: #43's PTY test only failed under the suite runner. Root cause: bash sets SIGINT/SIGQUIT ignored in async worker jobs; the ignore inherits fork+exec, and POSIX forbids trapping an entry-ignored signal — guest TUIs could never see Ctrl-C under any async runner. Fix: pty_run.py/pty_session.py restore SIG_DFL in the forked child pre-exec; plus all waitpids routed through bounded exception-safe `_reap()` (early-exit children can no longer lose status or wedge blocking waits). ptyunit suite 761/761.
- **PM/consumer flag**: ptyunit fix is on a branch, not merged to main — needs merge + submodule/reference bump in shellframe, shellql, seed (shellframe CI uses the sibling checkout so it picks it up automatically; brew-installed copy is stale until formula bump).
- **Gotcha worth keeping**: pty_run buffers all output until child exit — a hung child looks like "zero output" and misled two debug rounds; use xtrace-to-side-file for liveness, not captured stdout.
- **Next**: #55 ADR (v1/v2 sunset plan) before touching #46/#53; then M-severity batch (#45–#47). Cut v0.6.0 when #45 closes.
18 changes: 17 additions & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,22 @@ Call inside a `shellframe_raw_enter` session. Compare results against the
Uses `read -d ''` (NUL delimiter) so Enter (`\n`) is captured rather
than consumed as the line terminator (see [Hard-won lessons](hard-won-lessons.md#7-bash-read-converts-r-to-n-internally--use-read--d--for-enter)).

Sets `SHELLFRAME_KEY_EOF=1` when stdin has reached EOF; widget loops must
check it and exit cancelled — looping on an EOF read spins at 100% CPU (#44).

**Exit/return-code contracts**

| Call | Returns |
|---|---|
| `shellframe_confirm` | 0 = yes, 1 = no/cancelled (incl. stdin EOF) |
| `shellframe_action_list`, `shellframe_table` | 0 = confirmed, 1 = quit (incl. stdin EOF) |
| `shellframe_shell` | 0 = user exited normally, 1 = stdin EOF (runtime lost its input source) |
| `shellframe_app` | 1 + stderr diagnostic on misconfiguration (unknown screen, missing render/handler, handler that never sets `_SHELLFRAME_APP_NEXT`) |

While a runtime is active it owns EXIT/INT/TERM trap handling (terminal
state must be restored even on crashes); the caller's own traps are saved at
entry and restored verbatim on normal return.

---

## `src/draw.sh`
Expand All @@ -149,7 +165,7 @@ so its `${#raw}` byte count equals its visible character count.
```bash
local raw="~/bin/gflow"
local rendered="${SHELLFRAME_GRAY}~/bin/${SHELLFRAME_RESET}${SHELLFRAME_BOLD}gflow${SHELLFRAME_RESET}"
printf '%b' "$(shellframe_pad_left "$raw" "$rendered" 20)"
printf '%s' "$(shellframe_pad_left "$raw" "$rendered" 20)"
```

Color constants `SHELLFRAME_BOLD`, `SHELLFRAME_RESET`, `SHELLFRAME_GREEN`, `SHELLFRAME_RED`,
Expand Down
2 changes: 1 addition & 1 deletion docs/hard-won-lessons.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ and use its `${#raw}` length to compute padding manually.
printf "%-20b" "${SHELLFRAME_GREEN}hello${SHELLFRAME_RESET}"

# ✓ — measure raw, output rendered + explicit padding
printf '%b' "$(shellframe_pad_left "hello" "${SHELLFRAME_GREEN}hello${SHELLFRAME_RESET}" 20)"
printf '%s' "$(shellframe_pad_left "hello" "${SHELLFRAME_GREEN}hello${SHELLFRAME_RESET}" 20)"
```

---
Expand Down
2 changes: 1 addition & 1 deletion docs/showcase.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ _draw_row() {
(( i == SHELLFRAME_AL_SELECTED )) && cursor="> "
local -a acts; IFS=' ' read -r -a acts <<< "$acts_str"
local action="${acts[$aidx]}"
printf "%b%-14s [ %-8s]\n" "$cursor" "$label" "$action"
printf "%s%-14s [ %-8s]\n" "$cursor" "$label" "$action"
}

shellframe_action_list "_draw_row" "" \
Expand Down
36 changes: 34 additions & 2 deletions src/app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,14 @@ _SHELLFRAME_APP_QUESTION=""
_SHELLFRAME_APP_TITLE=""
_SHELLFRAME_APP_DETAILS=()

# Map widget return code → event name string
# Configuration-error diagnostic. Callers print this then `return 1` from
# shellframe_app — deliberately NOT exit: app.sh is a sourced library and
# must never kill the host script (#41 review).
_shellframe_app_die() {
printf 'shellframe: %s\n' "$1" >&2
}

# Map widget return code → event name string
_shellframe_app_event() {
local _type="$1" _rc="$2"
Expand Down Expand Up @@ -112,7 +120,18 @@ shellframe_app() {

# Get screen type (pure — subshell OK), run render hook (direct — can mutate globals)
local _type
if ! declare -F "${_prefix}_${_current}_type" >/dev/null 2>&1; then
_shellframe_app_die "unknown screen '${_current}' (prefix '${_prefix}') — missing ${_prefix}_${_current}_type()"
return 1
fi
_type=$("${_prefix}_${_current}_type")
# Render hook: same existence discipline (#41 review) — a typo'd
# render name would otherwise run the widget against stale context
# globals and silently succeed.
if ! declare -F "${_prefix}_${_current}_render" >/dev/null 2>&1; then
_shellframe_app_die "unknown screen '${_current}' (prefix '${_prefix}') — missing ${_prefix}_${_current}_render()"
return 1
fi
"${_prefix}_${_current}_render"

# Run the widget for this screen type
Expand Down Expand Up @@ -148,14 +167,27 @@ shellframe_app() {
fi
_rc=$?
;;
*)
_shellframe_app_die "screen '${_current}': widget type '${_type}' is not action-list|table|confirm|alert"
return 1
;;
esac

# Map rc → event name, call event handler directly (not in $() — safe to
# mutate globals). Handler must set _SHELLFRAME_APP_NEXT to the next screen name.
local _event
local _event _event_fn
_event=$(_shellframe_app_event "$_type" "$_rc")
_event_fn="${_prefix}_${_current}_${_event}"
if ! declare -F "$_event_fn" >/dev/null 2>&1; then
_shellframe_app_die "screen '${_current}': missing ${_event_fn}() handler for widget type '${_type}'"
return 1
fi
_SHELLFRAME_APP_NEXT=""
"${_prefix}_${_current}_${_event}"
"$_event_fn"
if [[ -z "${_SHELLFRAME_APP_NEXT:-}" ]]; then
_shellframe_app_die "screen '${_current}': ${_event_fn}() did not set _SHELLFRAME_APP_NEXT"
return 1
fi
_current="$_SHELLFRAME_APP_NEXT"
done
}
4 changes: 2 additions & 2 deletions src/draw.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@
# Usage:
# local raw="~/bin/gflow"
# local rendered="${GRAY}~/bin/${RESET}${BOLD}gflow${RESET}"
# printf '%b' "$(shellframe_pad_left "$raw" "$rendered" 20)"
# printf '%s' "$(shellframe_pad_left "$raw" "$rendered" 20)"
shellframe_pad_left() {
local raw="$1" rendered="$2" width="$3"
local pad=$(( width - ${#raw} ))
(( pad < 0 )) && pad=0
printf '%b%*s' "$rendered" "$pad" ''
printf '%s%*s' "$rendered" "$pad" ''
}

# ── Color constants ───────────────────────────────────────────────────────────
Expand Down
13 changes: 12 additions & 1 deletion src/input.sh
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ SHELLFRAME_MOUSE_SHIFT=0
SHELLFRAME_MOUSE_META=0
SHELLFRAME_MOUSE_CTRL=0

# Output variable set by shellframe_read_key when stdin has reached EOF
# (#44). 1 = EOF; reset to 0 at the start of every call. The initial read is
# untimed, so EOF (rc=1, empty value) is unambiguous on all bash versions.
# Widget loops must check this flag and exit cancelled instead of spinning.
SHELLFRAME_KEY_EOF=0

# Read one keypress (including full escape sequences) into a variable.
#
# Usage:
Expand All @@ -136,7 +142,12 @@ SHELLFRAME_MOUSE_CTRL=0
shellframe_read_key() {
local _out_var="${1:-_SHELLFRAME_KEY}"
local _k _c
IFS= read -r -n1 -d '' _k
SHELLFRAME_KEY_EOF=0
IFS= read -r -n1 -d '' _k || { [[ -z "$_k" ]] && SHELLFRAME_KEY_EOF=1; }
if (( SHELLFRAME_KEY_EOF )); then
printf -v "$_out_var" '%s' ""
return 0
fi
if [[ "$_k" == $'\x1b' ]]; then
IFS= read -r -n1 -d '' -t 1 _c
_k+="${_c}"
Expand Down
65 changes: 59 additions & 6 deletions src/shell.sh
Original file line number Diff line number Diff line change
Expand Up @@ -348,13 +348,33 @@ _shellframe_shell_draw_if_dirty() {

_shellframe_shell_read_key() {
local _out_var="${1:-_SHELLFRAME_KEY}"
local _k="" _c=""

# Timeout: 1 second. If SIGWINCH fires, bash 3.2 on macOS will NOT
# interrupt read, but the 1s timeout ensures we re-check the flag.
IFS= read -r -n1 -d '' -t 1 _k || true
local _k="" _c="" _rc=0
_SHELLFRAME_KEY_EOF=0

# Timeout handling — VERSION-DEPENDENT semantics (#44):
# bash >= 4.0: timer expiry returns >128; stdin EOF returns <=128.
# The return code alone discriminates reliably.
# bash 3.2: BOTH return 1 (verified against /bin/bash 3.2). The
# discriminator is TIME: EOF returns instantly while a
# timer expiry consumes the full window. So on 3.2 the
# window is widened to 2 s and measured with date +%s —
# an instant failure (delta 0) is EOF; anything that
# consumed seconds is a timeout/SIGWINCH tick. A false
# EOF here would quit the runtime during ordinary idling.
if (( BASH_VERSINFO[0] >= 4 )); then
_sf_read_t=1
else
_sf_read_t=2
_sf_t0=$(date +%s)
fi
IFS= read -r -n1 -d '' -t "$_sf_read_t" _k || _rc=$?

if [[ -z "$_k" ]]; then
if (( BASH_VERSINFO[0] >= 4 )); then
(( _rc > 0 && _rc <= 128 )) && _SHELLFRAME_KEY_EOF=1
elif (( _rc > 0 )) && (( $(date +%s) - _sf_t0 < 2 )); then
_SHELLFRAME_KEY_EOF=1 # failed instantly — stdin reached EOF
fi
printf -v "$_out_var" '%s' ""
return 0
fi
Expand Down Expand Up @@ -406,6 +426,7 @@ shellframe_shell() {
local _prefix="$1"
local _current="${2:-ROOT}"
_SHELLFRAME_SHELL_RUNNING=1
_SHELLFRAME_SHELL_EOF=0

local _saved_stty
_saved_stty=$(shellframe_raw_save)
Expand Down Expand Up @@ -440,7 +461,24 @@ shellframe_shell() {
} >> /tmp/shql-crash.log 2>/dev/null
fi
}
trap "_shellframe_shell_cleanup '$_saved_stty'" EXIT INT TERM
# Preserve any caller-installed EXIT/INT/TERM traps (review 2026-08-24):
# shellframe is sourced by other tools, and unconditionally leaving our
# traps installed after the runtime returns would silently drop the
# host script's own teardown. Contract: while the runtime is active it
# owns these signals (its cleanup must win); on normal return the
# caller's traps are restored verbatim below. Caller traps do NOT fire
# for signals that terminate the runtime abnormally mid-session.
_SF_PREV_EXIT_TRAP=$(trap -p EXIT)
_SF_PREV_INT_TRAP=$(trap -p INT)
_SF_PREV_TERM_TRAP=$(trap -p TERM)

# EXIT alone runs cleanup on normal termination and on any error unwind.
# INT/TERM additionally restore-and-exit with the conventional signal exit
# codes (#43): traps are cleared first so the pending EXIT trap does not
# double-run cleanup.
trap "_shellframe_shell_cleanup '$_saved_stty'" EXIT
trap "trap - EXIT INT TERM WINCH; _shellframe_shell_cleanup '$_saved_stty'; exit 130" INT
trap "trap - EXIT INT TERM WINCH; _shellframe_shell_cleanup '$_saved_stty'; exit 143" TERM

local _k_tab="${SHELLFRAME_KEY_TAB:-$'\t'}"
local _k_shift_tab="${SHELLFRAME_KEY_SHIFT_TAB:-$'\033[Z'}"
Expand Down Expand Up @@ -476,6 +514,12 @@ shellframe_shell() {

local _key=""
_shellframe_shell_read_key _key
# Stdin EOF (tty detached, stdin redirected closed): quit the
# runtime cleanly with a non-zero status instead of spinning (#44).
if (( ${_SHELLFRAME_KEY_EOF:-0} )); then
_SHELLFRAME_SHELL_EOF=1
_current="__QUIT__"; _screen_done=1; continue
fi
if [[ -z "$_key" ]]; then
# Timeout: tick toasts so they expire even when user is idle
if (( ${#_SHELLFRAME_TOAST_QUEUE[@]} > 0 )); then
Expand Down Expand Up @@ -660,4 +704,13 @@ shellframe_shell() {
elif [[ -w /dev/tty ]]; then
printf '\033[?2004l\033[?1006l\033[?1000l\033[?25h\033[?1049l' >/dev/tty 2>/dev/null
fi
# Hand the caller's own EXIT/INT/TERM traps back (saved at entry) so host
# scripts keep their teardown after the runtime returns.
[[ -n "$_SF_PREV_EXIT_TRAP" ]] && eval "$_SF_PREV_EXIT_TRAP"
[[ -n "$_SF_PREV_INT_TRAP" ]] && eval "$_SF_PREV_INT_TRAP"
[[ -n "$_SF_PREV_TERM_TRAP" ]] && eval "$_SF_PREV_TERM_TRAP"
# Stdin EOF quit (#44): report non-zero so callers can distinguish a
# clean user exit from the runtime losing its input source.
(( ${_SHELLFRAME_SHELL_EOF:-0} )) && return 1
return 0
}
7 changes: 6 additions & 1 deletion src/widgets/action-list.sh
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ _shellframe_al_default_draw_row() {
local -a _dacts
IFS=' ' read -r -a _dacts < <(printf '%s\n' "$_dacts_str")
local _daction="${_dacts[$_daidx]}"
printf "%b%-24s [%s]\n" "$_dcursor" "$_dlabel" "$_daction"
printf "%s%-24s [%s]\n" "$_dcursor" "$_dlabel" "$_daction"
}

# _shellframe_action_list_on_key key n_items
Expand Down Expand Up @@ -189,6 +189,11 @@ shellframe_action_list() {
_prev_sel=$SHELLFRAME_AL_SELECTED
shellframe_read_key _key

# stdin EOF: quit cancelled instead of spinning (#44)
if (( ${SHELLFRAME_KEY_EOF:-0} )); then
_al_retval=1; break
fi

_shellframe_action_list_on_key "$_key" "$_n"
_krc=$?

Expand Down
20 changes: 10 additions & 10 deletions src/widgets/alert.sh
Original file line number Diff line number Diff line change
Expand Up @@ -54,20 +54,20 @@ _shellframe_alert_render() {
local _i

# top border
printf '\033[%d;%dH%b+' "$_row" "$_c0" "$SHELLFRAME_GRAY" >&3
printf '\033[%d;%dH%s+' "$_row" "$_c0" "$SHELLFRAME_GRAY" >&3
for (( _i=0; _i<_inner; _i++ )); do printf '-' >&3; done
printf '+%b' "$SHELLFRAME_RESET" >&3
printf '+%s' "$SHELLFRAME_RESET" >&3
(( _row++ ))

# blank
printf '\033[%d;%dH%b|%*s|%b' "$_row" "$_c0" "$SHELLFRAME_GRAY" "$_inner" "" "$SHELLFRAME_RESET" >&3
printf '\033[%d;%dH%s|%*s|%s' "$_row" "$_c0" "$SHELLFRAME_GRAY" "$_inner" "" "$SHELLFRAME_RESET" >&3
(( _row++ ))

# title (centered, bold)
local _tl="${#_title}"
local _tlpad=$(( (_inner - _tl) / 2 ))
local _trpad=$(( _inner - _tl - _tlpad ))
printf '\033[%d;%dH%b|%b%*s%b%s%b%*s%b|%b' \
printf '\033[%d;%dH%s|%s%*s%s%s%s%*s%s|%s' \
"$_row" "$_c0" \
"$SHELLFRAME_GRAY" "$SHELLFRAME_RESET" \
"$_tlpad" "" \
Expand All @@ -77,7 +77,7 @@ _shellframe_alert_render() {
(( _row++ ))

# blank
printf '\033[%d;%dH%b|%*s|%b' "$_row" "$_c0" "$SHELLFRAME_GRAY" "$_inner" "" "$SHELLFRAME_RESET" >&3
printf '\033[%d;%dH%s|%*s|%s' "$_row" "$_c0" "$SHELLFRAME_GRAY" "$_inner" "" "$SHELLFRAME_RESET" >&3
(( _row++ ))

# detail lines
Expand All @@ -86,28 +86,28 @@ _shellframe_alert_render() {
local _ll="${#_line}"
local _rpad=$(( _inner - _ll - 2 ))
(( _rpad < 0 )) && _rpad=0
printf '\033[%d;%dH%b|%b %s%*s%b|%b' \
printf '\033[%d;%dH%s|%s %s%*s%s|%s' \
"$_row" "$_c0" \
"$SHELLFRAME_GRAY" "$SHELLFRAME_RESET" \
"$_line" "$_rpad" "" \
"$SHELLFRAME_GRAY" "$SHELLFRAME_RESET" >&3
(( _row++ ))
done
printf '\033[%d;%dH%b|%*s|%b' "$_row" "$_c0" "$SHELLFRAME_GRAY" "$_inner" "" "$SHELLFRAME_RESET" >&3
printf '\033[%d;%dH%s|%*s|%s' "$_row" "$_c0" "$SHELLFRAME_GRAY" "$_inner" "" "$SHELLFRAME_RESET" >&3
(( _row++ ))
fi

# bottom border
printf '\033[%d;%dH%b+' "$_row" "$_c0" "$SHELLFRAME_GRAY" >&3
printf '\033[%d;%dH%s+' "$_row" "$_c0" "$SHELLFRAME_GRAY" >&3
for (( _i=0; _i<_inner; _i++ )); do printf '-' >&3; done
printf '+%b' "$SHELLFRAME_RESET" >&3
printf '+%s' "$SHELLFRAME_RESET" >&3
(( _row++ ))

# footer hint
local _hint="Any key to continue"
local _hcol=$(( _c0 + (_box_w - ${#_hint}) / 2 ))
(( _hcol < 1 )) && _hcol=1
printf '\033[%d;%dH%b%s%b' "$_row" "$_hcol" "$SHELLFRAME_GRAY" "$_hint" "$SHELLFRAME_RESET" >&3
printf '\033[%d;%dH%s%s%s' "$_row" "$_hcol" "$SHELLFRAME_GRAY" "$_hint" "$SHELLFRAME_RESET" >&3
}

shellframe_alert() {
Expand Down
Loading