Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .chezmoiscripts/run_after_25-cursor-statusline.sh
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docs/agent-live-systems.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
96 changes: 96 additions & 0 deletions docs/ai-cli-context-statusline.md
Original file line number Diff line number Diff line change
@@ -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/<session_id>.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`.
64 changes: 64 additions & 0 deletions dot_claude/hooks/executable_context-threshold-stop.sh
Original file line number Diff line number Diff line change
@@ -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}')"
41 changes: 41 additions & 0 deletions dot_claude/modify_settings.json.tmpl
Original file line number Diff line number Diff line change
@@ -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}]}]
)
'
Loading
Loading