"""CompactAction — request a /compact and verify it via context_pct drop.
Why a separate completion signal matters
----------------------------------------
A probe that checks for a string in the pane (e.g. "Conversation
compacted") would be brittle — Claude Code's TUI wording changes
between releases, and the status message can scroll off before the
poll sees it. Using the statusline-reported ``context_pct`` gives a
numeric, unambiguous "did the compact succeed?" signal.
Outcome interpretation
----------------------
- ``SUCCESS`` — ``context_pct`` dropped by at least
``min_drop_pct`` (default 20 percentage points).
- ``COMPLETION_TIMEOUT`` — context didn't drop enough in time.
Could mean: (a) compact hasn't finished, (b) ``context_pct_fn``
returned ``None`` throughout (statusline unavailable), (c) the
compact was rejected by Claude Code. Operator inspects the
``pane_after`` log column.
- ``PRECONDITION_FAIL`` — pane was busy; we declined to
interrupt an in-flight turn.
- ``SEND_ERROR`` — ``send_text_and_submit`` itself raised.
Operators who want a scheduled auto-compact (interval or
threshold) should build that policy layer *on top of* this action —
it is intentionally policy-free.
"""
from __future__ import annotations
from typing import Any, Optional
from ..action_base import ActionContext, PaneAction
from ..liveness_probe import pane_is_busy
# Tail window carried into the attempt log for forensic readers.
# Compacts don't need the huge window a nonce probe needs because
# the completion signal is numeric, not textual.
_PANE_TAIL_CHARS = 2000
# Default minimum context-pct drop to declare SUCCESS. Compacts
# typically clear 30-70pp; 20 is a conservative floor that rejects
# the "compact ran but barely reclaimed anything" case as still
# worth reporting but not as SUCCESS.
_DEFAULT_MIN_DROP_PCT = 20.0
[docs]
class CompactAction(PaneAction):
"""Send ``/compact`` and wait for ``context_pct`` to fall.
Parameters
----------
min_drop_pct:
Percentage points the context must drop for this action to
report ``SUCCESS``. Default 20. Tune per operator
preference; higher = stricter.
command:
Override the submitted text. Default ``"/compact"``. Present
for tests that want to exercise the flow without assuming
a specific Claude Code command string.
"""
name = "compact"
[docs]
def __init__(
self,
min_drop_pct: float = _DEFAULT_MIN_DROP_PCT,
command: str = "/compact",
):
self.min_drop_pct = float(min_drop_pct)
self._command = command
# ---- PaneAction surface ----------------------------------------
[docs]
def snapshot(self, ctx: ActionContext) -> dict[str, Any]:
pane = ctx.capture_fn() or ""
return {
"pane_tail": pane[-_PANE_TAIL_CHARS:],
"context_pct": _coerce_float(ctx.context_pct_fn()),
}
[docs]
def precheck(self, before: dict[str, Any]) -> bool:
"""Refuse to compact a currently-busy pane.
Two separate reasons:
1. Interrupting an in-flight response would corrupt the
user's work (same as the nonce-probe rationale).
2. Claude Code occasionally ignores ``/compact`` when it
is mid-tool-call; the command lands but the TUI does
not act on it until the current turn resolves,
producing a confusing COMPLETION_TIMEOUT with no
actual failure.
"""
return not pane_is_busy(before.get("pane_tail", ""))
[docs]
def send(self, ctx: ActionContext) -> None:
ctx.mux.send_text_and_submit(ctx.session, self._command)
# Record the command we actually sent so the attempt log is
# self-describing for anyone debugging why a compact didn't
# match expectations (custom Claude Code build, alias, etc.).
ctx.extras["command"] = self._command
[docs]
def is_complete(self, before: dict[str, Any], now: dict[str, Any]) -> bool:
"""``True`` iff ``context_pct`` dropped by at least
``min_drop_pct`` percentage points between the before and
now snapshots.
Either side being ``None`` (statusline unavailable or not
yet populated) always returns ``False`` — the engine then
either keeps polling until the deadline or reports
``COMPLETION_TIMEOUT``. The attempt log still captures
both snapshots so the operator can see *why* we couldn't
confirm.
"""
b = _coerce_float(before.get("context_pct"))
n = _coerce_float(now.get("context_pct"))
if b is None or n is None:
return False
return (b - n) >= self.min_drop_pct
[docs]
def _coerce_float(value: Any) -> Optional[float]:
"""Tolerant float coercion — returns ``None`` for ``None`` /
bad input. The statusline parser may emit strings or ``None``
depending on the Claude Code build, so be forgiving."""
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError): # stx-allow: fallback (reason: type coercion or format mismatch)
return None