diff --git a/.chezmoiscripts/run_after_25-cursor-statusline.sh b/.chezmoiscripts/run_after_25-cursor-statusline.sh new file mode 100755 index 0000000..28b49c4 --- /dev/null +++ b/.chezmoiscripts/run_after_25-cursor-statusline.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Ensure Cursor CLI statusLine points at the shared ai-statusline script. +# Does not rewrite other cli-config.json fields (model/auth/state stay intact). + +set -euo pipefail + +cfg="${HOME}/.cursor/cli-config.json" +script="${HOME}/.local/bin/ai-statusline" + +if [ ! -f "$cfg" ] || [ ! -x "$script" ]; then + exit 0 +fi + +if ! command -v jq >/dev/null 2>&1; then + exit 0 +fi + +current="$(jq -r '.statusLine.command // empty' "$cfg" 2>/dev/null || true)" +# Compare literal "~/.local/bin/..." in case an older config stored a tilde path. +# shellcheck disable=SC2088 +if [ "$current" = "$script" ] || [ "$current" = "~/.local/bin/ai-statusline" ]; then + exit 0 +fi + +# Merging a statusLine is optional, so a config this script cannot parse must +# not take `chezmoi apply` down with it. Bail out and leave the file alone. +if ! jq -e 'type == "object"' "$cfg" >/dev/null 2>&1; then + exit 0 +fi + +tmp="$(mktemp)" +if jq --arg cmd "$script" ' + .statusLine = { + "type": "command", + "command": $cmd, + "padding": 2 + } +' "$cfg" >"$tmp" 2>/dev/null; then + mv "$tmp" "$cfg" +else + rm -f "$tmp" +fi diff --git a/README.md b/README.md index bf93f15..fd68be8 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,13 @@ This is defined in `dot_bash_profile.tmpl` and `dot_zshrc.tmpl`. - `AI_FILE_LINES` (default `300`) - `AI_DIFF_LINES` (default `300`) +## Agent CLI context statusline + +Cursor CLI and Claude Code share `~/.local/bin/ai-statusline` for a yellow/red +context meter (200K-class: 65%/85%; 1M-class: 20%/65%). Claude also gets a +`Stop` hook nudge. Thresholds, source paths, overrides, and smoke tests: +**`docs/ai-cli-context-statusline.md`**. + ## Neovim version and install notes This Neovim config tracks newer APIs and plugins, and may not work with distro diff --git a/docs/agent-live-systems.md b/docs/agent-live-systems.md index abe2a1d..ed748b3 100644 --- a/docs/agent-live-systems.md +++ b/docs/agent-live-systems.md @@ -127,6 +127,13 @@ Tracked defaults **do not** rebind Ctrl+V; text paste stays on Ctrl+V. Agent clipboard images use tmux `prefix + p` (`paste-image-agent.sh` in `dot_tmux.conf.tmpl`), not WT keybindings. +## Agent CLI statusline / hooks + +Context meter and Claude `Stop` latch: +[`docs/ai-cli-context-statusline.md`](ai-cli-context-statusline.md). After +apply, **restart** Cursor CLI / Claude Code so `statusLine` and hooks reload; do +not treat a live session as validation of the new config. + ## Checklist before closing a dotfiles task - [ ] Rendered config parses (`tmux -f …`, `bash -n`, JSON valid for WT) diff --git a/docs/ai-cli-context-statusline.md b/docs/ai-cli-context-statusline.md new file mode 100644 index 0000000..c720714 --- /dev/null +++ b/docs/ai-cli-context-statusline.md @@ -0,0 +1,96 @@ +# Agent CLI context statusline + +Shared context-usage meter for **Cursor CLI** (`cursor-agent` / `agent`) and +**Claude Code**. Warns at yellow/red thresholds so you can compact deliberately +instead of waiting for auto-summarize / auto-compact. + +## Behavior + +Both CLIs run `~/.local/bin/ai-statusline` as a `statusLine` command. On each UI +update it reads session JSON from stdin and prints a colored bar: + +| `context_window_size` | Yellow | Red | Hint command | +| ---------------------- | ------ | --- | --------------------- | +| < 500k (200K-class) | 65% | 85% | `/summarize` (Cursor) | +| ≥ 500k (1M-class) | 20% | 65% | `/compact` (Claude) | + +Agent detection: Claude payloads include `cost`, `rate_limits`, and/or +`exceeds_200k_tokens`; otherwise Cursor is assumed. + +On an **upward** level change (green→yellow, yellow→red) the statusline rings +BEL once per level per session. Usage dropping back below yellow clears the +latch. + +Session breadcrumbs live under +`${XDG_CACHE_HOME:-~/.cache}/ai-context-alerts/.json`. + +### Claude Code Stop hook + +`~/.claude/hooks/context-threshold-stop.sh` runs on `Stop`. When the breadcrumb +shows yellow/red and that level has not yet been stop-notified, it returns: + +- `systemMessage` — user-visible nudge in the Claude UI +- `terminalSequence` — OSC 777 desktop notify + BEL + +Cursor CLI cannot mirror this today: interactive `cursor-agent` only fires shell +hooks, and IDE `stop` only supports `followup_message` (auto-submits a prompt), +not a passive user alert. Cursor relies on the statusline + BEL. + +## Source → applied + +| Source | Target | +| ------------------------------------------------------- | ----------------------------------------------------------- | +| `dot_local/bin/executable_ai-statusline` | `~/.local/bin/ai-statusline` | +| `dot_local/bin/lib/ai-context-thresholds.sh` | `~/.local/bin/lib/ai-context-thresholds.sh` | +| `dot_claude/modify_settings.json.tmpl` | merges `statusLine` + `Stop` into `~/.claude/settings.json` | +| `dot_claude/hooks/executable_context-threshold-stop.sh` | `~/.claude/hooks/context-threshold-stop.sh` | +| `.chezmoiscripts/run_after_25-cursor-statusline.sh` | merges `statusLine` into `~/.cursor/cli-config.json` | + +Neither config is fully managed. `cli-config.json` keeps model/auth/state local +and the run-after only sets the `statusLine` object. `settings.json` is a +chezmoi `modify_` script: it receives the current file on stdin and rewrites +only `statusLine` and this repo's `Stop` hook, so `theme`, `permissions`, MCP +servers, and any other hooks you add locally are preserved. Because it is a +managed target, `chezmoi diff ~/.claude/settings.json` still previews changes. + +The `Stop` entry is matched by command, so repeated applies rewrite it in place +instead of stacking duplicates. If `jq` is missing, or the file is not valid +JSON, the script passes the content through untouched rather than replacing it. + +## Overrides + +```bash +export AI_CTX_YELLOW=50 +export AI_CTX_RED=80 +``` + +Both must be set, and both must be plain integers, to override the window-size +defaults. A missing or non-integer value falls back to the window defaults. + +## Apply and reload + +```bash +chezmoi apply ~/.local/bin/ai-statusline \ + ~/.local/bin/lib/ai-context-thresholds.sh \ + ~/.claude/settings.json \ + ~/.claude/hooks/context-threshold-stop.sh +# run_after_25 merges Cursor statusLine on full apply; or run it once: +bash ~/.local/share/chezmoi/.chezmoiscripts/run_after_25-cursor-statusline.sh +``` + +Restart the Cursor CLI / Claude Code session (or start a new one) so +`statusLine` / hooks reload. See `docs/agent-live-systems.md` for general +apply/reload hygiene. + +## Smoke test + +```bash +echo '{"session_id":"probe","model":{"display_name":"Test"},"context_window":{"used_percentage":70,"context_window_size":200000}}' \ + | ~/.local/bin/ai-statusline + +echo '{"session_id":"probe","model":{"display_name":"Opus"},"context_window":{"used_percentage":25,"context_window_size":1000000},"cost":{}}' \ + | ~/.local/bin/ai-statusline +``` + +Expect yellow hints at those percentages, with `/summarize` vs `/compact` +respectively. Clean up with `rm -f ~/.cache/ai-context-alerts/probe.json`. diff --git a/dot_claude/hooks/executable_context-threshold-stop.sh b/dot_claude/hooks/executable_context-threshold-stop.sh new file mode 100755 index 0000000..cd50ac7 --- /dev/null +++ b/dot_claude/hooks/executable_context-threshold-stop.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Claude Code Stop hook: user-visible nudge when context crosses yellow/red. +# +# Depends on ~/.local/bin/ai-statusline writing a per-session breadcrumb under +# $XDG_CACHE_HOME/ai-context-alerts/. Emits systemMessage + terminalSequence +# once per level per session (resets when usage drops back to green). + +set -euo pipefail + +helper="${HOME}/.local/bin/lib/ai-context-thresholds.sh" +if [ ! -f "$helper" ]; then + exit 0 +fi +# shellcheck source=/dev/null +. "$helper" + +input="$(cat)" +session_id="$(printf '%s' "$input" | jq -r '.session_id // "unknown"')" +state_path="$(ai_context_session_state_path "$session_id")" + +if [ ! -f "$state_path" ]; then + exit 0 +fi + +pct="$(ai_context_state_get "$state_path" pct 0)" +level="$(ai_context_state_get "$state_path" level green)" +yellow="$(ai_context_state_get "$state_path" yellow 65)" +red="$(ai_context_state_get "$state_path" red 85)" +window="$(ai_context_state_get "$state_path" window 200000)" +compact_cmd="$(ai_context_state_get "$state_path" compact_cmd /compact)" +stop_alerted="$(ai_context_state_get "$state_path" stop_alerted green)" + +case "$pct" in + '' | *[!0-9]*) pct=0 ;; +esac + +if [ "$level" = "green" ]; then + exit 0 +fi + +prev_rank="$(ai_context_level_rank "$stop_alerted")" +cur_rank="$(ai_context_level_rank "$level")" +if [ "$cur_rank" -le "$prev_rank" ]; then + exit 0 +fi + +case "$level" in + red) + msg="Context at ${pct}% (red ≥${red}% for ${window}-token window). Run ${compact_cmd} soon — auto-compact may be imminent." + title="Claude Code · context red" + ;; + *) + msg="Context at ${pct}% (yellow ≥${yellow}% for ${window}-token window). Good spot to run ${compact_cmd} before continuing." + title="Claude Code · context yellow" + ;; +esac + +# OSC 777 desktop notify + BEL (allowlisted via Claude terminalSequence). +seq="$(printf '\033]777;notify;%s;%s\007\a' "$title" "$msg")" + +jq -nc --arg msg "$msg" --arg seq "$seq" \ + '{systemMessage:$msg, terminalSequence:$seq}' + +ai_context_state_merge "$state_path" "$(jq -nc --arg level "$level" '{stop_alerted:$level}')" diff --git a/dot_claude/modify_settings.json.tmpl b/dot_claude/modify_settings.json.tmpl new file mode 100755 index 0000000..8bb4cc2 --- /dev/null +++ b/dot_claude/modify_settings.json.tmpl @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Merge this repo's statusLine + Stop hook into ~/.claude/settings.json. +# +# chezmoi hands the current target content on stdin and uses stdout as the new +# content. Only the two keys below are owned here; theme, permissions, MCP +# servers, and any other hooks the operator adds locally must survive. + +set -euo pipefail + +statusline="{{ .chezmoi.homeDir }}/.local/bin/ai-statusline" +stop_hook="{{ .chezmoi.homeDir }}/.claude/hooks/context-threshold-stop.sh" + +current="$(cat)" +[ -n "${current//[[:space:]]/}" ] || current='{}' + +# Without jq, or against a file that is not a JSON object, pass the content +# through untouched. Rewriting here would be the clobber this script exists to +# prevent. +if ! command -v jq >/dev/null 2>&1; then + printf '%s' "$current" + exit 0 +fi +if ! printf '%s' "$current" | jq -e 'type == "object"' >/dev/null 2>&1; then + printf '%s' "$current" + exit 0 +fi + +# Stop entries are matched by command so re-applying rewrites in place rather +# than stacking a duplicate hook every time. +printf '%s' "$current" | jq \ + --arg statusline "$statusline" \ + --arg stop_hook "$stop_hook" ' + .statusLine = {type: "command", command: $statusline, padding: 2} + | .hooks //= {} + | .hooks.Stop = ( + ((.hooks.Stop // []) | map(select( + ([.hooks[]?.command] | index($stop_hook)) == null + ))) + + [{hooks: [{type: "command", command: $stop_hook}]}] + ) + ' diff --git a/dot_local/bin/executable_ai-statusline b/dot_local/bin/executable_ai-statusline new file mode 100755 index 0000000..e685fb9 --- /dev/null +++ b/dot_local/bin/executable_ai-statusline @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# Shared statusline for Cursor CLI + Claude Code. +# Reads StatusLinePayload JSON on stdin; prints a colored context meter. +# +# Thresholds (by context_window_size): +# < 500k → yellow 65% / red 85% (200K-class) +# >= 500k → yellow 20% / red 65% (1M-class) +# Override with AI_CTX_YELLOW / AI_CTX_RED. +# +# On yellow/red transitions, rings BEL once per level per session and updates +# a cache breadcrumb for Claude's Stop hook. + +set -euo pipefail + +lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=/dev/null +. "$lib_dir/lib/ai-context-thresholds.sh" + +payload="$(cat)" + +# Both CLIs re-run this on every UI update, so the payload is parsed exactly +# once. Fields are joined with US (0x1f): model names contain spaces, and tab +# or newline would be IFS whitespace, which bash collapses -- an absent +# used_percentage would then shift every later field left. Control characters +# are stripped first so no value can smuggle in a separator of its own. +model="" +pct_raw="" +window="" +session_id="" +agent="" +IFS=$'\x1f' read -r model pct_raw window session_id agent < <( + printf '%s' "$payload" | jq -r ' + [ + (.model.display_name // .model.id // "?"), + (.context_window.used_percentage // ""), + (.context_window.context_window_size // 200000), + (.session_id // "unknown"), + (if has("cost") or has("rate_limits") or has("exceeds_200k_tokens") + then "claude" else "cursor" end) + ] | map(tostring | gsub("[[:cntrl:]]"; "")) | join("\u001f")' 2>/dev/null +) || true + +# An unparseable payload leaves everything empty; fall through to the dim +# placeholder below rather than aborting the statusline. +[ -n "$model" ] || model="?" +[ -n "$agent" ] || agent="cursor" + +if [ -z "$pct_raw" ] || [ "$pct_raw" = "null" ]; then + printf '\033[90m[%s] ctx —\033[0m\n' "$model" + exit 0 +fi + +pct="$(printf '%s' "$pct_raw" | cut -d. -f1)" +# Guard non-numeric early-session values. +case "$pct" in + '' | *[!0-9]*) pct=0 ;; +esac +case "$window" in + '' | *[!0-9]*) window=200000 ;; +esac + +read -r yellow red < <(ai_context_thresholds "$window") +level="$(ai_context_level "$pct" "$yellow" "$red")" +compact_cmd="$(ai_context_compact_cmd "$agent")" + +state_path="$(ai_context_session_state_path "$session_id")" + +# Single read of the breadcrumb: the BEL latch, the last level and the last +# pct, so an unchanged refresh can be detected without a second jq. +prev_ui="green" +prev_level="green" +prev_pct="" +if [ -f "$state_path" ]; then + IFS=$'\x1f' read -r prev_ui prev_level prev_pct < <( + jq -r '[(.ui_alerted // "green"), (.level // "green"), (.pct // "" | tostring)] | join("\u001f")' \ + "$state_path" 2>/dev/null + ) || true + [ -n "$prev_ui" ] || prev_ui="green" + [ -n "$prev_level" ] || prev_level="green" +fi + +# One BEL per upward level transition (yellow, then red). +prev_rank="$(ai_context_level_rank "$prev_ui")" +cur_rank="$(ai_context_level_rank "$level")" +bell="" +new_ui_alerted="" +if [ "$cur_rank" -gt "$prev_rank" ]; then + bell=$'\a' + new_ui_alerted="$level" +fi + +# Persist only when it can matter. A session sitting at green re-renders +# constantly and has nothing to record, so the common case does no work at all. +if [ "$level" = "green" ]; then + # A missing breadcrumb already means "green, nothing alerted": both latches + # default to green here and the Stop hook short-circuits when the file is + # absent. Dropping it keeps later green refreshes off the jq read path and + # re-arms alerting for a later climb. + if [ "$prev_level" != "green" ]; then + rm -f "$state_path" + fi +elif [ "$level" != "$prev_level" ] || [ "$pct" != "$prev_pct" ] || [ -n "$new_ui_alerted" ]; then + # Yellow/red only needs a write when something the Stop hook reads actually + # moved. Everything it consumes derives from level and pct, and updated_at + # has no reader, so an unchanged refresh can skip the rewrite entirely. + ai_context_state_merge "$state_path" "$(jq -nc \ + --argjson pct "$pct" \ + --arg level "$level" \ + --argjson window "$window" \ + --argjson yellow "$yellow" \ + --argjson red "$red" \ + --arg agent "$agent" \ + --arg compact_cmd "$compact_cmd" \ + --argjson now "${EPOCHSECONDS:-$(date +%s)}" \ + --arg ui_alerted "$new_ui_alerted" \ + '{ + pct:$pct, level:$level, window:$window, yellow:$yellow, red:$red, + agent:$agent, compact_cmd:$compact_cmd, updated_at:$now + } + + (if $ui_alerted == "" then {} else {ui_alerted:$ui_alerted} end)')" +fi + +hint="" +color=$'\033[32m' +case "$level" in + yellow) + color=$'\033[33m' + hint=" · good spot to ${compact_cmd}" + ;; + red) + color=$'\033[31m' + hint=" · ${compact_cmd} soon" + ;; +esac + +# Progress bar (10 cells). +bar_width=10 +filled=$((pct * bar_width / 100)) +[ "$filled" -gt "$bar_width" ] && filled="$bar_width" +empty=$((bar_width - filled)) +bar="" +if [ "$filled" -gt 0 ]; then + printf -v fill '%*s' "$filled" '' + bar="${fill// /▓}" +fi +if [ "$empty" -gt 0 ]; then + printf -v pad '%*s' "$empty" '' + bar="${bar}${pad// /░}" +fi + +printf '%s[%s] %s %s%%%s\033[0m%s\n' "$color" "$model" "$bar" "$pct" "$hint" "$bell" diff --git a/dot_local/bin/lib/ai-context-thresholds.sh b/dot_local/bin/lib/ai-context-thresholds.sh new file mode 100755 index 0000000..439b96f --- /dev/null +++ b/dot_local/bin/lib/ai-context-thresholds.sh @@ -0,0 +1,135 @@ +# shellcheck shell=bash +# Shared context-usage thresholds for Cursor CLI + Claude Code statusline/hooks. +# +# Defaults: +# ~200K windows → yellow 65% / red 85% +# ~1M windows → yellow 20% / red 65% +# Override with AI_CTX_YELLOW / AI_CTX_RED (integers). + +ai_context_cache_dir() { + printf '%s\n' "${XDG_CACHE_HOME:-$HOME/.cache}/ai-context-alerts" +} + +# True for plain non-negative integers only. Callers feed these values to +# `[ -ge ]` and `jq --argjson`, both of which abort the script on anything else. +ai_context_is_int() { + case "${1:-}" in + '' | *[!0-9]*) return 1 ;; + *) return 0 ;; + esac +} + +ai_context_thresholds() { + local window="${1:-200000}" + local yellow red + + ai_context_is_int "$window" || window=200000 + + if [ "$window" -ge 500000 ]; then + yellow=20 + red=65 + else + yellow=65 + red=85 + fi + + # Overrides win only when both are integers. A malformed value used to be + # echoed straight through into `[ -ge ]` and `jq --argjson`, which killed the + # statusline under `set -e` and left an empty breadcrumb behind. + if ai_context_is_int "${AI_CTX_YELLOW:-}" && ai_context_is_int "${AI_CTX_RED:-}"; then + yellow="$AI_CTX_YELLOW" + red="$AI_CTX_RED" + fi + + printf '%s %s\n' "$yellow" "$red" +} + +# Prints: green | yellow | red +ai_context_level() { + local pct="${1:-0}" + local yellow="${2:-65}" + local red="${3:-85}" + + ai_context_is_int "$pct" || pct=0 + ai_context_is_int "$yellow" || yellow=65 + ai_context_is_int "$red" || red=85 + + if [ "$pct" -ge "$red" ]; then + printf 'red\n' + elif [ "$pct" -ge "$yellow" ]; then + printf 'yellow\n' + else + printf 'green\n' + fi +} + +ai_context_level_rank() { + case "${1:-green}" in + red) printf '2\n' ;; + yellow) printf '1\n' ;; + *) printf '0\n' ;; + esac +} + +ai_context_compact_cmd() { + case "${1:-cursor}" in + claude) printf '/compact\n' ;; + *) printf '/summarize\n' ;; + esac +} + +ai_context_session_state_path() { + local session_id="${1:-unknown}" + # Keep filenames boring for shell/json tooling. + session_id="${session_id//\//_}" + printf '%s/%s.json\n' "$(ai_context_cache_dir)" "$session_id" +} + +# Read a field from the session state file; default as $2. +ai_context_state_get() { + local path="$1" + local key="$2" + local default="${3:-}" + if [ ! -f "$path" ]; then + printf '%s\n' "$default" + return 0 + fi + jq -r --arg key "$key" --arg default "$default" '.[$key] // $default' "$path" 2>/dev/null || printf '%s\n' "$default" +} + +# Merge JSON object fields into the session state file. +# +# Never fatal: this runs on every statusline refresh, so a bad cache file must +# not take the meter down with it. A corrupt breadcrumb is reset from the +# current patch rather than failing forever and re-ringing BEL each refresh. +ai_context_state_merge() { + local path="$1" + local patch_json="${2:-}" + local dir + + # Callers build the patch as `"$(jq -nc ...)"` in the argument list, where a + # jq failure does not trip `set -e`. Guard so an empty or truncated patch + # cannot be written out as state. This is a shell-only test on purpose: the + # statusline reaches here on refreshes, and spawning jq just to validate was + # a measurable share of the cost. + case "$patch_json" in + '{'*'}') ;; + *) return 0 ;; + esac + + dir="${path%/*}" + [ -d "$dir" ] || mkdir -p "$dir" || return 0 + + if [ -f "$path" ] && + jq -c --argjson patch "$patch_json" '. * $patch' "$path" >"${path}.tmp" 2>/dev/null; then + mv "${path}.tmp" "$path" + return 0 + fi + rm -f "${path}.tmp" + + # No usable state file. This path is rare, so pay for real validation here + # rather than letting a malformed patch replace the breadcrumb wholesale. + if printf '%s' "$patch_json" | jq -e 'type == "object"' >/dev/null 2>&1; then + printf '%s\n' "$patch_json" >"$path" + fi +} diff --git a/qa.just b/qa.just index 9658cd8..d40356c 100644 --- a/qa.just +++ b/qa.just @@ -6,10 +6,10 @@ default: # Lint shell scripts and test files. lint-shell: @set -euo pipefail; \ - fmt_files=("{{ justfile_directory() }}/dot_local/bin/executable_paste-image-agent.sh" "{{ justfile_directory() }}/dot_local/bin/executable_wl-paste" "{{ justfile_directory() }}/scripts/bookmark-pr-hygiene.sh" "{{ justfile_directory() }}/scripts/repos-smoke.sh"); \ + fmt_files=("{{ justfile_directory() }}/dot_local/bin/executable_paste-image-agent.sh" "{{ justfile_directory() }}/dot_local/bin/executable_wl-paste" "{{ justfile_directory() }}/dot_local/bin/executable_ai-statusline" "{{ justfile_directory() }}/dot_local/bin/lib/ai-context-thresholds.sh" "{{ justfile_directory() }}/dot_claude/hooks/executable_context-threshold-stop.sh" "{{ justfile_directory() }}/dot_claude/modify_settings.json.tmpl" "{{ justfile_directory() }}/.chezmoiscripts/run_after_25-cursor-statusline.sh" "{{ justfile_directory() }}/scripts/bookmark-pr-hygiene.sh" "{{ justfile_directory() }}/scripts/repos-smoke.sh"); \ while IFS= read -r _file; do fmt_files+=("$_file"); done < <(find "{{ justfile_directory() }}/tests/shell" -type f \( -name '*.sh' -o -name '*.bash' -o -name '*.bats' \) 2>/dev/null | sort); \ mise exec --locked -- shfmt -d -i 2 -ci "${fmt_files[@]}"; \ - sc_files=("{{ justfile_directory() }}/dot_local/bin/executable_paste-image-agent.sh" "{{ justfile_directory() }}/dot_local/bin/executable_wl-paste" "{{ justfile_directory() }}/dot_local/bin/lib/clipboard-image.sh" "{{ justfile_directory() }}/scripts/bookmark-pr-hygiene.sh" "{{ justfile_directory() }}/scripts/repos-smoke.sh"); \ + sc_files=("{{ justfile_directory() }}/dot_local/bin/executable_paste-image-agent.sh" "{{ justfile_directory() }}/dot_local/bin/executable_wl-paste" "{{ justfile_directory() }}/dot_local/bin/lib/clipboard-image.sh" "{{ justfile_directory() }}/dot_local/bin/executable_ai-statusline" "{{ justfile_directory() }}/dot_local/bin/lib/ai-context-thresholds.sh" "{{ justfile_directory() }}/dot_claude/hooks/executable_context-threshold-stop.sh" "{{ justfile_directory() }}/dot_claude/modify_settings.json.tmpl" "{{ justfile_directory() }}/.chezmoiscripts/run_after_25-cursor-statusline.sh" "{{ justfile_directory() }}/scripts/bookmark-pr-hygiene.sh" "{{ justfile_directory() }}/scripts/repos-smoke.sh"); \ while IFS= read -r _file; do sc_files+=("$_file"); done < <(find "{{ justfile_directory() }}/tests/shell" -type f \( -name '*.sh' -o -name '*.bash' \) 2>/dev/null | sort); \ mise exec --locked -- shellcheck "${sc_files[@]}" diff --git a/tests/shell/unit/ai_context_thresholds.bats b/tests/shell/unit/ai_context_thresholds.bats new file mode 100644 index 0000000..13ad44b --- /dev/null +++ b/tests/shell/unit/ai_context_thresholds.bats @@ -0,0 +1,185 @@ +#!/usr/bin/env bats + +setup() { + REPO_ROOT="$(cd "${BATS_TEST_DIRNAME}/../../.." && pwd)" + LIB="$REPO_ROOT/dot_local/bin/lib/ai-context-thresholds.sh" + STATUSLINE="$REPO_ROOT/dot_local/bin/executable_ai-statusline" + CACHE_DIR="$(mktemp -d)" + export XDG_CACHE_HOME="$CACHE_DIR" +} + +teardown() { + rm -rf "${CACHE_DIR:-}" +} + +payload() { + printf '{"session_id":"%s","model":{"display_name":"Opus"},' "${1:-t}" + printf '"context_window":{"used_percentage":%s,"context_window_size":%s}}' \ + "${2:-70}" "${3:-200000}" +} + +@test "thresholds use window defaults for 200K and 1M classes" { + run bash -c 'set -euo pipefail; . "$1"; ai_context_thresholds 200000' bash "$LIB" + [ "$status" -eq 0 ] + [ "$output" = "65 85" ] + + run bash -c 'set -euo pipefail; . "$1"; ai_context_thresholds 1000000' bash "$LIB" + [ "$status" -eq 0 ] + [ "$output" = "20 65" ] +} + +@test "integer overrides replace both thresholds" { + run bash -c \ + 'set -euo pipefail; . "$1"; AI_CTX_YELLOW=10 AI_CTX_RED=20 ai_context_thresholds 200000' \ + bash "$LIB" + [ "$status" -eq 0 ] + [ "$output" = "10 20" ] +} + +@test "non-integer overrides fall back to window defaults" { + run bash -c \ + 'set -euo pipefail; . "$1"; AI_CTX_YELLOW=abc AI_CTX_RED=def ai_context_thresholds 200000' \ + bash "$LIB" + [ "$status" -eq 0 ] + [ "$output" = "65 85" ] +} + +@test "a non-integer override does not take the statusline down" { + command -v jq >/dev/null 2>&1 || skip "jq not installed" + + run bash -c 'payload="$1"; shift; printf "%s" "$payload" | AI_CTX_YELLOW=abc AI_CTX_RED=def bash "$1"' \ + bash "$(payload crash 70 200000)" "$STATUSLINE" + [ "$status" -eq 0 ] + [[ "$output" != *"jq:"* ]] + [[ "$output" != *"integer expression expected"* ]] +} + +@test "level ranking tolerates non-numeric inputs" { + run bash -c 'set -euo pipefail; . "$1"; ai_context_level abc 65 85' bash "$LIB" + [ "$status" -eq 0 ] + [ "$output" = "green" ] +} + +@test "an empty patch never becomes session state" { + command -v jq >/dev/null 2>&1 || skip "jq not installed" + + run bash -c ' + set -euo pipefail + . "$1" + state="$2" + ai_context_state_merge "$state" "" + [ -f "$state" ] && exit 1 + exit 0 + ' bash "$LIB" "$CACHE_DIR/empty.json" + [ "$status" -eq 0 ] +} + +@test "a corrupt breadcrumb heals instead of failing forever" { + command -v jq >/dev/null 2>&1 || skip "jq not installed" + + run bash -c ' + set -euo pipefail + . "$1" + state="$2" + printf "not json at all" >"$state" + ai_context_state_merge "$state" "{\"ui_alerted\":\"yellow\"}" + jq -r ".ui_alerted" "$state" + ' bash "$LIB" "$CACHE_DIR/corrupt.json" + [ "$status" -eq 0 ] + [ "$output" = "yellow" ] + [ ! -f "$CACHE_DIR/corrupt.json.tmp" ] +} + +@test "the BEL latch persists across refreshes once state is healthy" { + command -v jq >/dev/null 2>&1 || skip "jq not installed" + + state="$CACHE_DIR/ai-context-alerts/latch.json" + + printf '%s' "$(payload latch 70 200000)" | bash "$STATUSLINE" >/dev/null + [ "$(jq -r '.ui_alerted' "$state")" = "yellow" ] + + printf '%s' "$(payload latch 72 200000)" | bash "$STATUSLINE" >/dev/null + [ "$(jq -r '.ui_alerted' "$state")" = "yellow" ] +} + +@test "a session sitting at green writes no breadcrumb" { + command -v jq >/dev/null 2>&1 || skip "jq not installed" + + printf '%s' "$(payload idle 30 200000)" | bash "$STATUSLINE" >/dev/null + printf '%s' "$(payload idle 31 200000)" | bash "$STATUSLINE" >/dev/null + + [ ! -f "$CACHE_DIR/ai-context-alerts/idle.json" ] +} + +@test "dropping back to green clears the breadcrumb and re-arms alerting" { + command -v jq >/dev/null 2>&1 || skip "jq not installed" + + state="$CACHE_DIR/ai-context-alerts/drop.json" + + printf '%s' "$(payload drop 70 200000)" | bash "$STATUSLINE" >/dev/null + [ "$(jq -r '.ui_alerted' "$state")" = "yellow" ] + + # Absence is the green state: no latch, and the Stop hook short-circuits. + printf '%s' "$(payload drop 10 200000)" | bash "$STATUSLINE" >/dev/null + [ ! -f "$state" ] + + # A later climb must be able to alert again. + printf '%s' "$(payload drop 70 200000)" | bash "$STATUSLINE" >/dev/null + [ "$(jq -r '.ui_alerted' "$state")" = "yellow" ] +} + +@test "an unchanged yellow refresh does not rewrite the breadcrumb" { + command -v jq >/dev/null 2>&1 || skip "jq not installed" + + state="$CACHE_DIR/ai-context-alerts/steady.json" + + printf '%s' "$(payload steady 70 200000)" | bash "$STATUSLINE" >/dev/null + before="$(stat -c %i "$state")" + + # ai_context_state_merge lands via mv, so any write changes the inode. + printf '%s' "$(payload steady 70 200000)" | bash "$STATUSLINE" >/dev/null + [ "$(stat -c %i "$state")" = "$before" ] + + # A real change must still be recorded. + printf '%s' "$(payload steady 75 200000)" | bash "$STATUSLINE" >/dev/null + [ "$(jq -r '.pct' "$state")" = "75" ] +} + +@test "an unparseable payload degrades instead of erroring out" { + command -v jq >/dev/null 2>&1 || skip "jq not installed" + + run bash -c 'printf "not json at all" | bash "$1"' bash "$STATUSLINE" + [ "$status" -eq 0 ] + [[ "$output" == *"ctx"* ]] + [[ "$output" != *"jq:"* ]] +} + +@test "control characters in a model name cannot shift the parsed fields" { + command -v jq >/dev/null 2>&1 || skip "jq not installed" + + # US (0x1f) is the field separator; a model name carrying it must not be able + # to fabricate pct/level or retarget the breadcrumb path. It has to travel as + # the JSON \u001f escape -- a raw control byte is not valid JSON. + us='\u001f' + evil="Evil${us}99${us}200000${us}hijacked${us}cursor" + body="{\"session_id\":\"real\",\"model\":{\"display_name\":\"$evil\"}," + body="$body\"context_window\":{\"used_percentage\":5,\"context_window_size\":200000}}" + + run bash -c 'printf "%s" "$1" | bash "$2"' bash "$body" "$STATUSLINE" + [ "$status" -eq 0 ] + [[ "$output" == *"5%"* ]] + [[ "$output" != *"99%"* ]] + [ ! -f "$CACHE_DIR/ai-context-alerts/hijacked.json" ] +} + +@test "a model display name containing spaces is not split" { + command -v jq >/dev/null 2>&1 || skip "jq not installed" + + run bash -c ' + printf "%s" "{\"session_id\":\"spaced\",\"model\":{\"display_name\":\"Claude Opus 4\"},\"context_window\":{\"used_percentage\":30,\"context_window_size\":200000}}" \ + | bash "$1" + ' bash "$STATUSLINE" + [ "$status" -eq 0 ] + [[ "$output" == *"[Claude Opus 4]"* ]] + [[ "$output" == *"30%"* ]] +}