feat: add tip rotation system with version-update and tip widgets#287
feat: add tip rotation system with version-update and tip widgets#287Avimarzan wants to merge 7 commits intosirmalloc:mainfrom
Conversation
Adds a tip rotation system that generates tips from Claude Code changelogs. Includes two new widgets (version-update, tip), core utility module with caching and async tip generation, Zod schemas, v3→v4 settings migration, hook handler integration, TUI config screen, and shared test helpers (44 tests). Review fixes applied: hideWhenEmpty placeholder (F-1), empty previousVersion guard (F-2), cached tip pool with write-only-on-rotation (F-3), deduplicated semver sort (F-4), async execFile (F-5), path.join (F-6), pre-release semver handling (F-7), ESM imports (F-8), deduplicated defaults (F-11), useState for TUI file reads (F-12), extracted test helpers (F-13), response body drain (F-14). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The version-update widget froze because checkVersionAndGenerateTips was wired into handleHook behind an `if (data.version)` guard, but Claude Code's PreToolUse / UserPromptSubmit hook payloads do not include a version field. The guard was always false, so the pipeline never ran. CC does pass `version` to the statusline render path via StatusJSON, so move the trigger into renderMultipleLines. To keep render fast, only do a sync readLastVersion() compare inline; on mismatch, spawn a detached child (process.execPath + --update-tips <version>, stdio: 'ignore', .unref()) that runs the full pipeline out-of-band. A bare void promise would not survive main() returning — the detached child does. Also remove the now-dead version-check block from handleHook and strip the temporary [DEBUG] logging that was used to confirm the hook payload shape.
|
Heads up — I pushed a follow-up commit (e6f474b) that fixes a bug I found while using this branch: the Root cause: Fix: Claude Code does pass
Why detached spawn, not a bare Verified end-to-end on Windows 11: after clearing Happy to split this into a separate PR if you'd rather keep #287 scoped to the original tip-rotation feature — just let me know. |
When the tip pool merges tips from multiple active version files, the
widget previously gave no indication which version a tip came from, so
users couldn't tell whether a tip was about a recent feature or
something several releases old.
Tag at merge time, not on disk: getMergedTipPool now returns
TaggedTip[] ({ text, version }) instead of string[], pulling the
version from the source file's `version` field. No changes to the
tips_{version}.json schema — existing files keep working untouched.
advanceTipRotation returns TaggedTip | null. The Tip widget appends
a dimmed ` · v<version>` suffix (SGR 2 / 22) after the tip text so it
reads as metadata, and is rendered outside the maxTipLength budget so
the tip text itself keeps its full length. rawValue mode returns
`<text> · v<version>` without ANSI codes.
Tests updated: getMergedTipPool and advanceTipRotation assertions now
compare against TaggedTip objects. All 29 tips.test.ts tests pass.
…rsions Two bugs surfaced after Phase 5.1/5.2 shipped: 1. Rotation never advanced for any rotateEvery > 1. ccstatusline is spawned as a fresh Node process for every statusline refresh via statusLine.command, so the module-scope _renderCount cache died with the process every render. Each call read renderCount=0 from disk, bumped to 1 in memory, failed the threshold check, exited. The rotation pointer was permanently stuck on tip[0]. Unit tests passed because they ran inside a single Node process where the cache survived — they did not model the cross-process reality. Fix: drop module-scope _renderCount and _cachedIndex. Rotation state now round-trips through tip-index.json on every render. Only the merged pool (an in-process cold cache) is kept across calls within one process. The extra writeFileSync per render is on a ~100-byte file, negligible alongside the existing skills/last-version writes. Added a new test 'rotates correctly across simulated fresh processes' that calls resetTipRotationCache() between each advanceTipRotation() to mimic the cross-process cache loss while preserving disk-backed state. 2. Multi-version jumps lost intermediate tips. Jumping 2.1.92 → 2.1.97 (normal after a few days away) fetched only the 2.1.97 changelog; tips for .93/.94/.95/.96 were never generated. The merged pool ended up with only 2.1.97 tips and the version-update widget jumped straight from .92 → .97 with no intermediate history. Fix: new listReleasesBetween(prev, current, cap=10) helper that queries the GitHub releases API (per_page=100), filters to versions strictly > prev and <= current, sorts ascending, caps at the 10 newest on degenerate gaps. checkVersionAndGenerateTips now walks every release in that chain, skipping any version whose tip file already exists (idempotent), with previousVersion threaded through each generated tipFile so version-update can walk the history. On API failure or empty list, falls back to [currentVersion] so fresh installs and error paths still work.
|
Pushed Bug 1 — Rotation never advanced for Fix: drop the module-scope counters and round-trip rotation state ( Bug 2 — Multi-version jumps lost intermediate tips. Jumping Fix: new Both fixes live in |
Phase 5.3 made rotation reliable, but real-world testing exposed a new problem with tip *content*: every regeneration of the same version produced different tip text. The old generateTips handed the raw changelog to claude --print with a blank-canvas prompt, so the LLM freely decided count, phrasing, which bullets to surface, and order. Split tip generation into two phases: 1. parseChangelog(changelog) — pure, deterministic regex extraction. Matches bullet lines, strips markdown (**bold**, \`code\`, links), drops PR refs and author attributions, collapses whitespace, and filters out headers, too-short lines, dependabot bumps, and bare version pointers. Preserves order, preserves emoji prefixes. Same input → same output, always. 2. polishBullets(bullets, settings) — one claude --print call with a strict constrained prompt: max maxTipLength per line, imperative or present-tense, preserve emoji, one output line per input line in the same order. On count mismatch, falls back to truncated raw bullets so we always return exactly N tips. The LLM polish is still non-deterministic per call, but it now runs exactly once per version per machine and the result is frozen to tips_<version>.json. Count, order, and which-bullets-survive are all deterministic. Also retires the minTips setting: tip count is now determined by changelog bullet count, not a user-configured floor. Removed from TipsSettingsSchema, TipsMenu, and the CurrentWorkingDir test fixture. Bumped CURRENT_VERSION from 4 to 5 for cleanliness (zod permissively drops unknown keys on parse, so no migration needed). Tests: added parseChangelog golden fixture and generateTips no-parseable-bullets case. 35/35 green in tips.test.ts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase 5.4: Deterministic tip text via parse + batch polish pipelinePhase 5.3 made rotation reliable, but real-world testing exposed a new problem with tip content: every regeneration of the same version produced different tip text. The old Fix — split tip generation into two phases:
The LLM polish is still non-deterministic per call, but now runs exactly once per version per machine and the result is frozen to Also retired Tests: added Commit: |
… tip version range - polishBullets timeout 30s → 60s: large changelogs (51 bullets for v2.1.101) were timing out at 30s, preventing tip file creation. - Added windowsHide: true to the claude --print execFile call so the polish subprocess no longer opens a visible console window on Windows. - VersionUpdate widget rewritten: shows the version range of tip files on disk (e.g. "Tips: v2.1.97 → v2.1.101") instead of the old "Updated: previousVersion → version" from the latest tip file, which was misleading when the user's actual CC version was higher than the latest tip file version. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase 5.4 follow-up: timeout, windowsHide, widget version rangeThree fixes after live testing the parse + polish pipeline:
Commit: |
Resolve conflicts in widget-manifest.ts, widgets/index.ts, and CurrentWorkingDir.test.ts — add upstream's new worktree widgets alongside our tip/version-update widgets. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
Adds a tip rotation system that surfaces useful tips from Claude Code changelogs directly in the status line. Includes:
version-update— showsUpdated: vX.Y.Z → vA.B.Cfrom the latest non-expired tip filetip— shows a rotating tip from the merged pool of all non-expired tip files (💡 emoji prefix, sequential rotation)src/utils/tips.ts):claude --printwith<TIPS>marker extractionTipsSettingsSchema):enabled,tipDir,rotateEvery,expiryDays,maxTipLength,minTips— all with sensible defaultsTipsMenu):handleHook()checks for version changes and triggers tip generation whentips.enabledis trueNew files
src/widgets/Tip.tshideWhenEmptytogglesrc/widgets/VersionUpdate.tssrc/utils/tips.tssrc/types/TipData.tssrc/tui/components/TipsMenu.tsxsrc/test-helpers/tips.tsModified files
src/types/Settings.tsTipsSettingsSchema, bumped version 3→4src/utils/migrations.tssrc/utils/widget-manifest.tsversion-updateandtipwidgetssrc/widgets/index.tssrc/tui/App.tsxsrc/tui/components/MainMenu.tsxsrc/tui/components/index.tssrc/ccstatusline.tsTest coverage
44 tests across 3 test files, all passing:
src/utils/__tests__/tips.test.ts— 27 tests (storage, rotation, semver, pool, expiry, pipeline)src/widgets/__tests__/Tip.test.ts— 8 tests (rendering, hideWhenEmpty, preview, raw mode)src/widgets/__tests__/VersionUpdate.test.ts— 9 tests (rendering, empty version guard, semver ordering, expiry)Design decisions
promisify(execFile)to avoid blocking the event loop in the TUITest plan
bun testpasses (44 tip tests + existing suite)tipandversion-updatewidgets via TUI, confirm they render🤖 Generated with Claude Code