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 <[email protected]>
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 <[email protected]>
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 <[email protected]>

* 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 <[email protected]>

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

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

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

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

---------

Co-authored-by: axisrow <[email protected]>
Co-authored-by: Claude Code <[email protected]>

@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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
renderTextWithCursor split the text twice per render, and every split
built a new Intl.Segmenter.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
@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.
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