Skip to content

feat(usage): forecast the 5-hour block (Block Forecast, Block Limit Timer) - #72

Closed
eric-engberg wants to merge 99 commits into
developfrom
feat/session-forecast
Closed

eric-engberg wants to merge 99 commits into
developfrom
feat/session-forecast

Conversation

@eric-engberg

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

Copy link
Copy Markdown
Owner

Standalone, cut from main.

What

Two new Usage widgets forecast the 5-hour block from the recent pace:

  • Block Forecast shows where the block's usage will be when it resets: →83.2%.
  • Block Limit Timer shows how long until 100% at that pace, Limit in: 1hr 13m, but only when that comes before the reset.

Each renders nothing until it has something to say, so they sit right after Block Usage. As separate widgets:

What's happening Line
Under pace 5h: 42.0% →83.2%
On pace to hit 100% before the reset 5h: 61.0% →100.0% Limit in: 1hr 13m
At 100% 5h: 100.0%
Idle, or a block less than 10 minutes old 5h: 42.0%

Block Forecast has u (show remaining, like Block Usage: →16.8%), and Block Limit Timer has s (short time: 1h13m). Both support raw value mode and their own colors; Block Limit Timer defaults to red. Pair them with Block Reset Timer's under-limit hide state (#71) to show the reset time once the limit is hit.

They're named for the block, like Block Timer and Block Reset Timer, rather than the session: ccstatusline also says "session" for the Claude Code conversation (Session Clock, Session Cost, Session Name).

Why

Block Usage says how much of the 5-hour limit is used, not whether you'll run out. 42% two hours in is fine; 42% twenty minutes in means you'll hit the limit well before the block resets. The forecast answers that directly, and the limit timer says how much working time is left if you keep going at this pace.

