Skip to content

feat(widgets): add editable label prefix to text-label widgets - #601

Merged
sirmalloc merged 16 commits into
sirmalloc:mainfrom
tough-griff:feat/widget-label-override
Oct 6, 2026
Merged

sirmalloc merged 16 commits into
sirmalloc:mainfrom
tough-griff:feat/widget-label-override

Conversation

@tough-griff

@tough-griff tough-griff commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Closes #600

Adds a per-widget label override for widgets that show a text label (Model: , Ctx: , In: , …). Select the widget in the line editor and press b (la(b)el…).

Changes

  • Storage: the override is saved as metadata.label and used exactly as typed. An empty string removes the label. A value equal to the current default is still stored, so it applies in every mode (see below). Changing a widget's type drops its label. No schema change or migration.
  • Rendering: formatRawOrLabeledValue now uses the override, so every widget that already went through it gets the feature from one change. Each widget's render takes its default from its own getLabelPrefix(item), so the label the editor offers and the one rendered can't drift apart. Widgets that built their label inline now use the helper too: Model, Session ID, Account Email, Context Length/Window/Bar, Memory Usage, Output Style, Session Clock/Name/Cost, Thinking Effort, Terminal Width, Git Staged/Unstaged/Untracked Files, and Current Working Dir (its optional glyph still comes before the label).
  • Opt-in: widgets declare a new optional Widget.getLabelPrefix(item), which returns their default label for the item's current mode (Context % gives Ctx Used: or Ctx Left: ; Block, Block Reset and Weekly Reset timers give Block , Reset and Weekly Reset in their bar modes). The usage and speed families get theirs from their shared config.
  • One override for every mode: widgets with more than one label variant (Block: vs Block before a bar) use the same override in all modes, even one that matches the current mode's default (e.g. Block set in a bar mode also drops the colon in time mode).
  • TUI:
    • The keybind is added centrally in ItemsEditor, the same way (h)ide… is, and is hidden while raw value is on.
    • It opens a new LabelEditor. The value is shown in quotes so trailing spaces are visible. Enter saves the typed label and Tab clears the override back to the default.
    • Rows show (label: "M ") while an override is set. The label is JSON-escaped there and in the editor's default, so a " inside it reads clearly.
    • l would be the natural key, but the reset timers already use it for (l)ocale.
  • Shared text cursor: CustomText's grapheme-aware cursor code is moved into useTextCursor (src/widgets/shared/text-cursor.ts), which the label editor and Custom Text now both use. This also makes Ctrl+←/→ jump to the start/end as Custom Text's hint says. The old checks compared input to 'ArrowLeft', which Ink never sends, and ran after the plain-arrow branch, so they never matched. Edits apply through a functional state update so keys typed before a re-render aren't lost, and the cursor ends with \x1b[27m (inverse off) rather than a full reset. getGraphemes is exported and replaces the copies in Custom Symbol and the symbol override editor.
  • Out of scope: glyph widgets keep (g)lyph. Widgets whose prefix is really part of the value (Version, Git PR, Sandbox/Voice/Remote Control status, Git Is Fork, Link) aren't changed.
  • Behavior change: the reset timers' loading placeholder follows the bar-mode label, so in progress/slider modes it reads Reset [Loading] / Weekly Reset [Loading] instead of Reset: [Loading].
  • Docs: docs/USAGE.md covers the keybind in the raw value section and in the editor keybind list.

Testing

  • Unit tests for the label helper and the text cursor (grapheme movement/deletion with emoji, edges, Ctrl jumps, cursor rendering)
  • Override cases for Model and Session Usage (text and slider modes), plus Context %'s mode-dependent default
  • A registry-wide test that every label widget's preview render, in every display mode, contains its getLabelPrefix(item)
  • Bar-mode loading cases for the Block Reset and Weekly Reset timers
  • A registry-wide test that no label widget binds b itself in any mode, next to the existing h check
  • An end-to-end ItemsEditor test covering b → edit → save → (label: "M "), with the keybind and row tag hidden in raw value mode. Further editor tests cover: a type change dropping the label; two keys typed before a re-render both landing; a joined-emoji label shown whole beside its default; and a bar-mode Block label being saved, then cleared with Tab
  • bun test and bun run lint pass
  • Manually checked in the TUI and with piped input:
