Skip to content

fix(renderer): end a merge at a hidden widget that isn't merged onward - #698

Merged
sirmalloc merged 4 commits into
sirmalloc:mainfrom
eric-engberg:fix/merge-across-hidden-widgets
Oct 26, 2026
Merged

sirmalloc merged 4 commits into
sirmalloc:mainfrom
eric-engberg:fix/merge-across-hidden-widgets

Conversation

@eric-engberg

@eric-engberg eric-engberg commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

What

A merge now ends at a hidden widget unless that widget is merged onward too. With A (merged) → B → C, when B renders nothing:

B's own merge Before After
none A joins C: no separator, and in Powerline C shares A's segment A and C keep the usual separator, or their own segments
merged on to C A joins C A joins C (unchanged)

Only layouts with a hidden widget right after a merged one change. Merges between widgets that both render, and merges into an explicit separator, behave as before.

Why

A merge is set between two neighbouring widgets. When B hides itself (a widget hidden at zero, one without data yet, a command with no output), the renderer handed A's merge on to whatever came after B, even though A was never merged with it. For example, a model widget merged into an icon or label that's sometimes empty would glue itself to the next widget whenever that one disappeared:

  • the separator between them vanishes;
  • in Powerline, they share one segment, and because the merge stops the theme from advancing, every later segment shifts one theme color, then shifts back when B shows again.

Making the merge follow how the line was set up fixes both: A's merge reaches C only if B is merged on to C, which is the case where the user did connect the whole chain.

How

  • The Powerline renderer, the plain renderer and the auto-align width pass each decided "merges with the next rendered widget" separately. All three now stop at an empty widget that has no merge of its own and carry on past one that does, so the line and its column widths can't disagree.

  • Plain mode: the default separator and the no padding merge's padding now follow whether the merge actually reaches the next widget, instead of only the widget's own setting. An explicit separator after a hidden widget is kept unless every hidden widget before it is merged onward.

  • This reverses three tests that pinned the old behavior:

    Each now has a pair: the unmerged empty widget ends the merge, and an empty widget that is merged on keeps the old joining. The fix(renderer): collapse separators around empty widgets #344 comment described the glue as the intended result; with this change that result stays available by merging the middle widget on as well.

  • Docs: the line editor's m entry in USAGE.md explains the rule.

Update: End a merge at a hidden widget in the next-line counters too

  • This PR ends a merge at a widget that renders nothing and isn't merged onward. The renderer already follows that rule, but the two per-line counters that tell the next line where to continue did not: countSeparatorSlots (src/utils/separator-index.ts, the separator cycle) and assignPowerlineThemeSlots (src/utils/powerline-theme-index.ts, theme colors with Continue Theme Across Lines). They still carried a merge past every empty widget. So after [A (merged), empty widget, B] they came up one separator and one theme color short of what the line drew, and the next line started one step behind: its first segment repeated the previous segment's theme color, and its first separator glyph repeated too.
  • Both counters now drop a pending merge when they skip an empty widget that isn't merged onward. A merge still carries across an empty widget that is merged onward itself, which matches the renderer. The piped status line and the TUI preview both use these counters, so both now continue the cycles where the previous line ends. Line 1 itself renders exactly as before.
  • Nothing else changes. Plain mode has no separator cycle or theme colors, so it is unaffected. The custom theme picker also calls assignPowerlineThemeSlots, but it treats every widget as shown, so the new branch never runs there.
  • One existing test, separator-index.test.ts 'honors merge state on the previous rendered widget across hidden widgets', asserted the old count for [A merge, hidden, B]. Its hidden widget is now merged onward, so it still expects 0 and covers the carry-through case. New cases cover a merge ending at a hidden widget.

Demo

A fake payload with two lines: Model merged into an empty Custom Command (true), then Context Length and Git Branch. On line 1 the empty command isn't merged on; on line 2 it is.

Powerline, before and after:

Merged widgets across a hidden widget before the change, Powerline mode

Merged widgets across a hidden widget after the change, Powerline mode

