Repository navigation
feat(usage): forecast the 5-hour block (Block Forecast, Block Limit Timer) - #72
Closed
eric-engberg wants to merge 99 commits into
Closed
eric-engberg wants to merge 99 commits into
eric-engberg wants to merge 99 commits into
Conversation
eric-engberg
force-pushed
the
feat/session-forecast
branch
from
October 9, 2026 04:25
d00c985 to
2a3fecb
Compare
…, 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]>
…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.
This was referenced Oct 27, 2026
…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]>
|
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.




Standalone, cut from
main.What
Two new Usage widgets forecast the 5-hour block from the recent pace:
→83.2%.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:
5h: 42.0% →83.2%5h: 61.0% →100.0% Limit in: 1hr 13m5h: 100.0%5h: 42.0%Block Forecast has
u(show remaining, like Block Usage:→16.8%), and Block Limit Timer hass(short time:1h13m). Both support raw value mode and their own colors; Block Limit Timer defaults to red. Pair them with Block Reset Timer'sunder-limithide 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
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.~/.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.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.sessionUsage/sessionResetAtBlock Usage uses (the payload'srate_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.→100.0%and Block Limit TimerLimit 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
Limit in: 30minstead 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.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./loginto 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.(long bar, inverted)while it renderedLimit 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 hidcompacton 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.session-usage-history.json.<pid>.tmpand renaming it oversession-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 othersession-usage-history.json.<digits>.tmpfiles 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.fetchUsageDataserves 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.fetchUsageDatanow keeps when the API sent the values it returns: now for a fresh answer, the cache file's write time for a cached one.getUsageFetchedAtMsexposes 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.session-usage-history.json.lockexclusively, 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./login.getUsageLoginFingerprintreads 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.mainas it is.Demo
Fake data: a seeded reading history and payloads with the reset 1hr 43m away, in four situations.
Powerline
Plain
Adding both after Block Usage with the picker's search:
Powerline
Plain
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:
→, so the forecast reads as part of the percent:5h: 42.0% →83.2%;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
Plain
The line editor holding that line:
Powerline
Plain
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:
Plain, before and after:
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:
Plain, before and after:
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: 45mand→100.0% | Limit in: 19m.Powerline, before and after:
Plain, before and after:
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:
Plain, before and after:
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:
Plain, before and after:
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:
Plain, before and after:
Testing
bun test: 3129 pass, 0 fail.bun run lintpasses. The new test files never write toHOME(run withHOMEpointed at an empty directory).mainonly where the new widgets show. The TUI opens and exits cleanly under both.Limit in: 1hr 13m, which reads as the time until the limit rather than its size):bun test3129 pass, 0 fail;bun run lintpasses; under Node (Vitest) the two changed test files pass, 13 of 13; the built CLI matchesmainwith the baseline configs under Bun and Node. The media is re-recorded for the new label.Update:
→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.dist/ccstatusline.jsunder 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.Limit in: 30mtoLimit in: 1hr 40m. At 40% it went from→100.0% | Limit in: 1hr 30mto→70.0%.→70.0%and nothing to→100.0% | Limit in: 1hr 40m, the same as the active session./loginto 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.(long bar, inverted)and(long bar, remaining)to(compact)and(remaining).bun test: 3263 pass, 0 fail, 9311expect()calls across 184 files.bun run lint(tsc --noEmitplus ESLint with--max-warnings=0): clean.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.src/utils/__tests__/session-usage-history.test.ts:HOMEandCLAUDE_CONFIG_DIR, using the dist built with the package.json build flags:session-usage-history.json.<n>.tmpfiles.daily.json.333.tmpand a plainsession-usage-history.json.tmp.chflags uchgon the history file, standing in for a held Windows handle, left no new temp files.getUsageFetchedAtMs.bun test: 3278 pass, 0 fail across 184 files.bun run lint: clean.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 test3359 pass, 0 fail across 186 files;bun run lintclean.fetchUsageDataprobe 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 (itsnode:child_processmock has nospawn).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.