Skip to content

feat: add tip rotation system with version-update and tip widgets#287

Open
Avimarzan wants to merge 7 commits intosirmalloc:mainfrom
Avimarzan:feat/tip-rotation-widgets
Open

feat: add tip rotation system with version-update and tip widgets#287
Avimarzan wants to merge 7 commits intosirmalloc:mainfrom
Avimarzan:feat/tip-rotation-widgets

Conversation

@Avimarzan
Copy link
Copy Markdown

Summary

Adds a tip rotation system that surfaces useful tips from Claude Code changelogs directly in the status line. Includes:

  • 2 new widgets:
    • version-update — shows Updated: vX.Y.Z → vA.B.C from the latest non-expired tip file
    • tip — shows a rotating tip from the merged pool of all non-expired tip files (💡 emoji prefix, sequential rotation)
  • Core utility module (src/utils/tips.ts):
    • Sync storage for tip files, last-version tracking, and tip index (matches existing codebase patterns)
    • Semver comparison with pre-release suffix handling
    • Tip pool merging, rotation with in-memory caching (only writes to disk on actual rotation, not every render)
    • Changelog fetching from GitHub Releases API
    • Async tip generation via claude --print with <TIPS> marker extraction
    • Pipeline orchestrator: version check → changelog fetch → tip generation → file write → expiry cleanup
  • Config schema extension (TipsSettingsSchema):
    • enabled, tipDir, rotateEvery, expiryDays, maxTipLength, minTips — all with sensible defaults
    • Settings version bumped 3→4 with automatic migration
  • TUI config screen (TipsMenu):
    • Toggle enabled/disabled, edit numeric settings
    • Browse all tips grouped by version with age display
    • Generate tips for current Claude Code version
    • Rotate now, clear all tips
  • Hook handler integration: handleHook() checks for version changes and triggers tip generation when tips.enabled is true

New files

File Purpose
src/widgets/Tip.ts Tip rotation widget with hideWhenEmpty toggle
src/widgets/VersionUpdate.ts Version update notification widget
src/utils/tips.ts Core tip utility (storage, rotation, generation, cleanup)
src/types/TipData.ts Zod schemas for TipFile, LastVersion, TipIndex
src/tui/components/TipsMenu.tsx TUI configuration screen
src/test-helpers/tips.ts Shared test helpers

Modified files

File Change
src/types/Settings.ts Added TipsSettingsSchema, bumped version 3→4
src/utils/migrations.ts Added v3→v4 migration
src/utils/widget-manifest.ts Registered version-update and tip widgets
src/widgets/index.ts Exported new widgets
src/tui/App.tsx Added Tips screen routing
src/tui/components/MainMenu.tsx Added Tips menu item
src/tui/components/index.ts Exported TipsMenu
src/ccstatusline.ts Extended hook handler for version checking

Test 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

  • Additive only — no modifications to existing widget behavior or rendering pipeline
  • Follows existing patterns — sync filesystem I/O (matches skills.ts), Zod schemas, widget interface, TUI component structure
  • Performance-conscious — tip pool cached in memory, disk writes only on rotation (not every render)
  • Async tip generation — uses promisify(execFile) to avoid blocking the event loop in the TUI

Test plan

  • Verify bun test passes (44 tip tests + existing suite)
  • Add tip and version-update widgets via TUI, confirm they render
  • Test TUI Tips menu: browse, generate, rotate, clear
  • Test with no tip files (widgets should show placeholder or hide)
  • Test expiry cleanup with old tip files

🤖 Generated with Claude Code

Avimarzan and others added 2 commits April 5, 2026 22:36
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.
@Avimarzan
Copy link
Copy Markdown
Author

Heads up — I pushed a follow-up commit (e6f474b) that fixes a bug I found while using this branch: the version-update widget froze at an old version and never caught up as Claude Code shipped new releases.

Root cause: checkVersionAndGenerateTips was wired into the --hook handler (handleHook in src/ccstatusline.ts) behind an if (data.version) guard. But Claude Code's PreToolUse and UserPromptSubmit hook payloads do not include a version field — only session_id, transcript_path, cwd, permission_mode, hook_event_name, tool_name, tool_input, tool_use_id. The guard was always false, so the pipeline never ran from the hook path. (Confirmed by logging the raw hook payload to disk across several sessions.)

Fix: Claude Code does pass version to the statusline render path — StatusJSONSchema.version is already declared, and renderMultipleLines(data) already receives it. So I moved the trigger into renderMultipleLines:

  1. Sync readLastVersion() compare inline — cheap, safe in the render hot path.
  2. On mismatch, spawn a detached child (process.execPath, [argv[1], '--update-tips', version], detached: true, stdio: 'ignore', windowsHide: true) and .unref() it. The child runs the full checkVersionAndGenerateTips pipeline out-of-band.
  3. New --update-tips <version> CLI mode in main() is what the detached child invokes.
  4. Removed the now-dead if (data.version) block from handleHook (skill tracking above it stays).