Plain, before and after:

Merged widgets across a hidden widget before the change, plain mode

Merged widgets across a hidden widget after the change, plain mode

Update

Piped status line from the fake example payload (scripts/payload.example.json) with two separators (arrow, round) and Nord Aurora with Continue Theme Across Lines. Line 1 merges Model into an empty Custom Command that isn't merged onward, and below it the same lines are rendered with Model not merged, for reference. Before, line 2 starts one step behind: v2.1.80 repeats the blue of line 1's last segment and the round separator comes first. After, line 2 continues the cycle (green, arrow first) and matches the reference. The plain stills are identical before and after.

Powerline, before and after:

Before, Powerline

After, Powerline

Plain, before and after:

Before, Plain

After, Plain

The TUI's Main Menu preview of the same lines, using the widgets' built-in sample data. The hidden widget here is an empty Custom Text, because a Custom Command shows a sample value in the preview and so never hides there. Before, line 2 repeats the blue of 'main' and starts with the round separator. After, it continues on green with the arrow, the same as the piped status line. Plain is identical before and after.

Powerline, before and after:

Before, Powerline

After, Powerline

Plain, before and after:

Before, Plain

After, Plain

Testing

  • Separator collapse (plain): merge: true and merge: 'no-padding' each end at an unmerged empty widget before an explicit separator (A | C) and carry across a merged one (same as without the empty widget); with the default separator, an unmerged empty widget keeps the separator and padding (same as an unmerged line) and a merged one drops the separator.
  • Powerline: a merge across a merged empty widget keeps the old joined output and width; across an unmerged one, output and auto-align widths equal the unmerged line; with a theme, colors stay in place when a widget after a merge renders empty.
  • bun test: 2789 pass, 0 fail. bun run lint passes.
  • Under Node (Vitest): both changed test files pass, 43 of 43 (36 of 36 on main).
  • Built CLI under Bun 1.4.2 and Node 26.10.0 with the demo configs: all runtimes give identical output in plain and Powerline modes; it differs from main only on line 1, as in the demo. The TUI opens and exits cleanly under both.
  • After merging today's main: bun test 3062 pass, 0 fail, bun run lint passes, and the built CLI's plain and Powerline renders match main under Bun 1.4.2 and Node 26.10.0.

Update:

  • bun test (Bun 1.4.2): 3065 pass, 0 fail, 8253 expect() calls across 173 files. bun run lint (tsc + ESLint, max warnings 0) is clean.
  • New and updated cases:
    • separator-index.test.ts 'ends a merge at a hidden widget that is not merged onward': expects 1 slot for [A merge, hidden unmerged, B]. It got 0 before the fix.
    • powerline-theme-index.test.ts, same name: expects 2 slots. It got 1 before the fix.
    • powerline-theme-index.test.ts 'carries a merge across a hidden widget that is merged too': expects 1 slot, before and after, so the counter doesn't reset on every hidden widget.
  • Under Node (v26.10.0) via vitest: separator-index.test.ts and powerline-theme-index.test.ts, 2 files, 19 tests passed.
  • Piped render, with lines [A merged, empty widget, B] / [C, D], separators and , and Nord Aurora with Continue Theme Across Lines: line 2 now renders the same whether A is merged or not (first separator , backgrounds 68, 108, 108). Before the fix, merging A made line 2 reuse and put C on B's color (220). Line 1 is byte-identical in both cases.
  • A 5000-case randomized check compared both counters with what renderStatusLine actually draws. Without the fix it found 340 separator and 340 theme-color mismatches; with the fix, 0 of each.
  • Demo stills recorded from both refs (status line and TUI preview): Powerline changes as described, and plain is pixel-identical before and after.

A merged widget joined the next widget that rendered anything, skipping over
widgets that rendered nothing. So with Session Usage merged into a widget that
hides itself, the hidden widget took the merge with it to whatever followed:
the separator vanished and, in Powerline, every later segment shifted one
theme color. A merge now carries past an empty widget only when that widget is
merged onward too; otherwise it ends there and the usual boundary returns. The
Powerline, plain and auto-align paths all follow the same rule.
…rs too