$ echo '{"model":{"id":"claude-sonnet-4-5","display_name":"Sonnet 4.5"}}' | bun run src/ccstatusline.ts --config settings.json
M Sonnet 4.5 | Model: Sonnet 4.5 | 80
settings.json
{
  "version": 4,
  "lines": [
    [
      { "id": "1", "type": "model", "metadata": { "label": "M " } },
      { "id": "2", "type": "separator" },
      { "id": "3", "type": "model" },
      { "id": "4", "type": "separator" },
      { "id": "5", "type": "terminal-width", "metadata": { "label": "" } }
    ]
  ]
}

🤖 Generated with Claude Code

tough-griff and others added 3 commits September 24, 2026 11:05
Widgets that prepend a text label when raw value is off ("Model: ",
"Ctx: ", "In: ", ...) now accept a per-item override stored in
metadata.label. The override replaces every label variant of a widget
verbatim; an empty override drops the label.

Widgets opt in via Widget.getLabelPrefix(item). Rendering goes through
formatRawOrLabeledValue, which inline-label widgets now use too. The
items editor offers a shared la(b)el… keybind (hidden in raw value
mode) that opens a new LabelEditor, and lists overrides as
(label: "...").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Extract CustomText's inline cursor handling into useTextCursor
(src/widgets/shared/text-cursor.ts) and use it from both the Custom
Text and label editors, giving the label editor cursor movement and
forward delete.