How

  • Pace: the change in the block's percent over the last 30 minutes, projected to the reset: current + pace × time left, capped at 100%. The limit time is (100 − current) ÷ pace, shown only when it's shorter than the time left. Nothing is shown when the pace is zero or negative (idle), already at 100%, past the reset, or in a block's first 10 minutes.
  • History: the percent from 30 minutes ago comes from a small history file, ~/.cache/ccstatusline/session-usage-history.json. Each 5-hour window keeps a list of readings, [first seen, last seen, percent]. A reading is added when the percent rises; while it doesn't, its last-seen time moves forward at most once a minute, so an active session writes about once a minute and an idle one not at all. A lower percent is ignored: usage never falls within a window, so it can only be another session's stale payload (each session reports the limits from its own last response), and recording it would fake a steep climb half an hour later.
  • Gaps: status lines only render while Claude Code is working, so there can be long stretches with no readings (the machine asleep, or usage on claude.ai or another computer). The value 30 minutes ago is interpolated between the readings either side of it, so a climb during a gap is spread over the gap instead of being treated as "unchanged until the next reading", which would overstate the pace right after it. Before the first reading, the line runs from the window's start at 0% (every window starts there), which gives the whole-window average.
  • Windows and accounts: readings are kept per Claude config directory (CLAUDE_CONFIG_DIR, or ~/.claude), since different logins have different windows. A reading joins the stored window whose reset time is within an hour of its own: one window's reset time jitters between sources (the usage API sends fractional seconds that change between fetches, the payload whole seconds), and different windows are always at least 5 hours apart. Windows past their reset are dropped, and each keeps only the readings the 30-minute lookback can still use.
  • Once per render: the reading is recorded and the forecast computed once, before the widgets render, and only when a line contains one of the two widgets; both read the result from the render context. The data is the same sessionUsage / sessionResetAt Block Usage uses (the payload's rate_limits, or the usage API when the payload lacks them). A failed fetch made only for these widgets isn't shown as an error elsewhere on the line, since they render nothing without data.
  • Failures: a missing, corrupt or other-version history file is an empty history. Writes go to a temp file and are renamed into place; a failed write is ignored and that render still gets its forecast. There's no lock: two sessions writing at once can lose one update, which only nudges the pace.
  • Preview: Block Forecast shows →100.0% and Block Limit Timer Limit in: 1hr 13m, a consistent "on pace for the limit" sample. Only a real status-line render records a reading; browsing the TUI never touches the history.

Update: Stop the forecast history's temp files piling up; Forecast an idle session's block from the latest reading; Keep another login's 5-hour block apart in the forecast history; Stop an idle session's refresh renders from flattening the forecast; Keep a carried-over bar off the forecast widgets' editor rows; Date a cached usage API percent by when the API sent it; Lock the forecast history while a render rewrites it; Keep each login's 5-hour block apart in the forecast history; Drop the docs' advice against merging Block Usage into the forecast

  • Refresh renders in an idle session: Claude Code re-renders an idle session's status line with the 5-hour percent from its last response. It does this every 10 seconds on a default install, and again when a prompt is written. That unchanged percent used to move its reading's last-seen time to now. So when usage elsewhere (claude.ai, another machine) raised the percent meanwhile, the next response put the whole climb in the last 30 minutes. For example, after 100 idle minutes at 20%, a response at 60% gave Limit in: 30m instead of 1hr 40m. Now a payload's unchanged percent moves the last-seen time only up to the time of the transcript's last API response. That is its last assistant entry, skipping API-error entries and the <synthetic> entries Claude Code writes itself. The transcript's write time wouldn't work, because a prompt, a tool result or a local command's output is written before the response that brings a new percent. A transcript with no response yet, such as a new one after /clear, leaves the reading's last sighting where it was. A percent from the usage API, used when the payload has none, is current and still counts as seen now. The transcript is read backwards from its end, and only when the percent is unchanged and its last sighting is a minute old. The remaining gaps understate rather than overstate. While only a subagent or a background call is responding, no new response reaches the main transcript, so an unchanged percent doesn't extend. A changed percent still records at once.
  • Idle sessions behind the shared history: sessions of one config directory share the history, but an idle session's payload keeps the percent from its own last response. The history already ignored that lower percent. The forecast, though, still placed it at now, after a higher reading another session had recorded. So the rise came out negative or too small, and the idle session showed nothing, or less than the active session, for the same block. Usage never falls within a block, so the forecast now starts from the higher of the session's percent and the latest reading, ignoring readings dated in the future. It returns that percent as currentPercent. Block Forecast now uses that percent, not the session's own, to decide whether a projection only repeats the current value, so both sessions hide the same near-flat projection. Block Usage still shows each session's own percent.
  • Another login in the same config directory: a reading used to join any stored window whose reset was within an hour of its own; this replaces the hour-wide match described above. After /login to another account, that account's block can reset minutes from the first account's. Its readings then landed in the first account's window and, being lower, were dropped. Until the old block reset, Block Forecast and Block Limit Timer showed nothing, then too little once the new account passed the old one's percent. One block's reset differs by under a second between the usage API (fractional seconds) and the payload (whole seconds). So readings now match a window only within a minute, and the other login's block gets a window of its own. Two blocks that reset at exactly the same moment still share a window. So far the usage API's 5-hour resets have all fallen on whole 10-minute marks, so this can happen. Only an account identity in the history's key would tell such blocks apart.
  • Editor rows after Change Widget Type: a type change keeps the widget's metadata. A Block Usage or reset timer in a bar mode could become a Block Limit Timer whose row read (long bar, inverted) while it rendered Limit in: 1hr 13m, or a Block Forecast whose row read (long bar, remaining). Neither widget draws a bar or offers (p) to clear one. The bar mode also hid compact on Block Limit Timer, which still applied it. Both widgets now use a shared helper for text-only usage widgets, getUsageTextModifierText. Before naming the modifiers, it drops the bar mode, and also the bar's inversion unless the widget shows used or remaining. The leftover metadata stays in the saved config, and rendering already ignores it.
  • Temp files on a failed write: The 5-hour usage history is still saved by writing session-usage-history.json.<pid>.tmp and renaming it over session-usage-history.json. Each render keeps its own temp name, so two renders writing at the same time never write into the same file. When the write or the rename fails, for example with EPERM while a Windows virus scanner or sync client holds the new file, the render now unlinks its temp file. A failed unlink is ignored, and the status line renders either way. The handle that broke the rename can block that unlink too, and earlier builds have already left orphans in ~/.cache/ccstatusline. To clear those, each write now lists the cache directory and removes the history's other session-usage-history.json.<digits>.tmp files whose mtime is a minute or more old. Errors from listing, stat or unlink are ignored, and a later write tries again. The one-minute age keeps the sweep away from a temp file that another render is still writing. While a handle stays held, only temp files from the last minute remain, and older ones go on the next write. The listing, stat and unlink calls are injected deps (readdirSync, statSync, unlinkSync), like the module's other file calls, so tests run against an in-memory directory. The sweep runs only on renders that actually write the history, about once a minute in an active session, so other renders don't pay for a directory listing.
  • Cached usage API percents: this replaces "A percent from the usage API, used when the payload has none, is current and still counts as seen now" above. fetchUsageData serves its cache: 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 took that percent as seen now and moved the old reading's last sighting forward, which flattened the forecast meanwhile. Once fetching recovered, the climb since then looked like it happened in the last few minutes. 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 dates a payload percent by the transcript's last response. A new reading is now also dated by when its percent was seen rather than when it is first written, and never before the last reading's last sighting.
  • Concurrent writes: this replaces "There's no lock" above. The renders of every session and profile share the history file. A render read it whole, 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. 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 readings are dated by when their 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, most of them, don't take the lock.
  • A login whose block resets at the same moment as another's: this replaces "Two blocks that reset at exactly the same moment still share a window" above. Readings are now kept per config directory and login. The login is the usage cache's fingerprint of its refresh token (fix(usage): fingerprint the usage cache by refresh token so a routine token refresh is not read as an account switch sirmalloc/ccstatusline#536), which 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 an OAuth login (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; renders whose percent comes from the payload didn't read the keychain before.
  • Merging Block Usage into the forecast: with fix(renderer): end a merge at a hidden widget that isn't merged onward #73 (upstream fix(renderer): end a merge at a hidden widget that isn't merged onward sirmalloc/ccstatusline#698) merged, a merge ends at a hidden widget that isn't merged onward. The docs no longer say to add the forecast widgets separately rather than merging Block Usage into them, and the "Fully configured" layout below works on main as it is.

Demo

Fake data: a seeded reading history and payloads with the reset 1hr 43m away, in four situations.

Powerline

Block Forecast and Block Limit Timer in four situations, Powerline mode

Plain

Block Forecast and Block Limit Timer in four situations, plain mode

Adding both after Block Usage with the picker's search:

Powerline

Adding Block Forecast and Block Limit Timer, Powerline mode

Plain

Adding Block Forecast and Block Limit Timer, plain mode

Fully configured

How the widgets are meant to be used together, with #71 (Block Reset Timer can hide until the 5-hour block hits 100%) and #73 (a merge ends at a hidden widget that isn't merged onward) applied as well:

  • Block Usage merged without padding into Block Forecast, whose label is set to →, so the forecast reads as part of the percent: 5h: 42.0% →83.2%;
  • Block Limit Timer next, which appears only when the limit comes before the reset;
  • Block Reset Timer with "when the 5-hour block is under 100%" checked, which takes over once the limit is hit.

Without #73, merging Block Usage into the forecast isn't advisable: whenever the forecast hides, the merge would carry on to the next widget. The → label works without customizing as well, and only the leading space is added here for the merged layout.

The same four situations, fake data:

Powerline

The fully configured forecast line in four situations, Powerline mode

Plain

The fully configured forecast line in four situations, plain mode

The line editor holding that line:

Powerline

The line editor with the fully configured forecast line, Powerline mode

Plain

The line editor with the fully configured forecast line, plain mode

Update

Block Usage, Block Forecast and Block Limit Timer piped through one session's renders across an idle stretch, with fake data in a scratch HOME. The last response, 100 minutes ago, saw 20%; a refresh render and a new prompt repeat that 20%; then the new response says 60% (second row: 40%). Before, the whole climb counts as the last half hour: Limit in: 30m, and at 40% a false →100.0% | Limit in: 1hr 30m. After, it is spread over the idle stretch: Limit in: 1hr 40m, and →70.0% with no limit time.

Powerline, before and after:

Before, Powerline

After, Powerline

Plain, before and after:

Before, Plain

After, Plain

Three sessions of one login sharing the 5-hour history, piped with fake data: the active session that took the block from 30% to 50% over 40 minutes, and two idle sessions whose payloads still say 40% and 30%. Before, the idle sessions show an understated →70.0% or nothing at all. After, all three show the active session's →100.0% | Limit in: 1hr 40m (Block Usage still shows each session's own payload percent).

Powerline, before and after:

Before, Powerline

After, Powerline

Plain, before and after:

Before, Plain

After, Plain

Piped with fake data after /login switched accounts in one config directory. Login A was last seen at 80% (reset in 1hr). Login B's block resets 40 minutes later and has climbed 1% a minute for 45 minutes, to 55% (second row: 81%). B's earlier renders were replayed at their times through each branch's own history code. Before, B's readings were dropped into A's window: nothing at 55%, and an understated →83.0% at 81%. After, B's own pace shows: →100.0% | Limit in: 45m and →100.0% | Limit in: 19m.

Powerline, before and after:

Before, Powerline

After, Powerline

Plain, before and after:

Before, Plain

After, Plain

The line editor starts with two Block Usage widgets drawn as long bars showing what's remaining; the preview uses the widgets' sample data. Change Widget Type turns the first into Block Limit Timer, then (s)hort is toggled, and turns the second into Block Forecast. The preview then shows plain text. Before, the rows still read (long bar, inverted) and (long bar, remaining), and compact doesn't appear. After, they read (compact) and (remaining).

Powerline, before and after:

Before, Powerline

After, Powerline

Plain, before and after:

Before, Plain

After, Plain

Piped with fake data after /login switched accounts in one config directory, with both blocks resetting at the same moment. Login A took its block to 100%, then login B climbed 1% a minute for 45 minutes, to 55%. Every earlier render was replayed at its time through each branch's own CLI. Before, B's readings joined A's window, and the forecast went from A's 100%, so nothing shows. After, B's own pace shows: →100.0% | Limit in: 45m.

Powerline, before and after:

Before, Powerline

After, Powerline

Plain, before and after:

Before, Plain

After, Plain

Piped with fake data, with the 5-hour percent from the usage API (the payload has none). The API said 40% an hour ago, and a rate limit kept every render since, one every 10 minutes, on that cached 40%. Now a fetch says 60%. Every earlier render was replayed at its time through each branch's own CLI. Before, the cached 40% counted as seen until 5 minutes ago, so the climb to 60% came out as the last half hour's: →100.0% | Limit in: 1hr. After, it is spread over the hour since the 40% was fetched: →93.3%.

Powerline, before and after:

Before, Powerline

After, Powerline

Plain, before and after:

Before, Plain

After, Plain

Testing

  • Forecast math (15 tests): watched continuously, a gap spread evenly, the window-start fallback, a window younger than 30 minutes, exactly and just under 10 minutes old, landing exactly on 100% at the reset, over 100% with the limit time, idle, falling, at 100%, a reset now or past, a reading from a clock that moved back, a last-seen time past now.
  • History file (17 tests): a new reading on a rise, a lower percent from a stale session ignored, the last-seen time moving after a minute and not before, window matching across the API's fractional-second reset, a new window, dropping reset windows and empty profiles, pruning to what the lookback can use, staying bounded when the percent changes every minute for an hour, separate profiles, corrupt / other-version / malformed files, clamping, a failed write.
  • Once-per-render step (11 tests): nothing without a forecast widget, recording for either widget, the config-directory key (with and without a trailing slash), and nothing (no file access) with a missing, unparsable or past reset time, a missing percent, or no usage data.
  • Widgets: every shown and hidden state, raw value, remaining and short-time modes, the number format, previews, keybinds and editor rows; a renderer test showing a hidden Block Forecast between Block Usage and another widget leaves the line exactly as if it weren't there, with a default separator and in Powerline mode; the prefetch requirements.
  • TUI preview: rendering both widgets in the preview never calls the recording function.
  • bun test: 3129 pass, 0 fail. bun run lint passes. The new test files never write to HOME (run with HOME pointed at an empty directory).
  • Under Node (Vitest): the 6 new test files pass, 70 of 70; the 3 changed files pass, 77 of 77.
  • Built CLI under Bun 1.4.2 and Node 26.10.0, with a seeded history in each of the four situations: all runtimes give identical output in plain and Powerline modes; it differs from main only where the new widgets show. The TUI opens and exits cleanly under both.
  • Countdown label (latest commit: Block Limit Timer prints Limit in: 1hr 13m, which reads as the time until the limit rather than its size): bun test 3129 pass, 0 fail; bun run lint passes; under Node (Vitest) the two changed test files pass, 13 of 13; the built CLI matches main with the baseline configs under Bun and Node. The media is re-recorded for the new label.

Update:

  • New regression tests, each failing before its fix: a refresh render, or a prompt written before its response, no longer moves a reading's last-seen time past the transcript's last response, and a transcript with no response keeps the last sighting; the forecast after an idle stretch spreads the climb over it (limit in 100 minutes, was 30); the forecast starts from the latest reading when the session's percent is behind it (projected 83.2%, was 52.17%); an idle session's forecast matches the active session's (was none); Block Forecast hides a near-flat projection in both sessions (the idle one showed →42.0%); another login's block, resetting minutes from the first's, keeps its own window (its 10% was dropped into the other login's 80% window); the editor rows after a type change from a bar read (compact) and (remaining) (were (long bar, inverted) and (long bar, remaining)); a failed rename removes its temp file (was left behind); a failed removal still returns the readings.
  • Node v26.10.0: Vitest on the six affected test files (session-usage-history, session-forecast-prefetch, session-forecast, renderer-block-forecast, BlockForecast, BlockLimitTimer) passes all 86 tests. A built dist/ccstatusline.js under Node, in a scratch HOME, gave the fixed output for a prompt after an idle stretch and for a new transcript after /clear, at 60% and at 40%. It also showed an active session still moving its reading up to its last response.
  • Piped before/after with Bun in a scratch HOME with fake data (the demo scenarios). After 100 idle minutes at 20%, with a refresh render and a new prompt, a response at 60% went from Limit in: 30m to Limit in: 1hr 40m. At 40% it went from →100.0% | Limit in: 1hr 30m to →70.0%.
  • Idle sessions at 40% and 30%, while another session took the block to 50%, went from →70.0% and nothing to →100.0% | Limit in: 1hr 40m, the same as the active session.
  • After /login to an account whose block resets 40 minutes after the first one's, the new account at 55% went from nothing to →100.0% | Limit in: 45m. At 81% it went from →83.0% to →100.0% | Limit in: 19m.
  • Line editor: two Block Usage widgets drawn as long bars showing what's remaining were changed to Block Limit Timer (then (s)hort) and Block Forecast. Their rows went from (long bar, inverted) and (long bar, remaining) to (compact) and (remaining).
  • bun test: 3263 pass, 0 fail, 9311 expect() calls across 184 files.
  • bun run lint (tsc --noEmit plus ESLint with --max-warnings=0): clean.
  • Under Node, node node_modules/vitest/vitest.mjs run src/utils/__tests__/session-usage-history.test.ts src/utils/__tests__/session-forecast-prefetch.test.ts: 2 files, 46 tests passed.
  • New tests in src/utils/__tests__/session-usage-history.test.ts:
    • Three failed before the sweep was added: 'removes the temp files other renders left a minute or more ago', 'removes old temp files when its own rename fails too' and 'moves on to the next old temp file when one can't be removed'.
    • Two guards pin the edges: 'leaves the temp files alone when it doesn't write' and 'still returns the readings when the cache directory can't be listed'.
    • 'removes its temp file when the rename fails' and 'still returns the readings when the temp file can't be removed either' cover the unlink.
  • Manual check with a scratch HOME and CLAUDE_CONFIG_DIR, using the dist built with the package.json build flags:
    • One render under Bun and one under Node v26 each deleted the old session-usage-history.json.<n>.tmp files.
    • Each kept a fresh one, plus an unrelated daily.json.333.tmp and a plain session-usage-history.json.tmp.
    • Three more renders with the rename blocked by chflags uchg on the history file, standing in for a held Windows handle, left no new temp files.
  • Review fixes (the last three commits), new regression tests, each failing before its fix:
    • A cached usage answer is dated by the cache file's write time, both fresh and served through a rate limit (was now). A fetched answer is dated by when it came, and an error by nothing.
    • A new percent is dated by when it was seen, never before the last reading's last sighting, and now when nothing shows when it was seen. An API percent's time comes from getUsageFetchedAtMs.
    • Under the lock, the history is read again, keeping a write another render made in between (it was lost). While another render holds the lock, the write is left to a later render, which records the same reading. A lock 10 seconds old, or 10 seconds ahead, is taken over. No lock is taken with nothing to write.
    • Readings are filed under the config directory and login, or the directory alone without a login. Another login whose block resets at the same moment keeps its own readings and gets its own forecast (it showed nothing). The login fingerprint stays the same across an access-token refresh and changes with the refresh token. No login lookup happens without a percent to record.
    • Breaking each fix on purpose fails at least one of these tests. The one exception is clearing the fetch time after a failed fetch, which nothing reads within a render.
  • bun test: 3278 pass, 0 fail across 184 files. bun run lint: clean.
  • After merging today's main (upstream feat(usage): Block Reset Timer can hide until the 5-hour block hits 100% sirmalloc/ccstatusline#697, fix(renderer): end a merge at a hidden widget that isn't merged onward sirmalloc/ccstatusline#698, feat(tui): opt-in dev reload that restarts the TUI in place sirmalloc/ccstatusline#700, fix(hooks): tell the user when a save can't add a widget's hooks sirmalloc/ccstatusline#703, fix(git-branch): read a jj repo's detached HEAD as no branch sirmalloc/ccstatusline#705 and others): bun test 3359 pass, 0 fail across 186 files; bun run lint clean.
  • Under Node 26.10.0 (Vitest): session-usage-history (43) and session-forecast-prefetch (15) pass, new tests included. The two new fetchUsageData probe tests fail there the same way the file's 40 existing probe tests already do: the probe subprocess loads the TypeScript source, whose extensionless imports Node can't resolve. usage-token.test.ts doesn't load under Node before or after these commits (its node:child_process mock has no spawn).
  • Built CLI under Bun 1.4.2 and Node 26.10.0, in a scratch HOME and CLAUDE_CONFIG_DIR with fake credentials and a stub security. A payload percent was filed under the config directory plus the refresh token's fingerprint, dated by the transcript's last response. An API percent served from a two-hour-old cache during a rate limit was dated by the cache's write time. No lock file was left. Eight profiles rendering at once for three rounds under Node: all eight were recorded every round, and no lock was left.
  • The two demos above, recorded on the branch before and after these commits.

