feat(install widget): dependency-cooldown configurator#57
Conversation
Add a fourth dimension to the install picker's (client, method, scope)
matrix: cooldown mode in {off, days, bypass}. Off keeps the existing
command intact; days inserts the tool-appropriate cooldown flag with a
``<DAYS>`` sentinel for the user-configurable day count; bypass inserts
``--no-config`` (uvx) or an ``env: UV_NO_CONFIG=1`` block (JSON/TOML).
Per-tool flag forms — verified against pip 26.1+ source and Astral's
``--exclude-newer`` docs:
* uvx days → ``uvx --exclude-newer P<N>D libtmux-mcp``
* uvx bypass → ``uvx --no-config libtmux-mcp``
* pipx days → ``pipx run --pip-args=--uploaded-prior-to=P<N>D ...``
* pip days → cooldown applies to the prereq ``pip install`` line via
``--uploaded-prior-to P<N>D`` (pip ≥ 26.1)
Pip and pipx have no global cooldown to bypass — their bypass panels
emit the same body as off plus a per-panel ``note`` row explaining the
caveat. Codex's project scope keeps its TOML body and gains the
cooldown flag inside the ``args`` array (or ``env`` block for bypass).
Server-renders 90 panels (10 scope rows x 3 methods x 3 cooldown
modes). A new ``cooldown_days_slot`` Jinja filter runs after Pygments
and swaps the escaped ``<DAYS>`` sentinel for a
``<span data-cooldown-days-slot>7</span>`` whose textContent
``widget.js`` will update in phase 2 — but the default 7 already ships
inline so first paint shows the correct snippet without any post-paint
DOM mutation.
The prehydrate ``@layer`` rules now enumerate every legal
``(client, method, scope, cooldown-mode)`` quadruple (90 panel-active
selectors, all ``!important`` so the CSS Cascade Level 5 layer-priority
reversal continues to beat unlayered preflight rules). The inline
``<head>`` script gains two reads (``cooldown.mode`` / ``cooldown.days``)
and emits both as ``data-mcp-install-cooldown-*`` attrs on ``<html>``
before first paint.
No UI control yet — cooldown mode hard-defaults to ``off`` for every
visitor in this phase, so the front page reads identically to before.
The settings panel and the right-edge checkbox land in the next commit.
…view Wire up the interactive layer for the cooldown picker landed in the previous commit. A new ``Configure cooldowns`` checkbox sits at the right edge of the install-method tab row; clicking the checkbox flips mode between ``off`` and ``days`` (defaulting days to 7) and opens the settings sub-view, while clicking the ``Configure cooldowns`` label always opens settings regardless of state. A ``?`` button opens the collapsible explainer with links to cooldowns.dev and Datadog's dependency-cooldown writeup. The widget body now has two parallel sub-views — install (the existing 30 cooldown-aware panels) and settings (radio mode + days input + expandable help). CSS swaps them via ``html[data-mcp-install-view]``. The settings form auto-saves on every change (matching the hsk-django flashcard pattern) — there's no Save button. ``← Back to installer`` returns to the install view without losing settings. Days slot updates fire on every ``input`` event so the snippet changes in real time as the user types; persistence and broadcast fire on ``change`` only so localStorage writes don't hammer per-keystroke. ``_assets.py`` switches from ``copy_asset_file`` to ``shutil.copy2``: recent Sphinx releases tightened the helper to refuse overwriting an existing destination (emitting ``misc.copy_overwrite`` warnings), which left widget JS/CSS stale on every incremental rebuild. The cache-busting ``?v=<hash>`` already keeps browsers honest. Playwright-verified flows: * fresh visit (off) -> click checkbox -> settings opens, mode=days, default 7-day cooldown applied to every snippet * edit days input to 30 -> snippet shows ``--exclude-newer P30D`` live * radio to bypass -> snippet swaps to ``--no-config`` form (or ``env`` block for JSON-kind clients); pip/pipx surface the per-tool no-op note * reload -> prehydrate restores mode + days before first paint, no visible flicker; view resets to install (transient by design)
User-facing one-paragraph entry under the existing ``### What's new`` heading, per AGENTS.md "deliverable test": describes what the reader can now do (pick a cooldown delay or bypass a global cooldown) and points at cooldowns.dev / Datadog for context. Closes #31.
mypy on full-tree (CI runs ``uv run mypy .``) flagged the ``in`` operator usage on ``Panel.pip_prereq`` because the ``all(... is not None)`` guard above doesn't carry narrowing into the per-instance checks below. Add explicit ``is not None`` asserts on the two specific panels before the membership tests. Functionally a no-op — the prior ``all`` already proves it.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #57 +/- ##
==========================================
+ Coverage 85.39% 86.02% +0.62%
==========================================
Files 40 40
Lines 2349 2454 +105
Branches 300 325 +25
==========================================
+ Hits 2006 2111 +105
Misses 260 260
Partials 83 83 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
… button Pull the ``← Back`` link and the *Dependency cooldowns* title onto a single row at the top of the settings sub-view, flush to the body's content area. Reshape the back link as a compact bordered button (``font-size: 0.85em``) so it reads as an action instead of inline prose; the title drops from ``1.1em`` to ``1em`` so it inherits the widget's body type scale instead of sticking out. WAI-ARIA polish: * visible label shrinks to ``← Back`` (compact), but the ``<button>`` carries ``aria-label="Back to installer"`` so screen readers still get the destination * the decorative left arrow gets ``aria-hidden="true"`` to keep it out of the accessible name * the surrounding ``role="dialog"`` keeps its ``aria-label``, and the visible ``<h3>`` provides the heading semantic next to it
…ISO durations End-to-end testing with the documented snippets surfaced a runtime failure on pipx: ``--pip-args=--uploaded-prior-to=P7D`` is rejected inside pipx 1.8.0's bundled venv because the bundled pip is older than 26.1 and only ISO 8601 duration syntax was added there. Pip 26.0 (2026-01) accepted absolute datetimes only, and that's what ships in the current pipx release. Switch every cooldown days-mode snippet to the absolute-date form ``YYYY-MM-DD`` computed from ``today (UTC) - savedDays``. The form is accepted across uv's ``--exclude-newer``, pip 26.0+'s ``--uploaded-prior-to``, and pipx's bundled pip — portable across the full matrix. ``widget.js`` adds a ``daysToIsoDate(n)`` helper that recomputes the cutoff on every days-input change so the rendered snippet matches what the user would run *that day*. Rename ``<DAYS>`` -> ``<COOLDOWN_DATE>`` sentinel; the Jinja filter swaps it for a ``data-cooldown-date-slot`` ``<span>`` whose default is the server-rendered ``today - DEFAULT_COOLDOWN_DAYS`` ISO date. Build-time staleness is bounded by build cadence — JS refreshes on first hydration so any visitor sees a current cutoff. Verified live: * ``uvx --exclude-newer 2026-05-10 libtmux-mcp`` runs cleanly * ``pipx run --pip-args=--uploaded-prior-to=2026-05-10 libtmux-mcp`` runs cleanly (this was the broken cell) * ``pip install --dry-run --uploaded-prior-to 2026-05-10 libtmux-mcp`` resolves libtmux-mcp 0.1.0a6 (the only release older than the cutoff)
…pip to duration form Replace the single ``cooldown.mode`` enum with three orthogonal localStorage keys: ``cooldown.enabled`` (master on/off), ``cooldown.type`` (``"days"`` vs ``"bypass"``), and ``cooldown.days`` (day count). The *Configure cooldowns* checkbox now flips ``enabled`` only — it no longer doubles as the entry point into the settings view. The *Configure cooldowns* label / ``?`` button remain the explicit settings entry. Touching the radio or days input auto-sets ``enabled=1`` so a user who configures clearly wants the cooldown on. Switch uvx and pip days-mode snippets from absolute date to ISO 8601 duration ``P<N>D``. uv stores ``--exclude-newer P7D`` as ``ExcludeNewerValue::Relative(ExcludeNewerSpan)`` and recomputes the cutoff against ``current_time()`` on every resolver call (see ``crates/uv-distribution-types/src/exclude_newer.rs:64-89``); pip 26.1+ resolves the duration at flag-parse time per invocation. Both keep the user's saved ``.mcp.json`` arg fresh — every spawn evaluates ``now - N days`` against that spawn's clock, not against the day the config was written. pipx days bodies keep the absolute-date form because pipx 1.8.0 bundles a pip older than 26.1 that rejects ``P<N>D`` with ``Invalid isoformat``. JS still recomputes the date on every page load so the rendered snippet stays current. Two sentinels coexist in the panel bodies — ``<COOLDOWN_DURATION>`` for uvx + pip, ``<COOLDOWN_DATE>`` for pipx — and the ``cooldown_days_slot`` Jinja filter swaps both post-Pygments. ``widget.js`` updates both slot kinds (``[data-cooldown-duration-slot]`` and ``[data-cooldown-date-slot]``) on every days-input change. The 90-rule ``@layer mcp-install-prehydrate`` panel-active selectors rewrite to key on the ``(enabled, type)`` pair: 30 rules for ``enabled=0`` → ``[data-cooldown="off"]``, plus 30 each for the days and bypass type when enabled=1. Same rule count as before, different attribute shape. Smoke-tested live: * ``uvx --exclude-newer P7D libtmux-mcp`` runs cleanly * ``pipx run --pip-args=--uploaded-prior-to=2026-05-10 libtmux-mcp`` runs cleanly (the cell that motivated the absolute-date fallback) * ``pip install --dry-run --uploaded-prior-to P7D libtmux-mcp`` resolves the right older release on host pip 26.1.1 Full UX trace via Playwright: checkbox toggles enabled with view unchanged, label opens settings, radio/days input auto-enable, back returns to install view, uncheck returns to off variant.
``uv run ruff format --check .`` flagged three files reformatted on the prior commit. Apply the formatter so CI passes.
Clicking any client / method / scope tab while the cooldown settings view is open now returns to the install view. A tab click is an install-side action — if the reader was mid-configuring cooldowns, the click means "I want to see the snippet for this new selection", so the settings drawer dismisses to surface the updated panel. Verified end-to-end: open settings, click Cursor / pipx / scope tab, view flips to ``install`` each time with the new selection reflected.
The ``_json_body`` helper composed the snippet via ``textwrap.dedent`` on an f-string with a multi-line ``inner`` substitution. The first member (``"command"``) inherited the template's 20-space source-indent, but continuation members (``"args"``, ``"env"``) only carried the 12-space ``server_indent`` prefix. ``dedent`` then computed the common leading whitespace across all lines — limited by the lower-indent continuation lines — and stripped that uniformly, leaving ``"command"`` at 12 spaces and ``"args"`` at 4. The rendered Cursor and Claude Desktop snippets came out visibly broken. Drop ``textwrap.dedent`` entirely and build the JSON string with explicit per-line indents. Each server-object member now carries its own 12-space prefix at build time, joined with ``,\n``, and the surrounding ``mcpServers`` / ``tmux`` lines are spelled out in the return expression. The output is byte-for-byte stable regardless of source whitespace, and the unused ``textwrap`` import drops out. The ``_toml_body`` helper already used flat ``"\n".join`` and was unaffected.
…o kill load flicker Native ``<input type="checkbox">`` doesn't react to CSS attribute selectors — its rendered glyph follows the ``.checked`` property, which only ``widget.js`` can set on DOMContentLoaded. When a user saved ``cooldown.enabled = "1"`` and reloaded, the SSR HTML had an unchecked input, paint showed native-unchecked, JS then flipped ``.checked = true``, producing a visible one-frame unchecked → checked flicker on every load. Apply the same prehydrate pattern that already drives panel and tab state: strip the native chrome with ``appearance: none`` inside the ``@layer mcp-install-prehydrate`` block, and re-render the checkbox visual from CSS keyed off ``html[data-mcp-install-cooldown-enabled="1"]``. The inline ``<head>`` script already sets that attribute from localStorage before first paint, so the visual is correct at first paint without waiting for JS. The check mark is a centred ``✓`` ``::after`` pseudo on a brand-blue background — legible in both light and dark modes since the brand colour stays blue across themes. ``appearance: none`` would have also dropped the focus outline; the rule re-adds a brand-coloured ``:focus-visible`` ring so keyboard navigation accessibility stays intact. ``widget.js``'s existing ``applyCooldownToWidget`` continues to sync ``.checked`` post-DOMContentLoaded — needed for screen readers, the ``onChange`` click handler's read of ``el.checked``, and form-submit semantics. That sync is invisible because the CSS visual is already correct. Verified live with ``localStorage.cooldown.enabled = "1"`` + reload: ``getComputedStyle(checkbox).backgroundColor`` reads ``rgb(10, 75, 255)`` (brand-primary) at first paint, no flicker visible. Empty-localStorage reload renders unchecked. Toggle on/off via click updates the visual synchronously with the ``<html>`` attribute change.
Code reviewFound 3 issues:
libtmux-mcp/docs/_widgets/mcp-install/widget.html Lines 18 to 25 in 362a92b
libtmux-mcp/docs/_widgets/mcp-install/widget.css Lines 195 to 201 in 362a92b
libtmux-mcp/docs/_ext/widgets/mcp_install.py Lines 22 to 28 in 362a92b libtmux-mcp/docs/_ext/widgets/mcp_install.py Lines 231 to 237 in 362a92b 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
…trs + filter Three comments / docstrings in this branch's earlier commits still named the cooldown slot by its pre-split attribute (``data-cooldown-days-slot``) and the Jinja filter by an incorrect shortened form (``cooldown_slots``). The slot was later split into ``data-cooldown-duration-slot`` (uvx + pip days bodies) and ``data-cooldown-date-slot`` (pipx days bodies); the filter is registered in ``_base.py`` as ``cooldown_days_slot``. A grep against the comment names returned no live code, so any reader following them would search for symbols that don't exist. * ``widget.html`` top docstring — rewrite the two sentences that describe the filter: name both sentinels (``<COOLDOWN_DURATION>``, ``<COOLDOWN_DATE>``) and both slot spans, and cite the filter's factory location for grep-ability. * ``widget.css`` cooldown-days slot comment — name both slot attributes and what each carries. * ``mcp_install.py`` module docstring + sentinel comment — fix the two ``cooldown_slots`` references to ``cooldown_days_slot``. Functional code unchanged. Closes the only finding from the automated code review on this PR.
…ilter The Jinja filter that swaps post-Pygments ``<COOLDOWN_DURATION>`` and ``<COOLDOWN_DATE>`` sentinels for the corresponding slot ``<span>`` elements had no direct test coverage. Existing tests verified body-string composition (the input side) and the rendered HTML's panel structure (via the full-build path), but nothing asserted that the filter itself produces the expected spans. A silent regression — for example, dropping one of the two ``str.replace`` calls in ``make_cooldown_days_slot_filter`` — would have shipped raw sentinel text into every days-mode snippet and gone undetected. Add six tests: - **Duration sentinel swap**: input containing ``<COOLDOWN_DURATION>`` emits a ``data-cooldown-duration-slot`` span carrying the ``P<DEFAULT_COOLDOWN_DAYS>D`` default text. - **Date sentinel swap**: input containing ``<COOLDOWN_DATE>`` emits a ``data-cooldown-date-slot`` span carrying an ISO date. - **No-op without sentinels**: off and bypass bodies, which carry no sentinel, pass through unchanged. - **Both sentinels in one HTML**: a single string containing both sentinels gets both spans — defense against the swap order accidentally clobbering one form. - **Markup return type**: the output is ``markupsafe.Markup`` so Jinja autoescape doesn't re-escape the injected span. - **End-to-end wiring**: a built page renders both ``data-cooldown-duration-slot`` and ``data-cooldown-date-slot`` spans and contains no raw escaped sentinels — guards against regressing the ``jenv.filters["cooldown_days_slot"]`` line in ``BaseWidget.render``. Closes the test gap surfaced by the automated code review on PR #57.
Summary
exclude-newercooldowns when runninguvx libtmux-mcp#31's docs request for auvx-bypass path.(client, method, scope, cooldown)cell.--exclude-newer P<N>D/--no-config, pip's--uploaded-prior-to P<N>D(pip 26.1+), pipx's--pip-args=--uploaded-prior-to=P<N>D. JSON/TOML config snippets gainenv: UV_NO_CONFIG=1blocks for bypass.localStorage(libtmux-mcp.mcp-install.cooldown.{mode,days}); the prehydrate<head>script restores both before first paint so reloading never flashes through the default.copy_asset_fileno-overwrite behavior in_assets.pyso widget JS/CSS actually re-ship on incremental builds (a quiet trap — the build reported success while serving stale assets).Changes by area
Data model & rendering
docs/_ext/widgets/mcp_install.py— NewCooldowndataclass +COOLDOWNStuple.Panelgainscooldown,pip_prereq,notefields._body_for(client, method, scope, cooldown)returns(body, language, note).<DAYS>sentinel marks the spot in days-mode bodies where the user-configurable day count slots in.docs/_ext/widgets/_base.py— Newcooldown_days_slotJinja filter swaps the post-Pygments<DAYS>for<span data-cooldown-days-slot>7</span>inside the highlighted string literal. The number inherits Pygments colors and stays selectable for copy-paste.docs/_ext/widgets/_prehydrate.py— Readscooldown.mode/cooldown.daysfrom localStorage in the inline<head>script. The@layer mcp-install-prehydraterules enumerate every legal(client × method × scope × cooldown)quadruple so first paint always lands on the correct cell.docs/_ext/widgets/_assets.py— Replacecopy_asset_filewithshutil.copy2to force overwrites on incremental builds (Sphinx 8 emitsmisc.copy_overwritewarnings and aborts otherwise).Template & UX
docs/_widgets/mcp-install/widget.html— Adds the right-edge cooldown control (checkbox + label button +?help) on the method-tab row, plus a parallel settings sub-view with radio mode picker, days input, and a collapsible What are cooldowns? explainer linking cooldowns.dev and the Datadog Security Labs writeup.docs/_widgets/mcp-install/widget.css— Styles the cooldown control, settings view, days input, and the per-tool cooldown caveat row. CSS-only view swap viahtml[data-mcp-install-view="settings"](no transitions — see commitf23d7d5for why).docs/_widgets/mcp-install/widget.js—setCooldownMode()/setCooldownDays()/setView()plus delegated handlers for the checkbox, radio, days input, back link, and help button. Auto-save on every change (matching the hsk-django flashcard settings pattern);inputevents update slot textContent in real time whilechangeevents persist + broadcast.Tests & changelog
tests/docs/test_widgets.py— 4-axis cross-product asserts; per-tool body-shape tests for each cooldown mode; verifies the pip/pipx bypass caveat note; confirmsPanel.pip_prereqis set only for the pip method and carries the cooldown flag when applicable.CHANGES— User-facing entry under### What's newper AGENTS.md's deliverable test.Design decisions
Days slot via post-Pygments span injection. Server-renders the snippet as
…P<DAYS>D…. Pygments highlights the surrounding text, then thecooldown_days_slotfilter swaps<DAYS>for a<span data-cooldown-days-slot>7</span>. The span lives inside the Pygments string-literal span so the number inherits its color;widget.jsupdatestextContenton every input keystroke. Copy-paste captures the visible number. The alternative — CSS::before { content: var(--days) }— would have made the value uncopyable.View toggle is transient, mode is persistent. Cooldown mode and days persist across pages and reloads. The settings-vs-install view does not — a user mid-form who reloads expects to see the snippet on next load, not the open form. Implementing view as
html[data-mcp-install-view](set only by JS, never restored by prehydrate) gets this for free.Bypass for non-uv tools is shown, not hidden. Pip and pipx default backend have no global cooldown to bypass, so their bypass panels render the same body as off but surface an italic per-tool note. This is more honest than silently making the radio inert on certain method tabs, and the note links the user to the uv-backed fallback (
pipx[uv]).Single source of truth for defaults.
DEFAULT_COOLDOWN_MODE/DEFAULT_COOLDOWN_DAYSlive in Python and are serialized into the inline prehydrate script. Adding a new cooldown mode or moving the default later only requires editing the Python module — the script extends automatically.Verification
Every legal
(client, method, scope, cooldown)quadruple has a matching CSS rule in the prehydrate<style>block. With the front page built, this should match 90:$ grep -oc 'data-mcp-install-cooldown-mode="[^"]*"\] \.lm-mcp-install__panel' docs/_build/html/index.htmlThe days slot span appears exactly once per days-mode panel (30 across the front page):
$ grep -oc 'data-cooldown-days-slot' docs/_build/html/index.htmlThe bypass caveat note renders for the 20 panels where bypass is a no-op (pip + pipx, every scope row):
$ grep -oc 'lm-mcp-install__cooldown-note' docs/_build/html/index.htmlTest plan
uv run pytest --reruns 0— widget unit tests cover 4-axis cross product, per-tool flag injection, pip/pipx bypass caveat notes, andPanel.pip_prereqbeing method-scoped.uv run ruff check .+uv run ruff format --check .just mypy— strict-typed widget framework passes.just build-docs— all dirhtml targets build; the pre-existing duplicate-ID warnings indocs/tools/**/*.mdare unrelated.dayswith the default 7, snippet shows--exclude-newer P7D.30in the days input → all visible snippets update toP30Dbefore blur (inputevent hook).uvx --no-config libtmux-mcp(CLI) orenv: { "UV_NO_CONFIG": "1" }(JSON config)./, navigate to/clients/→ cooldown state survives the gp-sphinx soft-nav.