The Powerline renderer now ends a merge at a widget that renders nothing
and isn't merged onward: A (merged), an empty unmerged widget, then B
draws a separator between A and B and gives B its own theme color. The
per-line counters that tell the next line where to pick up the separator
and theme-color cycles still followed the old rule and carried the merge
past every empty widget, so they counted one separator and one color
fewer than the line drew.

On a multi-line Powerline status line that put the next line one step
behind: its first separator repeated the previous glyph and, with
Continue Theme Across Lines on, its first segment reused the color of
the segment before it. Git Branch merged with Git Ahead/Behind hits this
whenever the branch is in sync. The TUI preview uses the same counters,
so it showed the same.

countSeparatorSlots and assignPowerlineThemeSlots now drop the pending
merge when they skip an empty widget that isn't merged onward, matching
the renderer. A merge still carries past empty widgets that are merged
onward themselves.
countSeparatorSlots had grown a branch per kind of widget inside its
loop. Each widget's effect on the count now lives in
advanceSeparatorSlots, which keeps the loop to one call and puts the
rules for separators, flex separators, hidden widgets and shown widgets
side by side. No change in behavior.
@sirmalloc
sirmalloc merged commit 8e63775 into sirmalloc:main Oct 26, 2026
3 checks passed
eric-engberg added a commit to eric-engberg/ccstatusline that referenced this pull request Oct 26, 2026
…o it

The docs said to add Block Forecast and Block Limit Timer as separate
widgets, since a merge carried on across a widget that rendered nothing:
a hidden forecast joined Block Usage to whatever came next. Since sirmalloc#698,
a merge ends at a hidden widget that isn't merged onward, so merging
Block Usage into the forecast (with the forecast's label set to ` →`,
say) reads as one value while the forecast shows and leaves the line as
it was while it's hidden.
axisrow added a commit to axisrow/ccstatusline that referenced this pull request Oct 27, 2026
…it names, sirmalloc#717 claude-settings test isolation, sirmalloc#705 jj detached HEAD, sirmalloc#703 hooks save warning, sirmalloc#700 dev reload, sirmalloc#698 renderer merge ends at hidden widget, sirmalloc#697 block reset timer under-limit hide, sirmalloc#745 dev-deps bumps