Why detached spawn, not a bare void promise: checkVersionAndGenerateTips calls claude --print as a subprocess which can take ~30s, and fetches the GitHub changelog. A bare void checkVersionAndGenerateTips(...) wouldn't survive: after renderMultipleLines returns and main() exits, Node tears down the process and kills the in-flight async work. detached: true + stdio: 'ignore' + .unref() lets the child outlive the parent. This also keeps the render path fast — the statusline draws immediately while the tip pipeline runs in the background, and the next render picks up the new tips_{version}.json.

Verified end-to-end on Windows 11: after clearing ~/.cache/ccstatusline/last-version.json and tips_*.json, opening a fresh CC session triggers the detached child, tips regenerate in ~30s, and the next render cycle shows Updated: v2.1.92 → v2.1.97. Unit tests (tips.test.ts, 29 passing) and bun build both green.

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.
@Avimarzan
Copy link
Copy Markdown
Author

Pushed 7ef5a80 on feat/tip-rotation-widgets fixing two more bugs I hit in real use after the earlier fixes in this PR:

Bug 1 — Rotation never advanced for rotateEvery > 1. advanceTipRotation kept _renderCount in module scope and only persisted it to disk when it crossed the threshold. But Claude Code spawns ccstatusline as a fresh Node process for every statusline refresh (it's wired as statusLine.command), so the module cache died between renders. Each call read renderCount=0 from disk, bumped to 1 in memory, failed the >= rotateEvery check, exited. The rotation pointer was permanently stuck on tip[0]. The unit tests missed this because they ran inside one Node process where the cache survived — they didn't model the cross-process reality.

Fix: drop the module-scope counters and round-trip rotation state (index + renderCount) through tip-index.json on every render. Only the merged pool stays in-process (it's a cold cache anyway). The extra writeFileSync per render is on a ~100-byte file and runs alongside the existing skills/last-version writes — negligible. Added rotates correctly across simulated fresh processes which calls resetTipRotationCache() between each advanceTipRotation() to mimic the process respawn.

Bug 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 and the rotation pool ended up with just .97 tips. The version-update widget also jumped straight from .92 → .97 with no intermediate history.

Fix: new listReleasesBetween(prev, current, cap=10) that hits 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 tips_*.json already exists (idempotent) — with previousVersion threaded through each generated file so the widget can walk the history. Fallback to [currentVersion] on API failure, empty list, or fresh install. Runtime is bounded because the catchup already runs in the detached --update-tips child (Phase 5.1), so the statusline render is never blocked.

Both fixes live in src/utils/tips.ts + tests in src/utils/__tests__/tips.test.ts — 31/31 passing, build succeeds.

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>
@Avimarzan
Copy link
Copy Markdown
Author

Phase 5.4: Deterministic tip text via parse + batch polish pipeline

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. Wipe and regenerate the same version → different tips every time.

Fix — split tip generation into two phases:

  1. parseChangelog(changelog) — pure, deterministic regex. 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 and 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/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 now runs exactly once per version per machine and the result is frozen to tips_<version>.json. Count, order, and which-bullets-survive are deterministic.

Also retired minTips — tip count is now determined by changelog bullet count, not a user-configured floor. Removed from TipsSettingsSchema, TipsMenu, and test fixtures. Bumped CURRENT_VERSION from 4 to 5 (zod permissively drops unknown keys, so no migration needed).

Tests: added parseChangelog golden fixture and generateTips no-parseable-bullets case. 35/35 green in tips.test.ts. Build clean.

Commit: fcc80e3 on feat/tip-rotation-widgets.

… 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>
@Avimarzan
Copy link
Copy Markdown
Author

Phase 5.4 follow-up: timeout, windowsHide, widget version range

Three fixes after live testing the parse + polish pipeline:

  1. polishBullets timeout 30s → 60s — v2.1.101 changelog (51 bullets) was timing out at 30s, preventing tip file creation.

  2. windowsHide: true on claude --print — the polish subprocess was opening visible console windows on Windows.

  3. VersionUpdate widget rewritten — now shows the version range of tip files on disk (e.g. Tips: v2.1.97 → v2.1.101) instead of the misleading Updated: previousVersion → version from the latest tip file. Single tip file shows Tips: v2.1.98.

Commit: d702dd4 on feat/tip-rotation-widgets.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant