Repository navigation
feat(widgets): add editable label prefix to text-label widgets - #601
Conversation
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]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…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
left a comment
There was a problem hiding this comment.
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:
-
Timer widgets in their bar modes. Block Timer, Block Reset Timer and Weekly Reset Timer render
Block(no colon) before a bar, butgetLabelPrefix()always returnsBlock:. In a bar mode the editor offersBlock:as the default. TypingBlock:there is then dropped bysetLabelas equal to the default, so the bar keepsBlock. The same goes forResetandWeekly Reset. Returning the bar-mode label fromgetLabelPrefix(item)in those modes, as Context % does for its two labels, would line them up. -
Changing a widget's type keeps the label. Changing the type with
→keeps the item's metadata, and nearly every labeled widget now readsmetadata.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 nobto clear it, and comes back on the next type change. Droppinglabelwhen the type changes would avoid that. -
One source for the default label. Each widget states its label in
getLabelPrefix()and again as the literal it passes toformatRawOrLabeledValue, which is how the timer mismatch slipped in. If render took its label fromthis.getLabelPrefix(item), the two couldn't drift. -
useTextCursorand fast input.handleInputworks from thestatecaptured at render and callssetState(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:
LabelEditorputs the label and(default: …)in two siblingTexts. Ink lays out each code point of a toned or joined emoji (👍🏾, 👩💻) as its own character, so the secondTextoverwrites the end of such a label on screen; the saved value is fine. Nesting both in oneTextavoids it.getGraphemesintext-cursor.tsis private, so the sameIntl.Segmentercode is still inCustomSymbol.tsxandshared/symbol-override.tsx. Exporting it would leave one copy.renderTextWithCursorsplits 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[0mresets every attribute;\x1b[27mwould end just the inverse.
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.
…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]>
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]>
|
@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. |
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.
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.
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.
…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.
…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.
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.
…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.
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.
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.
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.
…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.
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 pressb(la(b)el…).Changes
metadata.labeland 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.formatRawOrLabeledValuenow uses the override, so every widget that already went through it gets the feature from one change. Each widget'srendertakes its default from its owngetLabelPrefix(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).Widget.getLabelPrefix(item), which returns their default label for the item's current mode (Context % givesCtx Used:orCtx Left:; Block, Block Reset and Weekly Reset timers giveBlock,ResetandWeekly Resetin their bar modes). The usage and speed families get theirs from their shared config.Block:vsBlockbefore a bar) use the same override in all modes, even one that matches the current mode's default (e.g.Blockset in a bar mode also drops the colon in time mode).ItemsEditor, the same way(h)ide…is, and is hidden while raw value is on.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.(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.lwould be the natural key, but the reset timers already use it for(l)ocale.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 comparedinputto'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.getGraphemesis exported and replaces the copies in Custom Symbol and the symbol override editor.(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.Reset [Loading]/Weekly Reset [Loading]instead ofReset: [Loading].docs/USAGE.mdcovers the keybind in the raw value section and in the editor keybind list.Testing
getLabelPrefix(item)bitself in any mode, next to the existinghcheckItemsEditortest coveringb→ 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-modeBlocklabel being saved, then cleared with Tabbun testandbun run lintpasssettings.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