Conflict policy: upstream infra wins, fork features ported on top (fork-only
src/daemon/, src/widgets/editors/*, parity fixtures and fork widgets kept).
Notable joins: sirmalloc#703 returns SkippedWidgetHooks through the fork's
saveSettingsTo wrapper; sirmalloc#697 keeps the fork's zero hide state alongside
upstream's under-limit state; sirmalloc#698 replaces the fork's merge-glue palette
semantics with upstream's merge-ends-at-unmerged-empty-widget rule;
dev-reload (ctrl+r) composes with the fork's first-run preset picker.

Co-Authored-By: Claude Code <[email protected]>
sirmalloc added a commit that referenced this pull request Oct 29, 2026
…imer) (#699)

* feat(usage): forecast the 5-hour window from the recent pace

* feat(usage): keep a per-profile history of session usage readings

* feat(usage): record session usage and forecast once per render

* feat(usage): add the Session Forecast widget

* feat(usage): add the Session Limit Timer widget

* docs: describe Session Forecast and Session Limit Timer

* test(usage): load Session Limit Timer's test the way the reset timer tests do

* fix(usage): ignore a lower session percent from a stale session

* test(usage): check the forecast's place mid-line, and document separate placement

* test(usage): check the TUI preview never records a forecast reading

* refactor(usage): call the forecast widgets Block Forecast and Block Limit Timer

ccstatusline uses "session" for two things: the 5-hour usage window and
the Claude Code conversation (Session Clock, Session Cost, Session Name).
The timers for the 5-hour window already say "block" (Block Timer, Block
Reset Timer), so these two say it too: Session Forecast becomes Block
Forecast (`block-forecast`) and Session Limit Timer becomes Block Limit
Timer (`block-limit-timer`), with their files, classes, descriptions and
docs. Neither has been released, so no saved setting needs migrating.

The helpers that record and forecast the window keep their names
(session-forecast.ts, session-usage-history.ts, the history file): they
follow the usage data's `sessionUsage` field, which isn't user-facing.

* test(block-forecast): check the line without the usage widget's label

The test placed Block Forecast after Session Usage and matched the line
against "Session: 42.0%", so it pinned that widget's label rather than
the forecast's place on the line. Show the usage widget's raw value
instead; the test checks the same thing whatever the label is.

* docs(block-forecast): refer to the 5-hour usage widget without its name

The docs said the forecast widgets sit right after Session Usage, which
another change renames Block Usage. Describe it as the 5-hour usage
widget instead, so the text reads right with either name; two test
titles follow.

* fix(block-forecast): label the limit timer as a countdown

Block Limit Timer printed `Limit: 1hr 13m`, which reads as the size of
the limit rather than the time until it's reached. It now prints
`Limit in: 1hr 13m` (`Limit in: 1h13m` short). Raw value mode is
unchanged.

* fix(usage): stop the forecast history's temp files piling up

The 5-hour usage history is saved by writing a temp file named after the
process id and renaming it over session-usage-history.json. When the
rename failed, for example with EPERM while a Windows virus scanner or
sync client held the new file, the error was ignored and the temp file
stayed. Every status line render is a new process with a new temp name,
so ~/.cache/ccstatusline gained another session-usage-history.json.<pid>.tmp
about once a minute, and nothing ever removed them.

A failed write now unlinks its temp file, ignoring a failed unlink. The
handle that broke the rename can refuse that unlink too, and earlier
builds have already left orphans behind, so each write also removes the
history's other <pid>.tmp files that are a minute or more old. The age
keeps it off a temp file another render is still writing. While a handle
stays held, only the last minute's temp files remain, and those from
earlier builds go on the next write.

Each render keeps its own temp name, so two renders writing at once
never write into the same file.

* fix(usage): forecast an idle session's block from the latest reading

Sessions of one Claude config directory share the 5-hour history. An
idle session's payload keeps the percent from its own last response, so
while another session took the account from 30% to 50%, the idle one
still said 30%. The history already ignores that lower percent, but the
forecast still put it at now, after the 50% reading. The rise over the
last 30 minutes came out negative or too small, so in the idle session
Block Forecast and Block Limit Timer showed nothing, or a projection and
limit time well behind the active session's for the same account.

Usage never falls within a block, so the forecast now goes from the
higher of the session's percent and the latest reading already seen. It
hands that percent on, and Block Forecast compares its projection with
it rather than with the session's own when deciding whether the
projection only repeats the current value, so both sessions hide the
same near-flat projection.

* fix(usage): keep another login's 5-hour block apart in the forecast history

The forecast history is kept per Claude config directory, and a reading
joined any window of it whose reset time was within an hour. After
/login to another account in the same config directory, that account's
block resets at a different time, and when that was within the hour of
the first account's reset, its readings landed in the first account's
window. Being lower than that account's last reading, they were all
dropped as a stale session's, and Block Forecast and Block Limit Timer
showed nothing, then too little once the new account passed the old
one's percent, until the old block reset.

One block's reset only differs between the usage API and the status
payload by fractions of a second, so a reading now joins a window only
when its reset is within a minute. Another login's block gets a window
of its own, unless both blocks reset at the same moment. The 5-hour
resets the usage API has sent so far fall on a whole 10-minute mark; if
blocks are rounded that way, two unrelated ones can share a reset
exactly, and no tolerance tells those apart.

* fix(usage): stop an idle session's refresh renders from flattening the forecast

Claude Code's refreshInterval, 10 seconds on a default install,
re-renders an idle session with the 5-hour percent from its last
response. An unchanged percent moved its reading's last-seen time to now
once a minute, so a session idle from 12:50 to 14:30 recorded "20% until
14:29". When usage on claude.ai or another machine meanwhile took the
account to 60%, the next response's reading made the whole climb look
like it happened in the last 30 minutes, instead of being spread over
the gap as a stretch without renders is: Block Limit Timer said "Limit
in: 30m" rather than 1hr 40m, and at 40% Block Forecast said →100.0%
with a limit time instead of →70.0%.

A percent from the payload now moves its reading's last-seen time no
later than the session's last API response: the timestamp of the
transcript's last assistant message, leaving out the ones Claude Code
writes itself, such as an API error's. The transcript's write time
wouldn't do: a prompt is written before its response arrives, and so is
a local command's output, while the renders they trigger still carry the
old percent. When the transcript holds no response, as a new one after
/clear doesn't until its first, the percent is from before it began, and
the reading's last sighting stays where it was. An idle stretch
therefore stays a gap. A percent from the usage API, used when the
payload has none, is current and still moves the reading to now. The
transcript is only read once a reading's last sighting is a minute old.

* fix(usage): keep a carried-over bar off the forecast widgets' editor rows

Changing a widget's type in the line editor keeps its metadata. Coming
from Block Usage or a reset timer in a bar mode, Block Limit Timer's row
read "(long bar, inverted)" while it rendered "Limit in: 1hr 13m", and
Block Forecast's "(long bar, remaining)" while it rendered "→16.8%".
Neither widget draws a bar or offers (p) to clear one, and the bar mode
also hid "compact" on Block Limit Timer, which still applied it.

Both rows now go through a shared helper for text-only usage widgets
that leaves out the bar mode, and inverting its fill unless the widget
shows used or remaining, before naming the modifiers.

* fix(usage): date a cached usage API percent by when the API sent it

When the payload carries no 5-hour percent, the forecast history took
the usage API's as seen at the render. fetchUsageData serves its cache,
though: up to three minutes old normally, and during a rate limit or an
API failure, whatever the cache holds, which can be hours old. Each
render then moved that old reading's last sighting to now. The history
showed the percent unchanged until fetching recovered, which flattened
the forecast meanwhile, and the climb since then looked like it happened
in the last few minutes, which exaggerated the pace afterwards.

fetchUsageData now keeps when the API sent the values it returns: now
for a fresh answer, the cache file's write time for a cached one.
getUsageFetchedAtMs exposes it, and the history dates an API percent by
it, as it already dates a payload percent by the transcript's last
response. The stretch since then stays a gap that a later change is
spread across.

A new percent is now also dated by when it was seen rather than when it
is first written, from either source: a profile can find another
profile's fetch in the shared usage cache, say. It is never dated before
the last reading's last sighting.

* fix(usage): lock the forecast history while a render rewrites it

The renders of every session and profile share
session-usage-history.json. A render read the whole file, added its
reading and renamed a new file over it, so when two renders overlapped,
the later rename put back what it had read: the other render's new
reading was lost, or a whole profile it had just added. Unique temp file
names only kept the JSON from tearing.

A render with something to write now creates
session-usage-history.json.lock exclusively, reads the history again
under it, writes, and removes the lock. A render that finds the lock
held doesn't wait: it forecasts from what it read and leaves the write
to its next render, which records the same percent with the same time,
since a reading is dated by when its percent was seen. A lock more than
10 seconds old, or dated that far ahead, was left by a render that died
holding it, and is taken over. Renders with nothing to write don't take
the lock.

* fix(usage): keep each login's 5-hour block apart in the forecast history

The forecast history was kept per Claude config directory, and a reading
joined the window whose reset was within a minute of its own. After
/login to another account in the same directory, that account's block
can reset at the same moment as the last one's, since the usage API's
resets fall on whole 10-minute marks. Its readings then joined the
first account's window, where a lower percent counted as a stale
session's and was dropped, and the forecast went from the first
account's percent: switching from an account at 100% to one at 10% hid
Block Forecast and Block Limit Timer until the reset.

The history now goes by config directory and login, the login being the
usage cache's fingerprint of its refresh token (#536). That stays the
same when Claude Code reissues the access token every few hours, and
changes with /login. getUsageLoginFingerprint reads it through the usage
widgets' credential lookup, including its 30-second backoff after
finding no login. Without one (a token from the environment, say), the
history goes by the config directory alone, as before.

On macOS that is one keychain read per render that has a forecast widget
and a 5-hour percent; a render whose percent comes from the payload
didn't read the keychain before.

* docs(block-forecast): drop the advice against merging Block Usage into it

The docs said to add Block Forecast and Block Limit Timer as separate
widgets, since a merge carried on across a widget that rendered nothing:
a hidden forecast joined Block Usage to whatever came next. Since #698,
a merge ends at a hidden widget that isn't merged onward, so merging
Block Usage into the forecast (with the forecast's label set to ` →`,
say) reads as one value while the forecast shows and leaves the line as
it was while it's hidden.

---------

Co-authored-by: Matthew Breedlove <[email protected]>
pcvelz added a commit to pcvelz/ccstatusline-usage that referenced this pull request Oct 29, 2026
Upstream: Extra Usage Limit widget (sirmalloc#756), live widget preview while picking (sirmalloc#755), Fast Mode widget (sirmalloc#754), transcript block fallback fixes (sirmalloc#752), editor keys on a global install (sirmalloc#751), git cache TTL (sirmalloc#750), npx cached copy skip (sirmalloc#749), Block Usage hide at 100% (sirmalloc#747), Block Reset Timer hide until 100% (sirmalloc#697), fish-style branch abbreviation (sirmalloc#614), jj detached HEAD (sirmalloc#705), hooks save warning (sirmalloc#703), dev reload (sirmalloc#700), merge ends at a hidden widget (sirmalloc#698), Block Forecast and Block Limit Timer widgets (sirmalloc#699), contributor guide (sirmalloc#706), POSIX path case fix (sirmalloc#707), whitespace-distinct conflict filenames (sirmalloc#709), Quick Start reorder (sirmalloc#715), bunx launch guidance (sirmalloc#714), jj diff summary counts (sirmalloc#711), powerline manual separators (sirmalloc#713), keychain credential by account (sirmalloc#532), Keychain probe timeout (sirmalloc#760), usage cache scoped per CLAUDE_CONFIG_DIR (sirmalloc#266), test isolation (sirmalloc#717, sirmalloc#753), dev-dependency bumps (sirmalloc#745)

Conflicts resolved per the standard-resolution table; fork deletions re-applied.

Hand edits outside conflicts:
- src/utils/global-command-resolution.ts: fork-rename (ccstatusline@ -> ccstatusline-usage@ in upstream's new bunx pattern)
- src/utils/__tests__/global-package-manager.test.ts: fork-rename (package dir ccstatusline-usage in upstream's new tests)
- src/utils/usage-fetch.ts: compat fix - cacheUsageData gained a fetchedAtMs arg (upstream sirmalloc#266); adapted the fork-only fetchFreshUsageData call site
- src/utils/__tests__/usage-fetch.test.ts: compat fixes - cache/lock now hashed per CLAUDE_CONFIG_DIR (usageFilePath helper) in four fork tests; upstream fableUsage renamed to fork's weeklyFableUsage; seedUsageCache takes TokenHome
- src/utils/__tests__/usage-prefetch.test.ts: compat fix - mockFetchUsageData renamed to upstream's mockFetchUsage in cleanly-merged tests
- README.md: took upstream's Quick Start move (sirmalloc#715) and dropped the fork's old copy; fork-rename of its ccstatusline@ specifiers; links to fork-deleted docs (docs/USAGE.md, docs/WINDOWS.md) replaced with the fork's inline text
- CONTRIBUTING.md: links to the fork-deleted docs/DEVELOPMENT.md pointed at the README Development section
- docs/test-retirements.md: ledger entries for the 12 upstream test renames (sirmalloc#697, sirmalloc#698, sirmalloc#700, sirmalloc#705, sirmalloc#713, sirmalloc#760)
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.

2 participants