Skip to content

fix(tui): ignore letter shortcuts when ctrl or alt is held - #5

Closed
eric-engberg wants to merge 22 commits into
developfrom
fix/hotkey-modifiers
Closed

eric-engberg wants to merge 22 commits into
developfrom
fix/hotkey-modifiers

Conversation

@eric-engberg

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

Copy link
Copy Markdown
Owner

Bottom of the stack, targeting develop. Replaces #3, which GitHub auto-marked as merged when the stack was reordered (its old base branch came to contain its commit). It only touches code that exists on main.

Problem

Letter shortcuts compared the typed character without checking modifiers, so a ctrl or alt combo ran the plain-letter action:

  • ctrl+r toggled raw value in the line editor and reset styling in Edit Colors.
  • ctrl+d deleted a widget, and ctrl+a opened the add-widget picker. That last one matters for anyone whose tmux prefix is ctrl+a: pressing ctrl+a twice sends a literal ctrl+a through.
  • On macOS, alt/option+←/→ is commonly sent as ESC b / ESC f, which Ink reads as alt+b / alt+f. Word-jumping with option-arrow could therefore toggle bold, background mode, and so on.
  • A few handlers had already patched ctrl+s one key at a time with && !key.ctrl. Widget keybinds checked ctrl but not alt.

Fix

getPlainInput(input, key) in utils/input-guards.ts returns the typed character only for a bare keypress, and '' while ctrl or alt/option is held. Every letter shortcut now compares against it:

  • the line editor's built-in shortcuts and the widget-specific keybind matching
  • Edit Colors, Global Overrides, and Powerline setup, themes and separators
  • the line selector and the hide-state checklist

These are unchanged:

  • arrows, Enter and ESC, so option+arrow still navigates
  • shift, so A/a handlers keep working
  • text entry, which was already guarded by shouldInsertInput
  • App's real combos, ctrl+c and ctrl+s

The ad-hoc !key.ctrl patches are gone, and modifier combos are free for bindings of their own.

Modifiers on macOS and Windows/Linux

Ink 6.2 reports ctrl, meta (alt/option) and shift:

  • Guarding ctrl and meta covers Ctrl, PC Alt, and Mac Option when "Option as Meta/Esc+" is on.
  • Mac Option in its default mode types a different character (∫, ®, …), which never matches a shortcut letter.
  • Cmd and the Windows key normally aren't passed to terminal programs.
  • AltGr arrives as the resulting character, so it's unaffected.

Testing

  • getPlainInput unit tests.
  • Line-editor handler tests: ctrl and alt held with each of a i d k c r m do nothing, the bare letter still works, and widget keybinds ignore alt as well as ctrl.
  • hotkey-modifiers.test.ts: a table-driven Ink test that sends the real terminal bytes. Ctrl is the control character, e.g. \x12 for ctrl+r; alt is ESC + key. For one shortcut in each of the seven components, it checks that the bare key fires and ctrl and alt don't. ctrl+i isn't used because it's the same byte as Tab.
  • bun test: 2457 pass. bun run lint: clean.
  • Manual run in tmux, which sends genuine M- and C- sequences: alt+b, ctrl+r and alt+d do nothing in the line editor, while plain b and r work.

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.
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.
@sonarqubecloud

sonarqubecloud Bot commented Oct 4, 2026

Copy link
Copy Markdown

# Conflicts:
#	src/tui/components/items-editor/input-handlers.ts
@eric-engberg
eric-engberg marked this pull request as ready for review October 6, 2026 18:20
eric-engberg and others added 10 commits October 9, 2026 09:51
…, save guard return) (sirmalloc#689)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
…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 <sirmalloc@gmail.com>
… 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.
eric-engberg and others added 9 commits October 9, 2026 10:56
…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 <sirmalloc@gmail.com>
…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 <sirmalloc@gmail.com>
…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.
# Conflicts:
#	src/tui/components/ColorMenu.tsx
#	src/tui/components/LineSelector.tsx
#	src/tui/components/PowerlineThemeSelector.tsx
#	src/tui/components/items-editor/input-handlers.ts
@sonarqubecloud

sonarqubecloud Bot commented Oct 9, 2026

Copy link
Copy Markdown

@eric-engberg
eric-engberg removed this pull request from stack #12 October 9, 2026 19:12
@eric-engberg

Copy link
Copy Markdown
Owner Author

Merged upstream as sirmalloc#669.

@eric-engberg
eric-engberg deleted the fix/hotkey-modifiers branch October 9, 2026 22:23
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.

2 participants