@eric-engberg
eric-engberg force-pushed the feat/session-forecast branch from d00c985 to 2a3fecb Compare October 9, 2026 04:25
eric-engberg and others added 19 commits October 9, 2026 09:51
…, save guard return) (sirmalloc#689)

* fix(tui): let m clear a merge left on the last widget

The merge toggle was guarded to widgets with another widget after them,
because the last one has nothing to merge into. But a widget can become
last with its merge still set: merge Git Branch into the widget after it,
then delete that widget. Git Branch still shows "(merged→)", m does
nothing, and "(m)erge" is gone from the help line, so the flag can't be
cleared. The next widget added after it then merges into it silently
(`⎇ mainModel: Claude` in the preview).

On the last widget, m now clears a merge that is set, and the help line
offers it there. It still can't set a merge on the last widget, and the
merged, merged-without-padding and off cycle is unchanged elsewhere.

* fix(tui): keep the other items in order when a move wraps

Move mode wraps past either end of the list, but it did so by swapping the
moved item with the one at the other end. Moving Model up from the top of
Model, Context Length, Git Branch, Git Changes gave Git Changes, Context
Length, Git Branch, Model: Model reached the bottom, but Git Changes jumped
from the bottom to the top. The line selector's move mode did the same
with whole lines.

Both move modes now take the item out and put it back in at the target, so
wrapping past either end moves only that item and the rest keep their
order. A move to a neighbour is still a swap.

* fix(tui): return to the current screen from the invalid-settings save guard

When settings.json is invalid, Ctrl+S first asks before overwriting it. The
question can come up on any screen, but both answers went to the main menu:
with Edit Line 1 open, Ctrl+S then ESC (or Yes) left the line editor, and
you had to navigate back in to carry on.

The guard now returns to the screen Ctrl+S was pressed on, for both
answers. Save & Exit, the other route into the guard, starts from the main
menu and still returns there. The screen comes back as it does when you
navigate to it: a sub-editor that was open on it, such as the label editor,
and its unsaved text aren't restored.

* test(tui): put chalk's color level back after a whole-App test

The App sets chalk's global color level from the settings it loads, and
the whole-App test helper didn't restore it. Test files that run later in
the same process then rendered at that level instead of their own: a
No Color check could see 256-color codes. The sandbox now restores the
level along with the mocks and the config path.
…ers (sirmalloc#688)

* fix(jj-bookmarks): keep bookmarks on different heads apart

JJ Bookmarks lists the bookmarks of heads(::@ & bookmarks()) with the
template "bookmarks". jj log --no-graph prints each commit's template
output back to back with nothing in between, so when @ sits on a merge
of two bookmarked heads the names ran together: "feature-afeature-b".
The template now ends each head's output with a newline, which the
whitespace split already handles; bookmarks on one commit were always
space-separated and still are.

* fix(speed): switch to "k" at the rounding boundary

formatSpeed switched to "k" at exactly 1000 t/s, but values just below
round up when shown: 999.96 t/s rendered as "1000.0 t/s" instead of
"1.0k t/s" (and 999.6 as "1000 t/s" with whole numbers). It now
switches once the value would round to 1000 at the shown precision,
1000 - 0.5 / 10^decimals, the same boundary formatTokens uses for "M".

* fix(usage): show "time cursor" only on widgets that draw one

The time cursor is a usage bar setting (cursor metadata), and the
widget picker keeps metadata when it changes a widget's type. After
changing a usage bar with the cursor on to Block Timer, Block Reset
Timer or Extra Usage Utilization, the editor row said "time cursor",
though those widgets never draw one and have no key to turn it off.

The modifier text now lists the cursor only for widgets that ask for
it, as it already does for compact and date: the usage percent widgets,
which draw it. The metadata itself is left alone, so changing back to a
usage bar keeps the setting.

* refactor(jj-bookmarks): write the template as a raw string

String.raw shows the template exactly as jj reads it, "\n" included,
instead of a doubled backslash.

* test(usage): check only that a carried-over cursor isn't shown

The three time-cursor tests compared the whole modifier text, so they'd
also break on any change to how the bar mode itself is described. They
now check the part they're about: the bar mode still shows, and the
time cursor doesn't.
…fter its resets (sirmalloc#687)

* fix(renderer): keep a preserve-colors widget's Powerline background after its resets

In Powerline mode a widget that preserves its own colors (a Custom
Command with preserve colors on) gets its segment background once,
before its text. Command output usually ends with \x1b[0m, and Custom
Command appends one after cutting colored output to its max width; that
reset cleared the background too, so the trailing padding, and any text
after a reset mid-output, showed the terminal's background before the
separator arrow:

  \x1b[48;5;26m \x1b[31mhello\x1b[0m \x1b[0m<arrow>

Re-apply the segment background after each SGR sequence in the
preserved text that clears it: a full reset (0 or an empty parameter,
alone or combined like 0;32) or 49. The arguments of extended colors
(38/48/58;5;n and ;2;r;g;b) are skipped, so a 0 or 49 among them isn't
read as a reset. The command's own colors, including its own
backgrounds, are kept, and the full reset after the widget is
unchanged. Widgets without a background, and plain mode (which colors
the padding separately), are untouched.

* fix(renderer): honor backgrounds set after resets in combined SGR codes

Powerline widgets with preserveColors enabled restored the segment
background whenever an SGR sequence contained a reset. For output such as
ESC[0;41m, that restoration overwrote the explicit red background set
later in the same sequence. Indexed and truecolor backgrounds were also
affected.

Process all SGR parameters in order and track the final background
operation. Restore the segment background only when the sequence leaves
it cleared; preserve standard, bright, indexed, and truecolor backgrounds
set after a reset. Continue skipping extended color arguments so channel
values such as 0 and 49 are not interpreted as background operations.

Add parameterized helper and Powerline renderer regression coverage for
reset-before-color and color-before-reset ordering, empty reset parameters,
extended color arguments, and restoration after a subsequent reset.
Confirmed 16 new cases fail before the fix.

Validation:
- bun test: 2811 passed, 0 failed
- Targeted ANSI and Powerline tests: 63 passed, 0 failed
- bun run lint: TypeScript and ESLint passed
- git diff --check passed

---------

Co-authored-by: Matthew Breedlove <[email protected]>
…loc#686)

* fix(colors): stop number keys from leaving Edit Colors

ink-select-input selects the item at a digit key's position, the same as
Enter on it. Edit Colors ignored digits in its own key handler, but its
list still passed them to onSelect, and choosing the Back entry there
called onBack. So on a line with two colorable widgets, pressing 3 left
the screen; with eight, 9 did.

Edit Colors now handles Enter on Back itself and gives the list no
onSelect, so digits do nothing, as the handler already intended. Enter on
a widget still does nothing, and Enter on Back and ESC still go back.

* fix(colors): keep a live highlight when hiding separators

With separators shown in Edit Colors, highlighting one and pressing s
again hid it but left the highlight pointing at it. The list redrew with
the marker on the first item, but every key that acts on the highlighted
item looked up the hidden separator and found nothing: b, d, r and
left/right did nothing, a color entered after a, h or g was dropped, and
the "Current foreground" line went blank.

When hiding separators takes away the highlighted one, the highlight now
moves to the first widget, where the list already draws the marker.
Showing separators, or hiding them while a widget is highlighted, keeps
the highlight where it was.
…ry segment count (sirmalloc#685)

With abbreviate-home on, the leading "~" counted as one of the kept
segments. ~/a/b with segments=2 had three parts, so it rendered
"~/.../a/b" though nothing was left out; /a/b with segments=2 renders
"/a/b". The "~" is always kept in front, so it no longer counts.

The preview was a set of fixed strings for one or two segments, so
segments=3 or 4 still previewed two (".../example/project"), and those
strings didn't come from the sample path itself. The preview now runs
the same segment logic over the sample path that a real path goes
through, so it shows what the setting produces.
…rmalloc#684)

The status payload schema accepted null for context_window, effort, vim,
worktree and rate_limits, but not for version, cost, workspace or
output_style. A payload with any of those four set to null failed
validation as a whole: the CLI printed "Invalid status JSON format" and
exited 1, so no status line rendered, even when no widget on the line
reads that field.

Mark the four fields nullable like the others. Every reader already
goes through optional chaining or a truthiness check (Version, Session
Cost, Session Clock, Output Style, and the git and Claude settings
lookups that fall back from workspace to cwd), so a null field renders
the same as a missing one. Wrong types are still rejected.
…e-dated usage cache (sirmalloc#683)

* fix(usage): time out keychain reads and back off when no login is found

On macOS the usage credentials come from `security find-generic-password`
and, for the default profile on a miss, `security dump-keychain`. Neither
call had a timeout, so a `security` that blocks (for example on a keychain
unlock prompt) held the whole status line until someone answered it. With
a fake `security` that sleeps 12s, every render took 24s.

Both calls now time out after 5s, like the git and usage API calls.

A lookup that finds no OAuth login was also repeated on every render: an
API-key user with a usage widget has nothing to cache, so each render ran
both `security` calls, the full keychain dump included. The miss is now
remembered for 30s across renders (as it already was within one process),
in ~/.cache/ccstatusline/usage-credentials.lock. The record names the
credential stores it covers (the keychain service and config directory),
so one profile's miss never suppresses another profile's lookup. As with
the usage lock, a deadline further ahead than the backoff itself is
ignored, so a clock set back cannot stretch it.

With the same fake `security`, the first render now takes 10s and the
next ones inside the backoff about 0.2s.

* fix(usage): stop counting a usage cache dated in the future as fresh

The file cache is fresh while `now - mtime` is under 180s. For a file
modified in the future that age is negative, so it always passed. After
one render under a clock running ahead (since corrected, e.g. by NTP or a
VM resume), the cached usage stayed on screen with no refetch until real
time passed that mtime plus three minutes: a clock a day fast froze the
usage widgets for a day.

A file dated ahead of now now counts as fresh only within one cache
lifetime. That still covers a concurrent render writing the file just
after this one read the clock; anything further ahead cannot be right and
is refetched. The usage lock already ignores deadlines too far ahead for
the same reason.

* fix(terminal): report no width instead of tput's default of 80

When the ancestor walk finds no terminal, the probe fell back to
`tput cols`. That runs with piped stdio, so tput cannot see a terminal
either: it prints $COLUMNS if exported, otherwise the terminfo default for
$TERM, which is 80. So with no terminal found and TERM set, the status
line was truncated at 74 columns in full-width mode (40 in full-minus-40)
however wide the real terminal was, and flex separators padded to that
made-up width.

It also meant the probe never returned null in that case, so the
per-session "no TTY" cache was never written and the whole `ps` ancestor
walk re-ran on every render.

The fallback is gone. With no terminal found the probe returns null, which
the renderer already handles: no truncation, and flex separators render as
plain separators. That result is now cached per session as intended.
CCSTATUSLINE_WIDTH still overrides detection for anyone who wants a fixed
width.
)

Edit Colors' (r)eset and (c)lear all removed each widget's numberFormat
along with its colors, bold and dim. Precision used to be cycled from Edit
Colors, but it moved to Edit Lines (the `.` key), and the reset paths kept
clearing it. So resetting a widget's colors silently undid its number
style: a Context Length set to whole numbers went from `Ctx: 19k` back to
`Ctx: 18.6k`, though neither the key help nor the clear-all confirmation
mentions anything but colors.

Reset and clear all now remove only color, background, bold and dim.
…it (sirmalloc#681)

* fix(tui): keep the TUI usable after updating the pinned install from it

Running a global update from Check for Updates records the new version as
the pinned install. On the next render the TUI, still the older version,
saw a pinned install newer than itself and replaced everything with the
"Pinned Install Version Mismatch" screen, whose only option is Exit. Ctrl+S
is a no-op on that screen, so any edits made before the update were lost.

That screen exists so a TUI doesn't write settings for a pinned runtime of
a different version. Here, though, the settings being edited were loaded
before the update, so saving them afterwards is the same as saving a moment
earlier. The TUI now remembers the version its own update installed and
doesn't treat that version as a mismatch for the rest of the session, so
the menus and both save paths stay available. A fresh launch of the older
TUI still gets the mismatch screen.

* test(tui): put chalk's color level back after a whole-App test

The App sets chalk's global color level from the settings it loads, and
the whole-App test helper didn't restore it. Test files that run later in
the same process then rendered at that level instead of their own: a
No Color check could see 256-color codes. The sandbox now restores the
level along with the mocks and the config path.

---------

Co-authored-by: Matthew Breedlove <[email protected]>
… as v1 (sirmalloc#680)

loadSettings treats a file as v1 only when it has no `version` key, but
needsMigration/detectVersion treat any `version` that isn't a number as
v1. A current config with `"version": "4"` or `"version": null` (after a
hand edit, for example) therefore skipped v1 validation and then ran the
v1->v2 migration anyway. That migration rebuilds the config from `lines`
and the v1 fields only, so the result validated and was written back
over the file: Powerline, defaultPaddingSide, minimalistMode, separators
and every other v2+ setting were dropped, widget ids were regenerated,
and the status line announced "ccstatusline updated to v2.0.2".

needsMigration now reports no migration for a version that is present
but not a number. The config then reaches schema validation, which
rejects it, so it takes the existing invalid-file path: defaults in
memory, the load error recorded for the warning badge and TUI banner,
and the file left untouched. Import validation shares needsMigration, so
an import file with a non-numeric version is now rejected instead of
being reduced to its v1 fields.
…nd wide PR titles (sirmalloc#679)

* fix(git-branch): show detached HEADs and branches shadowed by a tag

On a detached HEAD (a tag checkout, a rebase in progress, a bisect),
`git symbolic-ref` fails, so Git Branch showed "no git" (or hid itself
with hide-no-git) inside a perfectly good work tree. It now shows the
short commit in parentheses, as git prompts do, e.g. "⎇ (0449a12)", and
the repo link points at that commit.

When a tag has the same name as the current branch, `symbolic-ref
--short` disambiguates the name to "heads/<branch>", so the widget
showed "⎇ heads/release" and linked to .../tree/heads/release. It now
reads the full ref and strips "refs/heads/", which is always the plain
branch name.

* fix(git-worktree): recognize the main worktree in submodules and separate git dirs

Git Worktree decided "main worktree" by the git dir's path: ".git" at
the end, else a ".../worktrees/<name>" pattern. A submodule's git dir is
.git/modules/<name> and a --separate-git-dir repo's is wherever it was
put (e.g. /x/repo.git), so both showed "no git". The path pattern could
also misread a repo kept under a directory named "worktrees".

It now asks git for both the git dir and the common dir in one call. A
linked worktree's git dir is always <common dir>/worktrees/<name>; any
other git dir belongs to the main worktree.

* fix(git-pr): truncate titles by display width and whole characters

PR titles were cut with title.slice(0, 29), counting UTF-16 code units.
An emoji at the cut lost its second half and showed as U+FFFD ("�"), and
a CJK title passed the 30-unit limit while taking 59 columns, since each
character is two columns wide.

Truncation now goes through the same display-cluster and width logic
the renderer uses for max widths, keeping the 30-column limit and the
single "…" ellipsis.

* refactor(git-worktree): swap path separators with replaceAll

A plain replaceAll says what the line does without a global regex.

* fix(git): preserve Node.js 14 worktree path normalization

---------

Co-authored-by: Matthew Breedlove <[email protected]>
…rs at No Color (sirmalloc#678)

* fix(renderer): keep truncating when the reserved width leaves no columns

Full minus 40, and Full until compact past its threshold, subtract 40
columns from the terminal width; Full subtracts 6. On a narrow terminal
that went to 0 or below, and every width check treated a non-positive
width as "no width detected": the line printed at full length and
wrapped, while 41 columns still truncated to a single ".". The TUI
preview cut the line at the terminal edge instead, so it no longer
matched what Claude Code received.

Clamp the effective width at 0 and treat any detected width, 0
included, as known, in both the plain and Powerline renderers. When the
reserve takes the whole terminal the line now truncates to nothing, the
continuation of what 41-43 columns already show. An undetected width
(null, or 0 from the caller) keeps the fallback behavior.

* fix(renderer): stop reading widget text FLEX as a flex separator

The plain renderer stood each flex separator in the assembled line as
the string 'FLEX' and later split the line on that string. A widget
whose rendered text was exactly FLEX with no color codes around it (No
Color mode, or a color name the palette doesn't have) matched too: its
text vanished and the line got a second flex gap.

Push null for a flex separator instead, which no widget output can
equal. Powerline mode already uses its own marker and is unchanged.

* fix(colors): emit no 256-color or truecolor escapes at Basic and No Color

getColorAnsiCode returned raw 256-color and truecolor escapes for hex:
and ansi256: colors at every color level. With No Color selected, which
the TUI describes as disabling all color output, a widget colored
hex:FF0000 on hex:0000FF still printed \x1b[48;2;0;0;255m\x1b[38;2;255;0;0m;
Basic did the same.

Return no code for these formats at the ansi16 level (Basic and No
Color), as the function already does for gradient specs. This matches
the TUI, which only offers these formats at 256 Color and Truecolor and
clears them from widgets when switching to Basic or No Color, so they
only reach these modes through a hand-edited config. Named colors are
unchanged.

* refactor(renderer): flatten the no-width flex fallback branch

The else branch re-checked terminalWidth === null, which the preceding
condition (a flex separator and a known width) already implies when a
flex separator is present. Chain it as else-if on hasFlexSeparator
instead of nesting an if/else inside the else.

* test(custom-command): start each runtime once before its capture tests

On a fresh CI machine the first start of a runtime can take seconds while
its binary is read from a cold disk. The capture tests start Node for the
first time in their opening case, so that case once ran past its 5s limit
(5018 ms) while the same case one test later took 146 ms and the rest about
60 ms. Each runtime now starts once in a setup hook with its own timeout,
before the timed tests.
…c#677)

Customizing a Powerline theme (c in the theme list) copied the theme's
colors onto the widgets by counting every widget in a line, restarting at
the first color on each line. The renderer counts differently: a widget
merged into the next one shares that group's color, and with Continue Theme
on, each line picks up where the previous one stopped. So the copy could
differ from what the theme had just shown. With Model merged into Context
Length, the pair shared one background under the theme, but the copy gave
Context Length the next color and shifted every widget after it; with
Continue Theme on, every line after the first restarted at the first color.

Number the widgets with the same slot rule the renderer uses to carry the
theme across lines, now shared from powerline-theme-index, and continue the
numbering across lines when Continue Theme is on. Lines without merges and
with Continue Theme off get the same colors as before.
…s too (sirmalloc#670)

* feat: label Extra Usage as spend on accounts without plan limits

On usage-based plans (Claude Enterprise) the usage API reports every
rate-limit window as null and no limits[] entries: there's no plan limit
to go over, and extra usage is the account's whole spend. The Extra
Usage widgets still called it overage ("Overage Used: $123.45"), and
their picker descriptions only mentioned pay-as-you-go overage, so
Enterprise users had no reason to think these widgets were theirs.
Searching the picker for "enterprise" or "spend" didn't find them.

The parser now flags a response that explicitly reports no plan limits
(`noPlanLimits`). The flag is kept in the usage cache and through the
merge with statusline rate_limits, and the three widgets label their
value "Spend Used", "Spend Left" and "Spend" when it's set. Only an
explicit null counts and any limits[] entry counts as a limit, so every
uncertain case keeps the "Overage" label. A cache written before this
change keeps the old label until its next refresh.

The widget descriptions now mention both Pro/Max overage and Enterprise
spend, which also makes the picker's search find them.

* test: cover the Spend label with an edited label

Upstream's label editor (sirmalloc#601) gives each widget one default label,
while Extra Usage Used, Remaining and Utilization pick "Overage" or
"Spend" by account. The editor offers the Overage label, since it has
no usage data, and an edited label replaces whichever one the account
would show.

* refactor: keep the Enterprise wording, drop the Spend label detection

With the label editor (sirmalloc#601) in main, anyone on a usage-based Enterprise
plan can relabel the Extra Usage widgets' "Overage" to "Spend" in a few
keys, so detecting those accounts from the usage API's response shape
isn't worth its cache field and heuristics.

What stays is the wording: the three widgets' descriptions say they
cover Pro/Max overage and Enterprise spend, the docs say the same and
point at the label editor, and picker searches for "enterprise" or
"spend" list the three widgets first, which a new test checks.

* test: check the picker lists the Extra Usage widgets first, however many there are

* test: look for the Extra Usage widgets among the widgets that mention the word

* refactor(widgets): share Extra Usage Used and Remaining's code

The two widgets were the same class apart from how the amount is worked
out, and their label, name, description and preview sample. They now
extend ExtraUsageAmountWidget, the way the Tokens widgets share
TokenCountWidget. Rendering is unchanged.
…rmalloc#668)

Until the first extra-usage charge of the month, the usage API reports
`utilization: null` alongside the monthly limit and the amount spent
($0). The widget treated the missing utilization as no data and
rendered nothing, so it looked broken on every account with extra
usage enabled until the first charge.

When the API reports no utilization, work it out from the amount spent
and the monthly limit. An API-reported value still wins, and accounts
without a monthly limit render nothing, as before.

Co-authored-by: Matthew Breedlove <[email protected]>
…malloc#673)

The separator editor wrote `separatorInvertBackground[i]` straight into a
copy of the saved list. That list is often shorter than the separators: its
schema default is `[false]`, and hand-edited or imported configs commonly
add separators without extending it. Toggling (t) or cycling (left/right) a
separator past the end of the list left holes in the array, which JSON saves
as `null`. With three separators and the default list, selecting the third
and pressing `t` saved `[false, null, true]`; the next load failed
validation, so both the TUI and the status line fell back to defaults with
the invalid-config warning.

Pad the list to one entry per separator, defaulting to false, before any
edit. Missing entries already rendered as not inverted, so nothing changes
visually; add, insert and delete now also work on aligned entries.
* feat(tui): edit a widget's label with e

The label editor opened with b (la(b)el…). Custom Text, Custom Command,
Custom Symbol and Link already open their text editors with e, so the
line editor's "edit this widget's text" key differed by widget. Labels
now use e too, shown as (e)dit label…, which also reads more plainly.

None of the widgets that bind e has a label, so e is free wherever the
label editor appears; the existing check that labeled widgets leave the
label key unbound now runs against e.

* docs: name the e key where the label editor hides in raw value mode

The label key moved to e, but one sentence still said b is hidden while
raw value is on.
…alloc#667)

* fix: show the max width in Git Branch and Git Root Dir previews

(w) caps how wide the branch or repository name gets, but the TUI
preview returned its sample before applying the cap, and the samples
(`main`, `my-repo`) were too short to cut anyway. With a cap set, the
previews now use a longer sample and cut it the way the status line
cuts a real name, so the setting shows as you make it.

* refactor: move Git Branch's preview into its own method

render() had grown a preview branch with its own sample, width and
link handling; it reads more easily on its own.
…#669)

* fix(tui): ignore letter shortcuts when ctrl or alt is held

Letter shortcuts compared the typed character without checking
modifiers, so ctrl+letter and alt+letter ran the plain-letter action:
ctrl+r toggled raw value, ctrl+d deleted a widget, and on macOS alt+arrow
(sent as ESC + letter) fired whatever letter followed. A few handlers
patched ctrl+s one key at a time with `&& !key.ctrl`; widget keybinds
checked ctrl but not alt.

Shortcuts now compare against getPlainInput(), which is empty while ctrl
or alt/option is held, across the line editor, widget keybinds, Edit
Colors, Global Overrides, Powerline setup/themes/separators, the line
selector and the hide-state checklist. Arrows, Enter, ESC, shift and text
entry are unchanged, and modifier combos are free for bindings of their
own.

* test(tui): wait for each shortcut's effect instead of a fixed delay

The shortcut tests sent keys and checked the result after a fixed 25 ms. On a
busy machine Ink can take longer, so the bare-key cases could fail even though
the shortcut works. They now wait until the shortcut's effect shows (up to
3 s), and a follow-up key waits for the previous key's redraw. Checks that a
modified key does nothing give it 150 ms to act.

After each wait the test also gives React two setImmediate turns: React draws
a frame, then attaches the new input listener in a scheduled effect, so a key
sent the moment the frame shows could reach no listener.

---------

Co-authored-by: Matthew Breedlove <[email protected]>
eric-engberg and others added 9 commits October 26, 2026 13:01
…malloc#703)

* fix(hooks): tell the user when a save can't add a widget's hooks

Skills reads skill activity that Claude Code hooks record. Since hooks
are only added when the status line command contains `ccstatusline`, a
status line run through a wrapper such as `bash ~/.claude/status.sh`
gets none, and saving says "✓ Configuration saved" while Skills keeps
showing "none" with nothing to explain why.

syncWidgetHooks now returns the hooks it left out: the status line
command and the names of the widgets that needed them. It reports
nothing when every hook was added, when no widget needs one, or when no
status line is installed yet (installing ccstatusline adds them).
saveSettings passes that on.

When a save leaves hooks out, Ctrl+S and Save & Exit show a notice
instead of the usual confirmation: which widgets lost their hooks, the
status line command, and how a wrapper script gets them (put
"ccstatusline" in its name and pass its arguments on). Continue returns
to the screen Ctrl+S was pressed on, or exits after Save & Exit.

* docs: explain why Skills shows none behind a wrapper script

Add a troubleshooting note under Claude Code Status Line Settings, and a
README pointer next to the other status line notes: Skills needs hooks,
which are only added when the status line command contains
`ccstatusline`, and how a wrapper script gets them.

* refactor(tui): give the flow notice only the props it draws

FlowNotice took every field of FlowNoticeState, continueScreen
included, but never read it: App decides where Continue leads, in the
onContinue it passes. Now that continueScreen can also be 'exit',
which only App can act on, leave it out of the notice's props.
…oc#705)

* fix(git-branch): read a jj repo's detached HEAD as no branch

Since Git Branch started showing a detached HEAD's commit, it shows one
in every colocated jj repo: jj keeps git's HEAD detached on the working
copy's parent, so the widget printed "⎇ (4227868)" where it used to
show "no git", or hide with "hide no git". That commit is jj's
bookkeeping, not something the user checked out, and users who pair Git
Branch with the jj widgets and hide it outside git repos saw it come
back in every jj repo.

On a detached HEAD, check whether the directory is in a jj repo first.
If it is, there's no branch: "no git", or hidden, as before. A plain git
repo's detached HEAD (rebase, bisect, tag checkout) still shows its
commit. The jj check only runs on a detached HEAD.

* fix(git-branch): only treat a colocated jj repo's detached HEAD as no branch

On a detached HEAD, Git Branch took any successful `jj root` to mean the
repo was a colocated jj repo, where jj keeps git's HEAD detached. But jj
finds its workspace by walking up to the nearest .jj, and doesn't stop at
a git repo's own root. So in a plain git repo anywhere under a jj
workspace (a home or dotfiles tree managed by jj, a git checkout inside a
jj monorepo), `jj root` succeeds too, and a rebase, bisect or tag
checkout there showed "no git", or hid the widget, instead of the
detached commit.

Now the repo counts as colocated only when git's top-level directory
holds the .jj. A git repo nested under a jj workspace shows its detached
commit again, while a colocated repo, and a jj workspace with its own git
worktree, still read as no branch. The extra git call only runs on a
detached HEAD where `jj root` has already succeeded.
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.
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.
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 (sirmalloc#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.
…malloc#614)

* feat(widget): fish-style abbreviation for Git Branch

Add a fishStyle metadata option (editor keybind (f)) to Git Branch:
every branch segment but the last collapses to its first character,
so 'main' renders as 'm' and 'fix/np/story' as 'f/p/story'. Mirrors
the existing fish-style mode of Current Working Dir. Link targets
keep the full branch name; the TUI preview reflects the mode.

Co-Authored-By: Claude Code <[email protected]>

* feat(widget): fish-style abbreviation for Git Worktree

Same fishStyle option as Git Branch (editor keybind (f)): every
worktree name segment but the last collapses to its first character,
so 'main' renders as 'm' and 'dir/wt' as 'd/wt'.

Co-Authored-By: Claude Code <[email protected]>

* fix(git): preserve Unicode code points in fish-style names

---------

Co-authored-by: axisrow <[email protected]>
Co-authored-by: Claude Code <[email protected]>
Co-authored-by: Matthew Breedlove <[email protected]>
…tory (sirmalloc#717)

The install tests reset the ccstatusline config path to the real ~/.config location and, after one test deletes CLAUDE_CONFIG_DIR without restoring it, write the real ~/.claude/settings.json. Under mutation testing a broken settings parse materialised defaults over a live config and pointed statusLine at a file inside the deleted Stryker sandbox (issue sirmalloc#716).

os.homedir() is mocked into the temp directory for the whole file and the default settings path is resolved on every call instead of once at import, so no test can reach the real home even when the parse is broken. afterEach restores CLAUDE_CONFIG_DIR for tests that delete it.

Verified: claude-settings + config tests 138 pass; full suite 3112 pass; mtime guards on both real config files show them untouched during a rerun.

Closes sirmalloc#716

Co-authored-by: axisrow <[email protected]>
Co-authored-by: Claude Code <[email protected]>
…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.
eric-engberg added a commit that referenced this pull request Oct 26, 2026
Brings in #72's review fixes and today's main, which has #71 as upstream
merged it, with sirmalloc's fix: Block Reset Timer's under-limit state
reads the usage API's percent when prefetch accepted one, then the
payload's own, never an API percent prefetch rejected. The shared
isAtSessionLimit check takes the render context for that, as on #89's
rebuilt branch, so Block Usage's at-limit state swaps with it at the
same render. The forecast passes only its usage data: a payload percent
at 100 already ends the forecast.
eric-engberg added a commit that referenced this pull request Oct 26, 2026
Brings in #72's review fixes and today's main, which has #71 as upstream
merged it. #115's cached-answer check and #72's fetch time meet in
fetchUsageData's file cache: a cache that answers the request is served
with its file's write time. Block Usage's at-limit check follows #89's
rebuilt branch, which reads the percent as Block Reset Timer now does.
eric-engberg added a commit that referenced this pull request Oct 26, 2026
…eat/block-forecast-value-colors

Stops before #72's merge of today's main, which this branch's base (#46)
doesn't have yet. #72's fetch time meets #36's (getUsageFetchedAt, from
the Extra Usage Today chain) in fetchUsageData: both are set from the
same cacheUsageData argument, which keeps #36's cache-file write time for
cached data and seconds-precision now for a fresh answer.
eric-engberg and others added 10 commits October 29, 2026 10:40
…Timer (sirmalloc#747)

At the 5-hour limit, the block's percent says nothing new: it's 100% until
the reset, and the countdown is what matters. Block Usage gets an "at-limit"
hide state, the mirror of Block Reset Timer's "under-limit": with both on,
the reset countdown takes the percent's place once the block is used up.

Both states read one shared check, isAtSessionLimit, which reads the
percent the way Block Reset Timer's under-limit state does: the usage API's
raw percent when prefetch accepted one, so 99.7% shown as "100%" isn't the
limit yet and a lagging payload doesn't decide, and otherwise the payload's
own percent, never an API percent prefetch rejected as stale. The two
widgets therefore always swap in the same render, never both or neither.
The prefetch asks the usage API for the block's percent for either state,
through the same cache, so a line with both makes one request.
…line (sirmalloc#749)

While npx runs the TUI (the README's route to a pinned install), npm puts
the bin dir of its cached copy, <npm cache>/_npx/<hash>/node_modules/.bin,
first on PATH. The PATH lookup behind Install Complete, Manage
Installation and the pinned-version guard skipped bunx's temporary copy
but not npx's, so under npx it took the cached copy for the active
binary:

- Install Complete and Manage Installation warned that multiple
  ccstatusline binaries are on PATH and that Claude Code would run the
  npx copy, which isn't on Claude Code's PATH at all
- Manage Installation showed the pinned install without its package
  manager or version
- the pinned-version guard never fired, so a newer TUI launched through
  npx could save settings the older pinned install may not support

npx cache bins are now skipped the same way as bunx's: ccstatusline, or
its .cmd/.ps1 shim, in node_modules/.bin under an _npx/<hash> directory,
with either path separator. A custom npm cache (npm_config_cache) only
moves the part before _npx, so it's covered too.
…c#750)

The git command cache timed each entry from the moment the lookup
began, before git ran. A command that takes as long as the TTL (5
seconds by default), such as `git status` in a large repository or any
command cut off by the 5-second timeout, was therefore stored already
expired. Every widget sharing that command missed the cache and ran it
again: Git Status, Clean Status and the staged, unstaged and untracked
flags and file counts all read `status --porcelain -z`, and Insertions,
Deletions and Changes all read the two `diff --shortstat` commands. A
line with several of them waited on the same slow command once per
widget, long enough for the status line to stay blank or stale.

Stamp the entry once git returns, after a success or a failure alike,
and store that one entry in both the in-process and the on-disk cache.
Lookups still compare against the time they began. The custom command
cache already works this way.
…irmalloc#751)

* fix(tui): stop editor keys pausing on a global ccstatusline install

When Claude Code runs the global `ccstatusline` command (a pinned
install, which is the install menu's default, or a self-managed one),
App looked that command up in its render body. The lookup runs
`which -a`, `npm prefix -g` and `bun pm bin -g` synchronously, and a
pinned install runs `which` and the bin-dir lookup again for the
relaunch path. The label editor's live preview and every widget edit
redraw App, so each of those keys blocked the TUI for a quarter to half
a second on macOS. Keys pressed meanwhile reached Ink as one read,
which it parses as a single key or none: of six quick backspaces, two
or three took effect.

Run the lookups in a memo keyed on what they depend on: the saved
installation metadata, Claude's status line command and the install
state. They now run once loading finishes, and again after an install,
update or uninstall from the TUI, since each changes one of those. The
screens show the same thing. Ctrl+S and Check for Updates still look
the command up when pressed.

* refactor(tui): let App choose how to classify the installation

getCurrentInstallation took an isClaudeInstalled flag that switched
between two behaviours: with ccstatusline installed in Claude Code, the
saved metadata stands in until the status line command is read;
otherwise the command alone is classified. Each caller already holds
that state, so each now picks: classifyInstalledStatusLine for the
installed case, classifyInstallation otherwise.

Check for Updates passes the saved metadata as settings?.installation
instead of special-casing missing settings, since no saved metadata
already classifies the command alone.
…block (sirmalloc#752)

* fix(blocks): find transcripts when the config dir name has glob characters

The transcript fallback for Block Timer, Block Reset Timer and Block %
globbed `<config dir>/projects/**/*.jsonl` with the absolute config
directory written into the pattern. Characters a directory name can
contain, such as ( ) [ ] { } * ?, are glob syntax inside a pattern, so
with CLAUDE_CONFIG_DIR at a path like "~/claude (work)/.claude" the
pattern never matched the literal path. Every scan found no
transcripts, so Block Timer stayed at "0hr 0m" and Block Reset Timer at
[Loading] for as long as that directory was in use.

The glob already runs with the config directory as its cwd, so the
pattern is now the relative `projects/**/*.jsonl`. The directory is
then only ever a path, never part of the pattern.

* fix(blocks): don't count rejected requests as block activity

When a request is rejected (a usage limit, an expired login, an API
outage), Claude Code writes an assistant row with isApiErrorMessage set,
model "<synthetic>" and every usage field 0. The block scan only checked
that input_tokens and output_tokens were numbers, so these rows passed
as activity even though nothing was served:

- With only rejected requests in the last 5 hours, Block Timer and Block
  Reset Timer showed a running block that doesn't exist, e.g.
  "Block: 3hr 1m" / "Reset: 1hr 58m" instead of "0hr 0m" / [Loading].
- When rejected requests came before real work, the block started at
  the hour of the first rejection, so the reset was reported hours
  early.

Either wrong start was then cached for 5 hours.

The scan now skips rows flagged isApiErrorMessage, as the token and
context metrics and Cache Timer already do.

* fix(blocks): don't end the block chain at a gap a skipped transcript may fill

To find the current 5-hour block, the transcript fallback walks back from
the newest activity to the first gap of 5 hours or more, then chains
blocks forward from there. It reads in widening passes (10h, 20h, 48h),
and each pass reads only the transcripts last written after its cutoff.
But it keeps every row in the transcripts it reads, so a session left
open since yesterday brings in rows from long before the cutoff. A gap
between those rows and today's was taken as the session boundary even
when another transcript, skipped only because it was last written
before the cutoff, has rows inside it. The wider passes never ran, the
chain started at the wrong row, and the block start came out hours
early: Block Timer read 4hr 57m instead of 2hr 57m, the reset was
predicted 2 hours early, and the wrong start was cached until then.

A skipped transcript can only hold rows from before the cutoff. So a gap
is now a boundary only if the part of it after the cutoff spans a
session by itself; otherwise the next, wider pass reads the skipped
transcripts first. The last pass still takes any gap, as before.

* fix(blocks): don't trust block cache entries dated ahead of the clock

The block cache keeps either the current block's start or, after a scan
that found no block, an instant until which scans are skipped (one
minute ahead). Neither had an upper bound. When the system clock runs
ahead for a render (a dual-boot clock mismatch, a manual change) and is
then set back:

- a "no block" entry stood for the whole skew plus a minute, so Block
  Timer showed "0hr 0m" and Block Reset Timer [Loading] for that long
  (a day, after a day's skew), even with fresh activity in the
  transcripts;
- a block start cached under the fast clock lies in the future, and
  still counted as valid, so elapsed time stayed at 0 and Block Reset
  Timer at 5hr for the skew plus 5 hours.

A "no block" entry is now trusted only while it ends within one
interval, and a cached start only once it has begun; otherwise the
transcripts are scanned again.

* test(blocks): cover the transcript rows the block scan skips

The block scan counts only served requests as activity. Besides rejected
requests, it skips lines that don't parse, rows without both input and
output token counts (such as user messages), sidechain rows, and rows
whose timestamp is missing, not a string, or not a valid date. Only the
rejected-request case had a test against the real scan; this one puts a
row of each other kind after a real request and checks that the block
and its last activity come from that request alone.

The row with the invalid date comes first so that, if it were kept, it
would sort ahead of the real request and show up as the last activity.

* refactor(blocks): move the activity-row check into its own function

getAllTimestampsFromFile both read the transcript and decided which rows
count as block activity, so the six reasons to skip a row sat as a run of
continue statements inside the line loop. getActivityTimestamp now
answers that question for one row, returning its timestamp or null with
early returns, and the loop only reads lines and keeps what it returns.
The row shape both functions use gets a name, TranscriptRow.

No change in which rows count.

* test(blocks): add an output-only row to the skipped-rows test

The block scan counts a row as activity only when it has both an input
and an output token count. The skipped-rows test had a row with input
tokens and no output tokens, but none with output tokens and no input
tokens, so a scan that checked only the output count still passed it.
This adds that row, so the test now fails if either count stops being
required.

* test: skip invalid Windows glob directory names

---------

Co-authored-by: Matthew Breedlove <[email protected]>
…e mock (sirmalloc#753)

ThinkingEffort.test.ts replaced ../../utils/claude-settings with a
vi.mock factory that exports only a vi.fn() loadClaudeSettingsSync.
Under Bun's test runner that mock isn't scoped to the file: test files
that run later in the same `bun test` process import the mocked module
too. When claude-settings.test.ts runs after it, its
loadClaudeSettingsSync tests get the stub's {} instead of the settings
file they wrote, and "should parse the settings file" and both
invalid-JSON tests fail. Whether they run in that order depends only on
the order the runner picks for the files: a CI run on Ubuntu hit it, and
running just these two files reproduces it on main.

The stub is now vi.spyOn on the module namespace, set in beforeEach and
restored in afterEach, the way VoiceStatus.test.ts stubs getVoiceConfig
and terminal.test.ts explains for terminal-width-cache. The tests check
the same things as before.
* feat(widgets): add a Fast Mode widget

Claude Code reports whether fast mode is on in the status line payload
(fast_mode), but nothing showed it. Fast Mode renders it like Voice
Status: icon plus text (⚡ on / ⚡ off) by default, an icon with a state
dot, plain text, or "fast on/off", with optional Nerd Font flash icons.
It can hide while fast mode is off, and renders nothing when the payload
has no fast_mode (older Claude Code).

A malformed fast_mode is dropped instead of failing the payload, so the
rest of the status line still renders.

* refactor(fast-mode): pick the icon in a helper instead of nested ternaries
…oc#755)

* feat(tui): preview the highlighted widget live while picking

Seeing what a widget looks like used to mean adding it, looking, and
removing it again. While the add/insert/change picker highlights a
widget, the status line preview now shows the line exactly as Enter
would leave it; ESC or browsing categories restores the line.

The new widget's id and powerline background are fixed when the picker
opens, so the preview doesn't change color on every keypress and Enter
adds exactly what was shown. Preview and apply share one placement
helper. A type change drops the widget's label there, as the label
editor (sirmalloc#601) does, so the preview shows the line without the old
label, as Enter applies it.

The picker rendered every entry, so the All category (~90 widgets) or a
broad search ran past the bottom of the terminal and scrolled the
preview off the top. Picker lists now show only the entries around the
selection, sized to the terminal height, the number of status lines and
the list's tallest description (wrapped at the terminal width as Ink
wraps it), with "↑ N more" / "↓ N more" markers. Entry numbers stay
absolute, so position in the full list is still visible.

* refactor(tui): find the highlighted picker category with indexOf

The category list holds plain strings, so finding the highlighted one
needs no callback; a picker with no categories has none highlighted.
* feat: add an Extra Usage Limit widget

The Extra Usage widgets show the amount spent, what's left and the
percentage used, but never the monthly limit itself, so a line could
read "Spend Used: $123.45" with no way to show "of $500". Remaining
and Utilization only imply it.

Extra Usage Limit shows the monthly limit from the usage API, in the
reported currency and the cost number format, labeled like the other
Extra Usage widgets ("Overage Limit", or "Spend Limit" on accounts
without plan limits). When extra usage is on without a monthly limit
it shows "none", and when it's off "n/a", with the same hide options as
its siblings. It needs only the extra usage state and the limit, which
come from the fetch the other Extra Usage widgets already make.

* fix: enable Extra Usage Limit label editing

---------

Co-authored-by: Matthew Breedlove <[email protected]>
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
1 New Critical Issues (required ≤ 0)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@eric-engberg

Copy link
Copy Markdown
Owner Author

Merged upstream as sirmalloc#699.

eric-engberg added a commit that referenced this pull request Oct 30, 2026
Merged against #72's final head (355af2e), which upstream squash-merged
as sirmalloc#699, so only main's changes since then come in.
eric-engberg added a commit that referenced this pull request Oct 30, 2026
…colors

Brings in upstream main through #46. #72 is upstream as sirmalloc#699, so the
files this branch had from it take main's versions, and only Block
Forecast's value colors stay on top: its widget, its tests and the docs
lines for (v). Main's BlockForecast.ts matches the #72 code this branch
built on, so the value colors apply to it unchanged.

# Conflicts:
#	docs/DEVELOPMENT.md
#	docs/USAGE.md
#	src/types/RenderContext.ts
#	src/utils/__tests__/usage-fetch.test.ts
#	src/utils/usage-fetch.ts
#	src/utils/usage.ts
#	src/widgets/BlockForecast.ts
#	src/widgets/__tests__/BlockForecast.test.ts
eric-engberg added a commit that referenced this pull request Oct 30, 2026
Merged against #72's final head (355af2e), which upstream squash-merged
as sirmalloc#699, so only main's changes since then come in.
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.

6 participants