Ctrl+Left/Right now jump to the start/end as the editor hint says.
The old checks compared input to 'ArrowLeft', which Ink never sends,
and ran after the plain-arrow branch, so they were unreachable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
axisrow added a commit to axisrow/ccstatusline that referenced this pull request Sep 26, 2026
…es (#10)

* feat(widgets): compact label presets with global and per-widget toggles

Add abbreviated label presets for labeled widgets, complementary to the
free-form label editing proposed upstream (sirmalloc#601):

- New `compactLabels` settings flag (default off). Additive v4 key: old
  configs parse unchanged and render identically, no version bump needed.
- Presets live in one map (`Model:` -> `M:`, `Context:` -> `Ctx:`,
  `Cost:` -> `` — the value's own `$` is the glyph) inside
  `formatRawOrLabeledValue`, so every widget routed through the helper
  picks them up with no per-widget changes. Model, Session Cost, and
  Context Bar moved onto the helper.
- One-click global toggle in Global Overrides `(j)`; per-widget override
  via `(j) compact label` in the items editor (metadata `compactLabel`),
  which wins over the global setting in both directions. Capability for
  the editor keybind is derived from a cached preview probe of the
  widget registry.
- Extend the Git Branch width-cap pattern to Current Working Dir and
  Session Name (`w` max-width keybind).
- Document per-widget padding/separator/label width overhead in
  docs/USAGE.md; defaults unchanged.

Tests: preset resolution, toggle metadata, global injection precedence,
migration parity (v1->v4 and schema-less v4 parse), registry-wide `j`
keybind reservation, capable-type probe sanity, Global Overrides `(j)`
keystroke test. bun test 2400 pass; bun run lint clean.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(tui): compact-label toggle cycles over the effective state

The per-widget (j) toggle only wrote/removed 'true', so with the global
Compact Labels setting on it became a visual no-op: the render-time
injection re-compactified the widget after the key was removed, and no
row marker showed the effective state.

- toggleCompactLabel now takes the settings and cycles the EFFECTIVE
  state: compact (saved 'true' or inherited global on) -> write 'false';
  forced off -> drop the key and inherit; otherwise -> write 'true'.
- getCompactLabelModifierText reports the effective state:
  '(compact label)' / '(compact label: on)' / '(compact label: off)'.
- ItemsEditor/input-handlers pass settings through to both.
- docs/USAGE.md: fix preset savings arithmetic (Model -4, Context -4).

Co-Authored-By: Claude Code <noreply@anthropic.com>

* test(renderer): cover (j) toggle forcing labels off while global compact is on

Co-Authored-By: Claude Code <noreply@anthropic.com>

* style: fix import order in compact-labels renderer test

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: axisrow <axisrow@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>

@eric-engberg eric-engberg left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this one. It's a feature I've wanted, and opting widgets in with getLabelPrefix keeps it tidy. I tried it and read through it, and ran into a few things:

  1. Timer widgets in their bar modes. Block Timer, Block Reset Timer and Weekly Reset Timer render Block (no colon) before a bar, but getLabelPrefix() always returns Block: . In a bar mode the editor offers Block: as the default. Typing Block: there is then dropped by setLabel as equal to the default, so the bar keeps Block . The same goes for Reset and Weekly Reset . Returning the bar-mode label from getLabelPrefix(item) in those modes, as Context % does for its two labels, would line them up.

  2. Changing a widget's type keeps the label. Changing the type with → keeps the item's metadata, and nearly every labeled widget now reads metadata.label. So a label set on Model (M ) shows up on Session Cost after a type change (M $2.45). If the new type has no label, the old one stays hidden with no b to clear it, and comes back on the next type change. Dropping label when the type changes would avoid that.

  3. One source for the default label. Each widget states its label in getLabelPrefix() and again as the literal it passes to formatRawOrLabeledValue, which is how the timer mismatch slipped in. If render took its label from this.getLabelPrefix(item), the two couldn't drift.

  4. useTextCursor and fast input. handleInput works from the state captured at render and calls setState(next). Two keys handled before a re-render apply to the same snapshot, and one is lost. A functional update, setState(prev => applyTextCursorInput(prev, input, key) ?? prev), avoids it. Custom Text's old code had the same pattern, so this isn't new, but the shared hook is a good place to fix it.

Smaller things:

  • LabelEditor puts the label and (default: …) in two sibling Texts. Ink lays out each code point of a toned or joined emoji (👍🏾, 👩‍💻) as its own character, so the second Text overwrites the end of such a label on screen; the saved value is fine. Nesting both in one Text avoids it.
  • getGraphemes in text-cursor.ts is private, so the same Intl.Segmenter code is still in CustomSymbol.tsx and shared/symbol-override.tsx. Exporting it would leave one copy.
  • renderTextWithCursor splits the text twice and builds a new segmenter each call. One module-level segmenter and a single split would do.
  • (label: "…") and the editor wrap the label in quotes without escaping it, so a label containing " reads ambiguously. JSON.stringify(label) would keep it clear.
  • After the cursor, \x1b[0m resets every attribute; \x1b[27m would end just the inverse.

eric-engberg added a commit to eric-engberg/ccstatusline that referenced this pull request Oct 6, 2026
sirmalloc#601 lets Tokens Input, Output, Cached and Total, and Git Staged,
Unstaged and Untracked Files, offer their labels to the label editor
one widget at a time. #16 and #22 move those widgets onto shared base
classes, so the merges kept their shape and the labels went with it:
an edited label still drew, but the editor never offered (e).

Each base now offers its subclass's label, once for all of them.
tough-griff and others added 11 commits October 6, 2026 10:24
…drift

Block, Block Reset and Weekly Reset timers render a colon-less label in
their bar modes, but getLabelPrefix always returned the colon form, so the
label editor offered the wrong default and an override equal to the bar
label was never saved. getLabelPrefix now returns the bar-mode label in
those modes, and every widget's render takes its label from
getLabelPrefix rather than repeating it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Changing a widget's type keeps its metadata, so a label written for one
widget showed up on the next, or lingered hidden on a type without a
label editor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
useTextCursor applied each key to the state captured at render, so two
keys handled before a re-render edited the same snapshot and one was
lost. Edits now go through a functional state update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The label and its default sat in sibling Texts. Ink measures a toned or
joined emoji as several columns, so the default overwrote the end of
such a label on screen. Both now share one Text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Custom Symbol and the symbol override editor each kept their own copy of
the Intl.Segmenter split that text-cursor already has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
renderTextWithCursor split the text twice per render, and every split
built a new Intl.Segmenter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
\x1b[0m reset every attribute, so any styling around the edited text
stopped at the cursor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The (label: …) modifier and the editor's default wrapped the label in
quotes without escaping it, so a label containing " read ambiguously.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
setLabel dropped an override equal to the default for the widget's
current mode, so a widget whose default changes with its mode could not
pin that label for every mode (e.g. "Block " set in a bar mode still
rendered "Block: " in time mode). Enter now always saves the typed
label, and Tab clears the override instead of filling in the default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The loading placeholder takes the bar-mode label, so it reads
"Reset [Loading]" rather than "Reset: [Loading]" in progress mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tough-griff

Copy link
Copy Markdown
Contributor Author

@eric-engberg thanks for the review! My tweaks worked at first pass for me and I admittedly didn't do much more thorough testing. I've addressed the feedback, PR description is updated accordingly.

@sirmalloc
sirmalloc merged commit 7bed5f2 into sirmalloc:main Oct 6, 2026
3 checks passed
eric-engberg added a commit to eric-engberg/ccstatusline that referenced this pull request Oct 6, 2026
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.
eric-engberg added a commit to eric-engberg/ccstatusline that referenced this pull request Oct 6, 2026
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.
eric-engberg added a commit to eric-engberg/ccstatusline that referenced this pull request Oct 6, 2026
Upstream's label editor (sirmalloc#601) drops a widget's label when its type
changes, since the label names the old widget's value ("Model: " on
Session Cost). It does that where the picker applies a type change,
which this branch moves into placePickerSelection so the live preview
and Enter share it. The label drop moves there too, so the preview
shows the line without the old label, as Enter applies it.
eric-engberg added a commit to eric-engberg/ccstatusline that referenced this pull request Oct 6, 2026
…lors

Upstream's label editor (sirmalloc#601) lets Thinking Effort's "Thinking: " be
edited, but this branch draws the widget through formatThinkingEffort,
which wrote the label itself. It now draws the edited label, or none,
inside the brackets and in the widget color, and the default comes from
one constant that getLabelPrefix shares.

This branch also merges the label-key branch (#48): its (b)rackets key
would otherwise clash with upstream's la(b)el… on a labeled widget.
eric-engberg added a commit to eric-engberg/ccstatusline that referenced this pull request Oct 6, 2026
…ication

Brings in upstream main, including the label editor (sirmalloc#601). Where sirmalloc#601
meets this branch's shared bar code:
- The bar widgets draw a bar through formatUsageBar, labeled with the
  widget's getLabelPrefix(item), so a bar mode keeps sirmalloc#601's bar label
  ("Block ", "Reset ") and an edited label.
- Context % and Context % (usable) declare their label once, on
  ContextPercentageWidgetBase; the usage widgets on UsagePercentWidget.
eric-engberg added a commit to eric-engberg/ccstatusline that referenced this pull request Oct 6, 2026
Brings in upstream main, including the label editor (sirmalloc#601). This branch
draws Model through formatModelName, which wrote "Model: " itself, so
it now draws the edited label, or none, in the widget color; the
default comes from one constant that getLabelPrefix shares.
eric-engberg added a commit to eric-engberg/ccstatusline that referenced this pull request Oct 6, 2026
…t/value-coloring

Brings in Extra Usage Today with upstream main merged. Value colors draw
their widget's label themselves, so with upstream's label editor (sirmalloc#601)
formatColoredValue now draws the edited label, or none, in the widget
color.
eric-engberg added a commit to eric-engberg/ccstatusline that referenced this pull request Oct 6, 2026
Extra Usage Today, Daily Budget, Extra Usage Limit, Session Cost Rate
and Daily Cost Rate declare their default labels, so (e)dit label offers
them as upstream's label editor (sirmalloc#601) does for its own widgets. The
editor's default for Extra Usage Today and Limit is the "Overage"
label, since it has no usage data; accounts without plan limits show
"Spend". Each builds that label in one place for the editor and render.

So "today: $12.34 of $50.00/day" is Extra Usage Today labeled "today: ",
Daily Budget labeled "of " and a "/day" Custom Text, with no raw values.
eric-engberg added a commit to eric-engberg/ccstatusline that referenced this pull request Oct 6, 2026
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.
j3r0lin added a commit to j3r0lin/ccstatusline that referenced this pull request Oct 9, 2026
Conflict resolutions:
- Label override (upstream sirmalloc#601): fork widgets keep their own rendering and
  read the label through getLabelPrefix/getLabel, so per-item label overrides
  apply. Raw mode keeps the fork's metadata.prefix.
- Usage percent widgets (upstream sirmalloc#545 moved them onto a shared module): the
  fork's SessionUsage, WeeklyUsage, WeeklyOpusUsage and WeeklySonnetUsage keep
  their own implementations (pace indicator, monthly/weekly promotion, cursor),
  so they do not take label overrides. FableWeeklyUsage, unchanged in the fork,
  uses the upstream shared module.
- BlockResetTimer keeps the "Weekly Reset" label when weekly is promoted into
  the session slot, now derived from the mode-aware upstream label.
- ContextLength keeps the fork's resolveContextLengthTokens fallback instead of
  the upstream tokenMetrics branch.
- MonthlyUsage now honors the configured percent number format, which the
  shared upstream usage widget test suite checks.
sirmalloc pushed a commit that referenced this pull request Oct 9, 2026
…s too (#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 (#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 (#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.
pcvelz added a commit to pcvelz/ccstatusline-usage that referenced this pull request Oct 11, 2026
Upstream: label prefixes editable with e (sirmalloc#601, sirmalloc#666), Session Cost Rate (sirmalloc#671) and Extra Usage Daily Budget (sirmalloc#672) widgets, own-lock-only usage lock clearing (sirmalloc#624), NO_PROXY and request deadlines, terminal control-sequence sanitizing, git hardening and linear-time parsing, shared searchable option editor (sirmalloc#649) and widget base classes (sirmalloc#646-sirmalloc#654), node: imports (sirmalloc#657), Number.* globals lint (sirmalloc#645), test waits on redraw instead of fixed delays (sirmalloc#652).

Hand edits outside conflicts:
- src/widgets/shared/searchable-option-editor.tsx: compat fix - ports the fork's ref-based input handling (Enter picks from what was just typed) into upstream's new shared editor
- src/utils/usage-fetch.ts: compat fix - fork's fetchFreshUsageData uses upstream's clearOwnUsageLock (sirmalloc#624) instead of the removed clearUsageLock
- src/widgets/ApiUsage.tsx, src/widgets/Battery.ts: compat fix - Number.isNaN/Number.parseInt for upstream's no-restricted-globals rule
- src/utils/__tests__/usage-prefetch.test.ts: compat fix - mock renamed to upstream's mockFetchUsage
- src/tui/components/__tests__/PowerlineSetup.test.ts, src/tui/components/__tests__/StatusLinePreview.test.ts, src/tui/components/__tests__/TerminalWidthMenu.test.ts: compat fix - drop the fork's flushInk import, unused after upstream's waitFor switch
- README.md: upstream notes linked docs/USAGE.md (deleted in the fork); inlined the proxy and workspace-trust text instead; upstream's own release-note entries not taken
- .fork-keep-deleted: src/widgets/FableWeeklyUsage.ts (upstream widget, unregistered in the fork, which ships WeeklyFableUsage.ts)
- src/widgets/WeeklyFableUsage.ts: compat fix - adopts upstream's shared getUsagePercentWidgetEditorDisplay, which carries the time-cursor modifier
- src/tui/__tests__/App.test.ts: fork deviation - one extra down-arrow for the fork's Off Hours main-menu row
- src/widgets/__tests__/CacheTimer.test.ts, src/widgets/CacheTimer.ts: compat fix - emoji toggle moved from e to o because upstream reserves e for edit-label
- src/widgets/AgentAddress.ts: split the session-title guard into its own if (same behaviour) so the .fork-invariants nameSource guard matches again
- docs/test-retirements.md: ledger for tests upstream renamed or replaced
eric-engberg added a commit to eric-engberg/ccstatusline that referenced this pull request Oct 27, 2026
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.
sirmalloc pushed a commit that referenced this pull request Oct 29, 2026
* 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 (#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.
sirmalloc added a commit that referenced this pull request Nov 6, 2026
* 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.

* refactor: share the cache file lock

Move the terminal width cache's best-effort O_EXCL lock into its own
module so other cache files can use the same read-modify-write
protection instead of copying it. No behavior change.

* feat: add a daily state file for per-day usage tracking

Some usage figures are per UTC day, but ccstatusline only ever sees the
current month-to-date totals. Keep a small state file next to the other
caches that records what was seen and when, modeled on the terminal
width cache: one JSON file, the shared lock around each
read-modify-write, an atomic temp-file rename, pruning to today and
yesterday, and injected file and clock dependencies.

* feat: track each day's starting extra usage spend

The usage API reports extra usage spent month to date only. Record the
total per login in the daily state file and work out today's spend from
where it stood when the UTC day began: the last value seen the previous
day, zero on the 1st (extra usage resets at 00:00 UTC), or otherwise the
first value seen today. A total that drops mid-day never makes today's
spend negative, and an observation that loses the state lock to another
process still reports today's spend.

* feat: work out today's extra usage spend during the usage prefetch

fetchUsageData now remembers the token fingerprint of the login it
used. When a line shows today's spend, the prefetch records the
month-to-date total for that login in the daily state file and adds
extraUsageUsedToday (cents) to the usage data. It's worked out on every
render, cache hits included, and never stored in the usage cache.

* feat: add an Extra Usage Today widget

Shows the extra usage spent since 00:00 UTC, including claude.ai and
other machines, labeled like its siblings: "Spend Today" on accounts
without plan limits, "Overage Today" otherwise. It shows nothing until a
usage fetch has run since the UTC day began, and n/a when extra usage
is off, with the same hide options, currency and cost number format as
the other Extra Usage widgets.

* fix: count a cached extra usage total for the day it was fetched

Today's spend recorded every usage prefetch as an observation at render
time, cache hits and stale fallbacks included. A total fetched at 23:58
UTC on the last day of the month and replayed from the cache at 00:01
became the 1st's first observation, so Spend Today showed the whole
previous month for up to three minutes, or for as long as the API stayed
unreachable.

fetchUsageData now reports when its data came from the API
(getUsageFetchedAt: now for a fresh fetch, the cache file's write time
for a cache hit or stale fallback), and the observation is recorded at
that time. A total fetched before midnight updates the previous day's
record, so today's spend shows nothing until a total fetched today
arrives, and an older fetch replayed after a newer one is ignored.

* test: check fetchUsageData reports the login and fetch time

Today's extra usage spend depends on fetchUsageData recording which
login and which fetch its data came from, and every other test replaces
that link with a spy. Run the real fetch in the probe harness and check
it reports the login fingerprint the usage cache is written with and the
API fetch time, and neither when there are no credentials.

* test: cover the Spend label with an edited label

Upstream's label editor (#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 (#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.

* fix: drop extra usage times stamped while the clock ran ahead

Extra Usage Today ignores a fetch older than the one it last recorded,
so a cached fetch a concurrent render replays late can't move the day
back. A single render made while the system clock ran ahead defeats
that: it records a fetch stamped at a time that hasn't come yet. Once
the clock is put right, every real fetch looks older and is ignored, so
Today disappears for the rest of the day; and when the clock reaches the
stamped time, that record is taken as the day's, with the previous
day's total as its start. With $10 seen at 09:00, one render a day
ahead, then $25 and $40 later that day, Today was hidden for the rest of
the day and showed $40 the next day for at most $10 of real spend.

The usage cache that render wrote is stamped by the same clock and is
served as fresh until the clock catches up with it, so its fetch keeps
arriving with the future time and would store the same record again.

A record last seen more than a minute ahead of now is now dropped and
the day starts afresh, and a fetch stamped more than a minute ahead
isn't recorded. The minute's leeway keeps a small step back of the
clock, such as an NTP correction just after a fetch, from dropping the
day's starting point. Now is read after the state file, so a fetch
another render has just recorded is never taken for one from the
future and the replay guard still holds.

* fix(usage): remove daily.json's temp files when a write fails

updateDailyState writes daily.json through a temp file named
daily.json.<pid>.tmp and renames it into place. When the write or the
rename failed, for example a write cut short by a full disk or a rename
refused with EPERM while a Windows virus scanner or sync client holds
daily.json, the error went to the catch-all and the temp file stayed
behind. Every status line render is a new process with a new pid, so
each failing render left another orphan in ~/.cache/ccstatusline, and
nothing ever deleted them.

A failed write or rename now unlinks the temp file. A failed unlink is
ignored; as before, the state stays best-effort and the render goes on.

The same handle can be held on the temp file itself, and then it
refuses the unlink as well as the rename. So each write also removes
the other daily.json.<digits>.tmp files next to the state file once
they are a minute or more old, which no running write can still own.
While a handle stays held, only the last minute's temp files remain,
and orphans an older build left behind are cleared as well. The temp
name stays per process, so two renders never write through the same
file.

* fix(usage): keep today's extra usage spend across a token refresh

Extra Usage Today keeps each login's month-to-date total at the start of
the UTC day in daily.json, keyed by the usage cache's token fingerprint,
a hash of the refresh token. Claude Code saves a new refresh token each
time it refreshes the access token, which happens several times on a
working day. The key then changed mid-day, the new key had no record,
and today's spend started over from that moment: $15.00 at 13:00, then
$0.00 at 17:00 instead of $35.00 and $10.00 at 18:00 instead of $45.00.
The old record stayed behind until it was pruned.

The record is now kept under the account and organization the
credentials belong to. While a widget shows today's spend, ccstatusline
asks /api/oauth/profile through the usage fetch's own request, proxy
and credential code, once per token fingerprint, and keeps the answers
in ~/.cache/ccstatusline/usage-account.json. A failed lookup holds off
that fingerprint alone, for 30 seconds or a 429's Retry-After (at most
a day), and the hold-off is written before the request goes out, so
renders running side by side send one request between them.

Until the lookup answers, the record stays under the fingerprint as
before. Once the account is known, the records under its fingerprints,
written by an earlier build or while the lookup was failing, are folded
into the account's record once, so the day keeps its starting total.
A record seen while the clock ran ahead is dropped instead, as it would
be under its own key. A fingerprint that was never looked up can't be
placed with an account, so today's spend still starts over once when
an earlier build kept the record under a refresh token that has since
changed, or when a lookup outage spans a refresh.

The organization is part of the key because one account can sign in to
a personal organization and a work one, each with its own extra usage
total. The key is a hash, so neither cache file names the account.

* refactor(usage): tidy the account lookup and the record merge

mergeSpendRecord goes through an arrow, so reduce's index and array
arguments never reach it, and the account id is built before it's
fingerprinted instead of inside a nested template literal.

* refactor(usage): ask the profile endpoint once for the account email and the account key

The Claude Account Email widget (#724) and Extra Usage Today each asked
/api/oauth/profile about the same login, each with its own cache and backoff.
One request now answers both. lookUpProfile runs under the email lookup's
per-login lease and backoff files, and each caller keeps its part of the
answer in its own cache: the email in account-email-<hash>.json, the account
key in usage-account.json, which now holds only named logins. An answer that
leaves out the caller's part holds that login off as before, and the other
part is still kept.

A rate-limited lookup's Retry-After is kept within the day the backoff reader
trusts, as the account lookup did, so the email lookup no longer reads a
Retry-After further off as no backoff at all.

* docs(development): the account email and Extra Usage Today share one profile lookup

* refactor(usage): name the account id before fingerprinting it

The account key nested the id's template inside its own on one line. Build
the id first, as the account lookup did before it shared the profile
request.

* fix: enable label editing for Extra Usage Today

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow customizing the text label on labeled widgets (e.g. "Model: ", "Ctx: ")

3 participants