Skip to content

feat(thinking-effort): optional brackets and per-level colors - #2

Closed
eric-engberg wants to merge 105 commits into
developfrom
feat/thinking-effort-format
Closed

eric-engberg wants to merge 105 commits into
developfrom
feat/thinking-effort-format

Conversation

@eric-engberg

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

Copy link
Copy Markdown
Owner

Stacked on #1 (feat/widget-picker-preview), so the diff shows only this feature. The level-colors editor uses getPlainInput() from #5 so its shortcuts ignore ctrl/alt combos; otherwise it's independent of the PRs below. Before the upstream PR it gets rebased onto main (after #5, or with an inline guard if #5 isn't in).

Part of stack sirmalloc#12: develop ← #5 hotkey fix ← #4 dev reload ← #1 picker preview ← #2 thinking effort ← #9 256-color grid ← #11 bar widget refactor ← #10 bar gradients.

Demo

Model is merged with Thinking Effort, so the preview reads Claudehigh (the TUI's sample data). Pressing b adds brackets, giving Claude(high). Pressing l opens the level colors editor: Space turns level colors on, and moving through the rows shows each level's color in the sample. Enter saves, and the preview colors the level.

Powerline

Thinking Effort brackets and level colors in Powerline mode

Plain

Thinking Effort brackets and level colors in plain mode

Fix: the label's color with level colors on

With level colors on, the Thinking: label kept the widget's own color whatever the line's colors were: magenta on the theme's teal under Powerline with the nord theme, and only the first stop of a gradient item color. It now takes the theme's text color, or the gradient sweeps across it, and the level keeps its level color. Piped renders at high, xhigh and max; the payloads are sample data.

Powerline (nord theme, 256 colors)

Before:

Thinking Effort under the nord theme before the fix: the label in magenta on teal

After:

Thinking Effort under the nord theme after the fix: the label in the theme's text color

Plain (item color gradient:atlas)

Before:

Thinking Effort with a gradient item color before the fix: the label in the gradient's first stop

After:

Thinking Effort with a gradient item color after the fix: the gradient sweeps the label

Update

Status line rendered from a fake payload at the 256 Color level, with Thinking Effort's level colors set to the hex colors #f5d020, #ff8800 and #e8343c. Under each line is the escape code the real output uses for the level. Before the fix these are 24-bit codes (ESC[38;2;255;136;0m), which 256-color terminals can't show. After it they are the nearest 256-palette codes (ESC[38;5;220m, 208, 167). VHS's terminal supports truecolor, so the rendered colors only shift by that rounding, and max's red shifts the most.

Powerline, before and after:

Before, Powerline

After, Powerline

Plain, before and after:

Before, Plain

After, Plain

Summary

Showing the effort next to the model, e.g. Opus 5.5(xhigh) with the level color-coded, used to take four merged widgets (Model, (, Thinking Effort, )) and still couldn't color by level.

Thinking Effort gains two options. Both are off by default, so existing setups render exactly as before.

  • (b)rackets cycles off → () → [] → {} → <> and wraps whatever the widget renders: raw gives (xhigh), labeled gives (Thinking: xhigh).
  • (l)evel colors opens an editor that colors the value by level:
Thinking Effort: level colors
↑↓ select, ←→ change color, Space on/off, (x) custom color, (d)efaults, Enter save, ESC cancel

Sample: (high)

Level colors: On

   low        Bright Black
   medium     Green
▶  high       Yellow
   xhigh      Orange
   max        Red
   brackets   Match effort
  • The sample shows the highlighted level. On the brackets row it keeps showing the last level highlighted.
  • ←→ cycles through the named colors. (x) accepts #RRGGBB or 0-255; custom colors show as #RRGGBB or ANSI n, as in Edit Colors. (d) restores the defaults.
  • Brackets either match the level color, or use the widget's own color from Edit Colors.

Color responsibilities

  • Edit Colors keeps the fixed colors: the Thinking: label, unknown levels like super-max?, widget-colored brackets, background, and bold/dim. It now shows a one-line note when the selected widget sets some of its own colors.
  • The level-colors editor sets only the colors that change with the level.
  • When level colors are on, the widget paints only the level (and brackets that match it), and the renderer colors the rest the way it colors any widget: the Powerline theme's foreground, the widget color, or a gradient swept across the uncolored text. A new optional Widget.colorsOnlyItsRuns() marks such widgets; the renderer returns to its color after each run the widget colored. Claude Status and Custom Command, which own all of their foreground, are unchanged.
  • Color level 0 outputs plain text, and a global foreground override still strips the colors, as the renderer already does for these widgets.
  • The default xhigh orange (ansi256:208) falls back to bright magenta on 16-color terminals. Colors you pick yourself are left as-is, the same as the color menu.
  • The widget color follows the renderer's rules: unset means the widget default (magenta), and '' ("Default" in the color menu) means no color.

Implementation

  • widgets/shared/effort-style.ts: pure helpers for bracket cycling, level color get/set/reset, custom color parsing, and formatThinkingEffort(). Options are stored in widget metadata, and an option at its default stores nothing.
  • widgets/shared/effort-colors-editor.tsx: the editor screen.
  • ThinkingEffort.ts: keybinds, editor display ((brackets (), level colors)), and rendering through the formatter.
  • jsonl-metadata.ts: exports the existing KNOWN_THINKING_EFFORTS list so the levels have a single source.
  • ColorMenu.tsx: the note, shown for any listed widget that sets some of its own colors.
  • types/Widget.ts, utils/renderer.ts, utils/colors.ts, utils/gradient.ts: colorsOnlyItsRuns(), and coloring around the runs in the Powerline and plain paths (restoreForegroundAfterRuns, and a gradient that skips the runs).
  • The level-colors editor samples at the configured color level, so at 16 colors it shows the bright magenta xhigh the status line draws, and at No Color nothing.

Update: Map hex colors to the 256-color palette at the 256 Color level

  • At the 256 Color level, a hex: color now renders as the nearest 256-palette color (ESC[38;5;Nm / ESC[48;5;Nm). Before, it rendered as a 24-bit ESC[38;2;r;g;bm / ESC[48;2;r;g;bm code, while named, ansi256: and gradient colors already came out as palette codes. 256 Color is the default level, and Thinking Effort's level colors accept #RRGGBB at any level, so a hex color the user typed reached 256-color terminals (Terminal.app before macOS 26, for example) as an escape they can't draw. A separator that keeps its hex color after a switch from truecolor down to 256 goes through the same code, so it is fixed too.
  • The nearest color is chosen by squared RGB distance from palette entries 16-255: the 6x6x6 color cube at xterm's levels (0, 95, 135, 175, 215, 255) and the 24 grays. The 16 basic colors are left out because terminals theme them, so their real values are unknown. Hex colors don't use the gradient's rgbToAnsi256, which treats the cube levels as evenly spaced and can land well off. For #ff8800 it gives 214 (#ffaf00), where the search gives 208 (#ff8700, 1 unit away). For #5f5f5f, which is palette entry 59, it gives the gray 240.
  • Truecolor still gets the exact hex code. Basic and No Color still emit nothing for hex colors. Gradients are unchanged.

Testing

  • New unit tests for the style helpers, widget rendering (brackets with raw on and off, each color mode, unknown levels, color level 0, preview, keybinds, editor display) and the editor (sample follows the cursor, color cycling both ways, bracket mode, on/off, custom color, invalid input, defaults, ESC discards), plus a ColorMenu test for the note.
  • bun test: 2429 pass. bun run lint: clean.
  • Build: the entry chunk is unchanged (20,552 bytes) and the shared chunk grew by about 3 KB.
  • Manual run in tmux:
    • add via the picker, set raw and brackets, turn level colors on and save: the preview shows (high) in yellow
    • the saved config round-trips
    • the Edit Colors note appears

Update:

  • bun test: 3283 pass, 0 fail (9375 expect() calls across 184 files).
  • Four new tests in colors-gradient.test.ts cover hex at 256 Color: the nearest-entry mapping for foreground and background (#FF8800 -> 208, #4e9a06 -> 64, #808080 -> 244), exact palette entries (#5f5f5f -> 59, #FF0000 -> 196), no truecolor escape through applyColors, and truecolor unchanged. The first three fail on the old code.
  • The same test file under Node (node node_modules/vitest/vitest.mjs run src/utils/__tests__/colors-gradient.test.ts): 18 of 18 pass.
  • bun run lint (type check and ESLint, no warnings allowed) is clean.
  • The nearest-color search was checked against a separate nearest-palette implementation over 636,056 colors (every third value per channel), with 0 mismatches.
  • Piped renders at 256 Color under a scratch HOME, in plain and Powerline mode: hex level colors #f5d020, #ff8800 and #e8343c come out as ESC[38;5;220m, ESC[38;5;208m and ESC[38;5;167m (before: ESC[38;2;…m). A separator colored hex:FF8800 comes out as ESC[38;5;208m.

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.
Picking up a code change meant quitting the TUI and redoing any unsaved
setup and navigation by hand.

With CCSTATUSLINE_DEV_RELOAD=1 the interactive TUI runs under a small
supervisor process. ctrl+r writes a snapshot (unsaved and original
settings, current screen, selected line, menu positions, line editor
cursor) and exits with code 75; the supervisor starts a fresh process on
the latest code, which resumes from the snapshot. Screens that carry
their own flow state (confirm dialogs, install/update, import/export)
resume on the main menu. If the new code fails to start, the supervisor
keeps the snapshot and offers Enter to retry once it's fixed, or q to
quit.

Without the variable nothing changes: no supervisor, no key, and the
module is only loaded through a dynamic import in the interactive
branch, so the status line render path is unaffected.
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.
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.
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 status
line preview off the top, which made the live preview useless there.

Picker lists now show only the entries around the selection, sized to
the terminal height and the number of status lines, with "↑ N more" /
"↓ N more" markers. Entry numbers stay absolute so position in the full
list is still visible.
The dev reload cursor test checked the first frame and the cursor callback
after fixed 25 ms delays, which a busy machine can outrun. It now retries the
assertions until they pass (up to 3 s) with a waitFor helper. Once they pass,
waitFor 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.
The widget picker tests typed into the picker 25 ms after the key that opened
it. On a busy machine the picker might not be drawn and listening yet, so the
typed text reached the line editor instead, where several letters are
shortcuts. Each step now waits for its visible result before the next key:
the picker's title, the preview, onUpdate, or the redrawn list window.
Showing the effort next to the model, e.g. "Opus 5.5(xhigh)" with the
level color-coded, took four merged widgets and still couldn't color by
level.

Thinking Effort gains two options, both off by default so existing
setups render exactly as before:

- (b)rackets cycles off, (), [], {}, <> and wraps the widget's output
- (l)evel colors opens an editor that colors the value by level
  (low gray, medium green, high yellow, xhigh orange, max red by
  default), with brackets matching the level or the widget color,
  named/hex/ansi256 colors, and a sample that follows the cursor

The label, unknown levels and widget-colored brackets keep the color
from Edit Colors, which now notes that the widget sets some colors
itself. With level colors on the widget preserves its own foreground,
like Claude Status; color level 0 and global foreground overrides still
win. The default orange falls back to bright magenta on 16-color
terminals.
ctrl+d reset the level colors and alt+x opened the custom color prompt,
because the editor compared the typed letter without checking modifiers.
On macOS alt+arrow arrives as ESC + letter, so this also fired while
navigating. The editor now uses getPlainInput() like the rest of the TUI.
The default xhigh orange showed as its raw value, ansi256:208. It now shows
as Orange. Other custom colors are labeled the way Edit Colors labels them,
ANSI n and #RRGGBB, instead of the stored ansi256:n and hex:rrggbb.
…ests

The editor tests sent each key 25 ms after the previous one. On a busy
machine a key could arrive before the previous one was drawn, acting on the
old state. Every key in these tests redraws the editor or ends it, so each
now waits for that (up to 3 s) and fails loudly if it never comes. Keys that
should do nothing (ctrl and alt combos) get 150 ms to act instead.
@eric-engberg
eric-engberg force-pushed the feat/thinking-effort-format branch from 2e88bfc to d67d08f Compare October 4, 2026 06:03
@eric-engberg
eric-engberg force-pushed the feat/widget-picker-preview branch from efa1fb0 to ee86dc7 Compare October 4, 2026 06:03
@sonarqubecloud

sonarqubecloud Bot commented Oct 4, 2026

Copy link
Copy Markdown

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.
@eric-engberg
eric-engberg marked this pull request as ready for review October 31, 2026 00:28
sirmalloc and others added 22 commits October 31, 2026 08:07
…sirmalloc#801)

The final output swapped every space for U+00A0, and those terminals only let a
Private Use Area icon use two cells when the next cell is U+0020 or U+2002. A
space right after such an icon, with or without color codes or link closers in
between, now becomes an en space, which is not trimmed either.

Fixes sirmalloc#796

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Zed registers a zed://file handler that opens files and directories as a
workspace, so it belongs with the IDE link modes that Current Working Dir
and Git Root Dir already offer.

Adding 'zed' to IDE_LINK_MODES extends both widgets at once: the CWD link
cycle becomes file -> VS Code -> Cursor -> Zed -> off, and Git Root Dir
becomes VS Code -> Cursor -> Zed -> off. Legacy linkToCursor metadata
counts as the cursor mode, so its first toggle now lands on zed instead of
clearing the link.

Tests cover zed:// URLs for POSIX, Windows drive-letter, and UNC paths,
both widgets' rendering and editor modifiers, and the TUI link cycle. Docs
list Zed in the cycle descriptions.
…alloc#795)

Bumps the dev-dependencies group with 3 updates: [@remotion/cli](https://github.com/remotion-dev/remotion), [remotion](https://github.com/remotion-dev/remotion) and [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint).


Updates `@remotion/cli` from 4.0.538 to 4.0.539
- [Release notes](https://github.com/remotion-dev/remotion/releases)
- [Commits](remotion-dev/remotion@v4.0.538...v4.0.539)

Updates `remotion` from 4.0.538 to 4.0.539
- [Release notes](https://github.com/remotion-dev/remotion/releases)
- [Commits](remotion-dev/remotion@v4.0.538...v4.0.539)

Updates `typescript-eslint` from 8.71.2 to 8.72.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.72.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: "@remotion/cli"
  dependency-version: 4.0.539
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: remotion
  dependency-version: 4.0.539
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.72.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* feat(context-bar): add a thin line display mode

Block glyphs (█░, ▓░) read heavy in light themes and plain-text layouts.
The new 'line' mode draws used context as ━ and the rest as ─, at the
same 16-cell width as the medium bar. It joins the end of the p cycle,
so existing configurations are unchanged.

* feat(widgets): extend line mode to all progress bars

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
…alloc#792)

With no reset time from the status line payload or the usage API, Block
Reset Timer falls back to the 5-hour block found in the transcripts. In
timestamp mode it then formatted the missing sessionResetAt, got
nothing, and showed the remaining duration ("Reset: 3hr 45m") instead of
the reset time the user chose to see.

The block's end is known, so timestamp mode now formats that instant
when no reset time was reported.

Co-authored-by: effectustasi <216187407+effectustasi@users.noreply.github.com>
…malloc#790)

* test(git): cover grapheme clusters in branch abbreviations

* test(git): cover grapheme clusters in worktree abbreviations

* fix(git): preserve complete graphemes in fish-style branch names

* fix(git): preserve complete graphemes in fish-style worktree names
…irmalloc#787)

* fix(tui): mark Full width always as the default terminal width

The Terminal Width menu still marked "Full width minus 40" as
"(default)" after the settings default for flexMode moved to "full".
On a fresh install, or with any settings.json that has no flexMode,
the menu showed "Full width always (active)" with "Full width minus 40
(default)" below it. Picking the "(default)" entry to get back to the
default saved full-minus-40 instead.

"Full width always" now carries "(default)" while another mode is
active, following the rule the old marker used, and "Full width minus
40" shows only "(active)". A test checks the marker against
DEFAULT_SETTINGS, so a later change to the default can't leave the
menu behind again.

* fix(tui): say Full width always reserves 6 columns, not 4

The Terminal Width menu's description of "Full width always" says it
uses the full terminal width minus 4 characters. The renderer has
reserved 6 columns for that mode, in the preview and in the piped
status line, since the reserve changed from 4 to 6, and the menu text
wasn't updated with it. At 100 columns a long line truncates, and a
flex separator fills, to 94 columns rather than the 96 the menu
promises.

The description now says 6, matching the renderer. The renderer is
unchanged. The test renders a long line in that mode at 100 columns
and checks that the description names the number of columns the
renderer left free, so a later change to the reserve can't leave the
menu behind again.

* fix(tui): put the space back in the delete-line dialog's heading

Pressing d in Edit Lines opened a confirmation headed "☰ Line2 (2
widgets)", while the list and move mode both show "☰ Line 2". In the
dialog's JSX, "☰ Line" and the line number sit on separate lines, and
JSX drops whitespace that contains a line break, so nothing separated
them. The {' '} that used to sit between them was lost when the menus
moved to the shared List component.

The heading puts {' '} between the two again, as move mode does. The
existing delete test accepted the heading with or without the space;
it now expects "☰ Line 2 (empty)" and "☰ Line 1 (1 widget)", and waits
for the dialog before checking the second, since the list row behind
it has the same text.
…sts (sirmalloc#788)

The line editor and color menu tests slept a fixed 25ms after rendering
and after each key, then asserted. Ink redraws, and React re-attaches
Ink's input listener, on their own schedule. On a loaded machine the
assertion ran before the redraw, or the next key reached the listener
left from before the last redraw and was applied to the old state.
Under load these tests failed intermittently, the number style and
label tests most often.

Each wait now retries until the key's visible effect shows (the row's
new suffix, the label editor's draft, the picker's search text, the
moved highlight), then yields two setImmediate turns before the next
key, through the shared helper in src/tui/__tests__/helpers/wait-for-ink.ts.
Assertions that only restated a wait move into it; the rest are
unchanged.
…t left glyphs entered by hex (sirmalloc#786)

* fix(colors): clear separator colors a new Color Level can't show

Changing Color Level resets each widget's ansi256 or hex color that the
new level can't show to the widget's default, and clears such
backgrounds. A separator's foreground was skipped, because separators
have no widget default to reset to, so it kept its color. A separator
given a hex color at Truecolor still had it after cycling on to 256
Color, and the 256-color line printed a 24-bit escape for it
(\x1b[38;2;0;255;0m) next to the widgets' 38;5 codes.

The reset warning counts separator colors as well, so with only a
colored separator it promised a reset, and confirming it changed
nothing.

Clear a separator foreground the new level can't show, as backgrounds
already are. Without a color, a separator renders in the separator
default, or takes the previous widget's colors when separators inherit
colors.

* fix(powerline): invert a left-facing separator entered by hex code

Picking Triangle Left or Round Left with the arrow keys turns on the
separator's background inversion, which a left-facing glyph needs to
draw the next segment's color on the previous segment's background.
Entering the same glyph with (h), as E0B2 or E0B6, kept the slot's old
inversion instead. On the default separator that is off, so the arrow
was drawn reversed (\x1b[31m\x1b[44m between a red and a blue segment,
instead of \x1b[34m\x1b[41m), and the list showed no [Inverted] to hint
that (t) was needed. Typing E0B0 over an inverted Triangle Left
likewise left the right triangle inverted.

When (h) enters one of the preset glyphs, set its inversion as the
arrow keys do: on for the left-facing ones, off for the right-facing
ones. A custom code point keeps the slot's inversion, since its
direction is unknown, and (t) still toggles it.
…or (sirmalloc#785)

Ink 6.2.0's insertBeforeNode returns early when it inserts a node before
an existing sibling, so it never marks the parent text dirty the way
appendChildNode (and its own append branch) do. Yoga keeps the text's
old measured size, and Ink wraps the line at the old width onto rows
the layout never reserved, where the next row draws over it. It stays
that way until something re-measures the text, such as the next key.

The Custom Command and Link editors render the value as three sibling
texts: the part before the cursor, the cursor cell and the rest. With
the cursor at the start, as in an empty field, the part before it
renders nothing, so the next input inserts a new text ahead of the
cursor cell. Pasting "npx ccusage statusline" into an empty Custom
Command field showed only "npx ccusage", though Enter saved the whole
command. A URL or link text pasted into an empty Link field was cut the
same way, with its cut-off end showing at the end of the next row. A
key typed at the start of a command that was already the editor's
widest row hid the command's last word.

Add a hunk to the ink patch that marks the node dirty after both
branches, as Ink does upstream from 6.8.0.
… at the configured Color Level (sirmalloc#784)

* fix(tui): keep the main menu's highlight when a save changes its entries

Pressing Ctrl+S on the main menu after making changes moved the
highlight back to the entry that was last opened. Saving swaps Save &
Exit and Exit without saving for a single Exit, and List reset its
highlight to initialSelection whenever the number of items changed. The
main menu's initialSelection is the entry last opened, not the one
highlighted, so the cursor left the entry the user had moved to and the
next Enter opened a different screen.

List gets a keepHighlightOnItemsChange prop, and the main menu sets it.
With it, a new initialSelection still moves the highlight, but when just
the number of items changes, the highlight stays on the item it was on;
if that item is gone, it stays on the same row, inside the list. On the
main menu, Install stays Install after a save, and Save & Exit or Exit
without saving become the Exit that replaces them.

Other lists still move the highlight to initialSelection when their
item count changes. Line selection and Powerline Setup keep their
initialSelection on the highlight and set it to place the highlight:
appending a line highlights the new line, and turning Powerline on
highlights Separator.

* fix(tui): build the preview's color codes for the configured color level

In a terminal that chalk detects at fewer colors than the configured
color level, such as tmux with TERM=screen or ssh with TERM=xterm, the
TUI preview and color menus drew different colors from the real status
line. At the default 256 colors, cyan, magenta and bright black widgets
were drawn black; a truecolor setting previewed 256-color
approximations.

chalk works out a 256-color or hex style's escape code when the style
is created, at the level set at that moment. COLOR_MAP creates its
styles when colors.ts loads, so its codes follow the level chalk
detected for the terminal. The piped render sets chalk.level and then
calls updateColorMap() to rebuild them; the TUI set chalk.level on
load, on import and in Terminal Options, but never rebuilt the map.

The TUI now calls updateColorMap() each time it sets chalk.level, as
the piped render does. The test sandbox rebuilds the map when it puts
chalk's level back, so later test files get the codes they expect.

* docs(tui): say the main menu's initialSelection is the highlighted row

The main menu now records its highlighted row as it moves, so
initialSelection is that row's index rather than the entry last opened.
Keeping the highlight on its entry across a save is still needed: the
save swaps Save & Exit and Exit without saving for one Exit, which moves
the entries below, so the index alone would land on another entry. The
comments on MainMenu and List's keepHighlightOnItemsChange say so.

* test(tui): walk the main menu to an entry rather than through a fixed list

The main menu highlight tests pressed down through a hard-coded list of
entries, so an entry another change adds or makes selectable (Configure
Status Line opening before an install, say) broke them. They now press
down until the target entry is highlighted, waiting for the highlight to
move on each press.

* test(app): open Color Level directly, now Terminal Options' first row

With sirmalloc#765 Terminal Options lists only Color Level, so the steps that went
through Terminal Width land on Color Level when the menu opens.
… temp files (sirmalloc#783)

* fix(terminal): stop leaking temp files when the width cache write fails

writeCachedWidth wrote a temp file named after the process id and renamed
it over terminal-width.json inside a catch that swallowed every error.
When the rename failed (EPERM on Windows while a virus scanner or sync
client holds a handle on the file), the temp file stayed behind. Every
render is a new process with a new pid, so each failed write left one
more terminal-width.json.<pid>.tmp in ~/.cache/ccstatusline, and nothing
ever removed them. On Windows this write is routine: probing is disabled
there, so the no-width entry is rewritten whenever it passes the TTL, and
on every render while the rename keeps failing, since a failed write
leaves the old entry stale.

Handle it the way the git cache does since sirmalloc#564: remove the temp file
when the write or rename fails, and use one stable name
(terminal-width.json.tmp), so a cleanup blocked by the same handle leaves
at most one file, which the next write truncates. Writers use it under
the cache lock; one that proceeds unlocked can at worst leave malformed
JSON, which readCache already treats as a miss.

* fix(custom-command): stop leaking temp files when the cache write fails

writePersistentCache wrote a temp file named after the process id and
the current time, and renamed it over the cache file inside a catch that
swallowed every error. When the rename failed (EPERM on Windows while a
virus scanner or sync client holds a handle on the file), the temp file
stayed behind. With customCommandCacheTtlSeconds above 0, nothing reaches
the cache while the rename keeps failing, so every repaint misses, runs
the command again and leaves one more cmd-<hash>.json.<pid>.<ms>.tmp in
~/.cache/ccstatusline/custom-command-cache. Nothing ever removed them.

Handle it the way the git cache does since sirmalloc#564: remove the temp file
when the write or rename fails, and use one stable name
(cmd-<hash>.json.tmp), so a cleanup blocked by the same handle leaves at
most one file per cache file, which the next write truncates. The cost is
the one the git cache accepted: two sessions writing the same directory's
cache at the same moment can tear the shared temp file, and
readPersistentCache treats the malformed JSON as a miss.
…o picker fixes (sirmalloc#782)

* fix(usage): print es-ES's 12-hour day period as AM/PM, not "A M"

With the 12-hour clock in date mode, the Block and Weekly Reset Timers
turn the locale's day period into AM/PM by dropping its dots and
uppercasing it. That works for "am", "pm", "a.m." and "p.m.", but ICU
writes es-ES's as "a. m." and "p. m.", with a space after the first dot,
so the timers showed "10:30 A M CEST" in the status line and the preview.
Locales typed in by hand such as ca, bs and es-AR write it the same way.

Drop each dot together with the space after it, so "a. m." becomes "AM"
like the other forms, while a day period without dots keeps its spaces.

* fix(usage): keep reset dates Gregorian whatever the locale's calendar

In date mode, the Block and Weekly Reset Timers ask Intl for the year,
month and day in the chosen locale and fill them into a YYYY-MM-DD
layout, without saying which calendar to use. ar-SA, which the locale
picker offers, defaults to Gregorian in Node's ICU data but to the
Islamic (Umm al-Qura) calendar in Bun's, where the month comes back as a
name even though two digits were asked for. The same settings then
showed "٢٠٢٦-١٠-١٣ ١٦:٠٥" under npx and "١٤٤٨-جمادى الأولى-٠٢ ١٦:٠٥"
under bunx: a Hijri year with its era dropped and a month name inside
the numeric layout. th-TH (Buddhist year 2569) and fa-IR (Persian year
1405) typed in by hand gave non-Gregorian dates under both runtimes.

Ask for the Gregorian calendar, so the parts always fit the layout and
both runtimes print the same date. The locale keeps its own digits, as
it already does in its zone name and time.

* fix(usage): format reset timestamps in the chosen locale at UTC too

The Block and Weekly Reset Timers offer a locale in date mode whatever
the timezone, and the line editor shows it ("locale: de-DE"). But with
the timezone at its default, UTC, the formatter returned its fixed
English format before it looked at the locale, so the weekday names and
day period stayed English: "Sun 08:30 UTC" for de-DE and "8:30 AM UTC"
for ja-JP, while Etc/UTC, the same zone under another name, gave
"So 08:30 UTC" and "8:30 午前 UTC".

When a locale is set, format UTC through Intl in that locale, as every
other zone already is. The output keeps the UTC format's shape: every
locale names the zone "UTC", and compact mode still ends in "Z". Without
a locale the fixed format is unchanged, and en-US gives the same output
as no locale.

* fix(tui): let 'uk' and 'ca' pick en-GB and en-CA in the locale picker

The reset timers' locale picker searches each listed locale by a few
keywords, and en-GB's include 'uk' and en-CA's 'ca'. It also offers a
search that is a valid locale tag not in the list as a "Use <tag>"
custom row, placed first. 'uk' and 'ca' are valid tags too, for
Ukrainian and Catalan, so typing either put "Use uk" or "Use ca" above
en-GB or en-CA, and since the picker selects the first row after each
keystroke, Enter saved Ukrainian or Catalan formatting.

Put a locale whose keywords include the search ahead of the custom row.
The custom row still comes next, so a Ukrainian or Catalan tag is one
Down away, and any other tag the list doesn't offer, such as en-AU,
still comes first.

* fix(tui): open the timezone picker on a configured zone the runtime doesn't list

A reset timer's timezone picker should open on the configured zone, so
Enter keeps it. A valid zone that Intl doesn't list, such as Asia/Kolkata
(both Node and Bun list it as Asia/Calcutta) or Etc/GMT-5 picked under
Bun and reopened under Node, gets its own "Configured timezone" row
right after Local. The picker found the starting row's index in the
options as given, but shows them sorted, and the sort puts that row
after Africa/Abidjan. So the picker opened on Africa/Abidjan, and Enter
without moving saved it, switching the reset time to GMT.

Find the starting row in the list as the empty search shows it.

* fix(tui): keep 'uk' and 'ca' on en-GB and en-CA when either is the system or configured locale

The locale picker lists the system locale and the configured locale in
their own rows above the common ones, and drops the common row for the
same locale. Those rows carried only "system ..." or "configured ..."
search words, so when en-GB or en-CA was the system or configured
locale, the 'uk' and 'ca' keywords went with the dropped row. Typing
either put "Use uk" or "Use ca" first again and Enter saved Ukrainian or
Catalan; for 'uk', en-GB was not even listed. That is the case for a UK
or Canadian user under Node, which takes its default locale from LANG,
and for anyone reopening the picker after choosing en-GB or en-CA.

Give the System and Configured rows the keywords of the common locale
they replace, so they match and rank the same way it would.

The keyword tests now pin the runtime's locale: they passed under Bun,
which reports en-US whatever LANG says, and failed under Node with
LANG=en_GB.UTF-8 or en_CA.UTF-8.

* fix(usage): keep the UTC label on locale-formatted UTC reset timestamps

With a locale set and the timezone left at its default, UTC, the full
date-mode stamp now goes through Intl in that locale and takes the zone
name Intl gives. For most locales that is "UTC", but a few spell it
differently: el-GR writes "2026-03-15 08:30 Συντονισμένη Παγκόσμια Ώρα",
and sd, ku, sc and so give "م ع و", "SGH", "TCU" and "Waqtiga UTC", the
same under Bun and Node. Without a locale these stamps end in "UTC".

At UTC, the full form now ends in the fixed "UTC" label, as compact mode
already ends in a fixed "Z". The weekday, day period and digits still
follow the locale. An explicitly chosen IANA zone such as Etc/UTC keeps
Intl's zone name, as any other zone does.

* test(usage): cover the fixed UTC format when Intl rejects the locale

With a locale set and the timezone at UTC, a reset timestamp goes
through Intl in that locale, and keeps the fixed format when Intl can't
use the locale. Only the localized outcome had tests.

A POSIX-style tag such as "de_DE" is not a valid language tag, and Intl
throws on it under both Bun and Node. The stamp then reads
"Sun 08:30 UTC" in the full form and "Sun 08:30Z" in compact mode,
where de-DE gives "So 08:30 UTC" and "So 08:30Z".

* refactor(usage): format UTC reset timestamps in their own helper

formatUsageResetAt parsed the timestamp, split its overloaded
locale-or-hour12 argument, and then held both zone paths inline: the
UTC one, which tries the chosen locale, adds the compact "Z" and falls
back to the fixed format, and the IANA or local one with its en-US
fallback.

The UTC path moves into formatResetAtUtcInLocale, next to
formatResetAtUtc and formatResetAtInTimezone, the two formatters it
chooses between. formatUsageResetAt now reads as parse, normalize, then
format for the zone, and its UTC branch is a single call. The output is
the same for every timezone, locale and option.
…bs as one space (sirmalloc#781)

* fix(ansi): hide charset escapes and DCS/APC strings whole

With Preserve Colors off, Custom Command reduces its output to visible
text with getVisibleText before the renderer's sanitizer sees it. The
escape parser behind it read ESC and any character other than [ or ]
as a complete two-character sequence. That is right for ESC 7 or ESC c,
but not for ESC ( B, a character-set selection with an intermediate
byte, which `tput sgr0` prints before ESC [ m on xterm-256color. Its
final "B" was left as text, so a script printing
"$(tput setaf 2)OK$(tput sgr0) done" showed "OKB done". DCS, SOS, PM
and APC strings (ESC P ... ESC \) lost only their first two characters,
so their payload printed as text. By the time the sanitizer ran, these
were ordinary characters it had no reason to remove.

Read these forms the way terminal-sanitize.ts already does: ESC,
intermediate bytes (0x20-0x2F) and a final byte as one sequence, and a
DCS, SOS, PM or APC string up to ST or BEL, stopping before any other
ESC so an unterminated string doesn't hide the text after it. Visible
width goes through the same parser, so with Preserve Colors on these
sequences no longer count toward Max Width either.

* fix(ansi): recognize color codes written with colons

SGR parameters can be separated with colons, the ITU T.416 form:
ESC[38:5:196m, ESC[38:2::255:0:0m, or ESC[4:3m for a curly underline,
which `tput Smulx 3` prints on xterm-256color. The terminal sanitizer
keeps these, but the color pattern in ansi.ts only allowed digits and
semicolons, so everything downstream treated them as unknown:

- a Preserve Colors Custom Command cut by Max Width never got its
  closing reset, so its color or underline ran into the separators and
  widgets after it
- a global foreground override left the command's own colon-form color
  in place, where the semicolon form is recolored
- in Powerline mode, a colon-form reset such as ESC[0;38:5:196m didn't
  get the segment background re-applied after it

Allow colons in the pattern. When reading a sequence for a background
reset, an extended color written with colons holds its arguments in its
own parameter, so the following parameters are no longer skipped as if
they were its arguments: ESC[48:5:1;0m ends with the background cleared,
and ESC[0;38:2::0:0:0;41m keeps its red background.

* fix(render): print tabs as one space and measure them as one column

Widget output kept its tabs: the terminal sanitizer let them through,
and width measurement gave them no columns, as string-width does for
control characters. A terminal moves a tab on to the next tab stop, up
to 8 columns, so a line holding one printed wider than it was measured.
A Custom Command such as `git rev-list --left-right --count HEAD...@{u}`,
which prints "0<tab>3", beside a flex separator filled the line to its
width by count and then ran past it on screen, where it wraps or gets
cut off. Max Width and the line's own truncation let the extra columns
through the same way.

How far a tab moves depends on the column it lands in. That isn't known
while a widget is measured, before the line is laid out, and it changes
with where the host draws the line, so measuring tabs at their real
width can't be done reliably. Instead, the sanitizer prints each tab as
one space, and width measurement counts a tab as one column, so text
measured before it is sanitized, as Custom Command's Max Width is,
agrees with what is printed. A single space reads as the field
separator a tab usually is in one-line output, without spending up to
8 columns of the status line on it.

* test(ansi): cover an escape whose final byte never comes

An escape with intermediate bytes (ESC ( B) was only tested with its final
byte present. Pin down what happens when it is missing: at the end of the
text the escape is hidden up to where it stops, and a character that cannot
end it, here an emoji, stays visible after it.

* refactor(ansi): read escape bytes as code points and name the final-byte range

The scan for ESC, intermediate bytes and a final byte compared raw UTF-16
units and spelled the final-byte range inline in the slice call. It now reads
code points, as the grapheme-cluster scan in this file does, and tests the final
byte with isEscapeFinalByte next to isIntermediateByte, so the return reads as
"one past the final byte if there is one".

The parse is the same for every input: both ranges are ASCII, and code points
only differ from code units at a surrogate pair (a value of 0x10000 or more
instead of 0xD800 to 0xDBFF) and past the end of the text (undefined instead
of NaN), which neither range accepts either way.
…fter a sub-screen (sirmalloc#780)

* fix(tui): keep Merge highlighted after cancelling the shell-command confirmation

When an import adds a Custom Command, choosing Replace All or Merge
first asks "Apply this import? It adds a shell command...". Cancel says
"Go back to the preview without importing", but coming back from Merge
put the highlight on Replace All and switched the preview to the
Replace diff. The confirmation doesn't name the mode, so pressing Enter
again and confirming applied Replace All, which resets every setting
the file leaves out to its default.

The confirmation replaces the preview's List, and the List mounted
again at its first row, then reported that row as the new selection.
Mount it on the mode the preview was showing instead.

* fix(tui): let Escape leave Import Preview and its shell-command confirmation

Escape did nothing on Import Preview, on both the diff and the "Apply
this import? It adds a shell command..." confirmation. The only way out
was to arrow down to Cancel and press Enter. The Import Config path
dialog one step earlier, and the other menus, go back on Escape, and
docs/USAGE.md already says Escape returns from the confirmation to the
preview.

The dialog had no input handler of its own, and its List only handles
the arrows and Enter. Escape now steps back one screen: from the
confirmation to the preview, where the mode stays highlighted, and from
the preview out of the import, as Cancel does.

* fix(tui): keep Color Level highlighted after the custom-colors warning

In Terminal Options you press Enter on Color Level to cycle through the
levels. With an ansi256 or hex widget color, leaving 256 Color or
Truecolor first warns "Custom colors detected!". After Yes, No or
Escape the highlight was back on Terminal Width, so the next Enter
opened the Terminal Width screen instead of cycling on.

The warning replaces the List, and the List mounted again at its first
row. Remember the highlighted row, as Powerline Setup does around its
confirmations, and mount the List on it.

* fix(tui): keep the cancelled setting highlighted in Configure Status Line

Pressing Escape on a Refresh Interval or cache TTL prompt brought the
list back with the highlight on the first row, Refresh Interval (or Git
Cache TTL when Refresh Interval needs a newer Claude Code). Pressing
Enter again opened a different setting from the one just left. Saving
returns to the Main Menu, so only cancelling showed it.

Each prompt replaces the List, and the List mounted again at its first
row. Remember the highlighted row and mount the List on it.

* fix(tui): keep the chosen update style highlighted when going back in Install

In Install to Claude Code, going back from the package manager step
(Escape or "← Back") highlighted Pinned global install again, with its
description, even after choosing Auto-update. Pressing Enter then led
into the pinned global install commands instead of the npx/bunx ones.

The style List mounts again on the way back, and it always started on
its first row. Start it on the style still held from the first pick.

* test(import-preview): mark the command import with colorLevel instead of flexMode

flexMode is going away with the Terminal Width options; any non-default key
the import carries serves this fixture.

* test(terminal-options): start the warning tests on Color Level, now the first row

With sirmalloc#765 Terminal Options lists only Color Level, so the two tests start
there instead of stepping down from Terminal Width.

* fix(tui): drop the Terminal Options row change, which sirmalloc#765 made moot

With sirmalloc#765, Color Level is Terminal Options' only setting and the
custom-colors warning opens only from it, so the menu comes back on that
row without help.
…ttings as v1 (sirmalloc#779)

* fix(config): don't migrate a version-less settings.json with later settings as v1

A settings.json, or an Import Config file, with no "version" key was always
read as a v1 config, since v1 predates the field. loadSettings validated it
with the v1 schema, which ignores keys it doesn't know, and ran the v1->v2
migration, which rebuilds the config from `lines` and the nine v1 fields
only. Every later setting (powerline, defaultPaddingSide, minimalistMode,
gitCacheTtlSeconds, ...) went back to its default, the result was written
over the file with no copy kept, and the v2->v3 step added a "ccstatusline
updated to v2.0.2" message that then showed for 12 renders. Import ran the
same migration and took its merge keys from the migrated result, so Merge
applied nothing from such a file and Replace applied defaults, while the
TUI reported a successful import.

needsMigration now reads a version-less config as v1 only when it holds
none of the settings added after v1 (the keys SettingsSchema has beyond
SettingsSchema_v1). One that does is validated as the current version: if
it is valid it loads as written, and the file isn't rewritten just to add a
version; if it is invalid it takes the existing invalid-file path (defaults
in memory, the warning badge and TUI banner, file untouched). Genuine v1
files hold only v1 fields, so they still migrate.

Before a migration is written back, the original is now copied beside the
settings file as <file>.v<N>.bak, N being the version it was migrated
from, with the original's permissions. If that copy can't be written, the
migration isn't persisted: the file stays as it was and the load reports
the error, like any other file it can't use.

Import validation shares the check and takes its merge keys from the
file's own keys rather than the migrated result's, so Merge applies every
setting the file holds.

* fix(config): keep current settings a v1 import's migration drops

A merge import applies only the keys the file holds. For a version-less
v1 file, those keys were taken from the raw file, but the v1 migration
keeps only fields of the right type and drops a lines value that isn't
a list. The schema then filled the dropped keys with defaults, and the
merge wrote those defaults over the current values: a v1 file with
"flexMode": null reset Flex Mode to full, and one with a non-list lines
replaced the current lines with the default ones.

The merge now takes the raw keys that are still in the migrated result,
so a dropped value leaves the current setting alone.

* fix(config): back up settings.json byte for byte before a migration

The .v<N>.bak copy was written from the text decoded as UTF-8. Bytes
that aren't UTF-8, such as a Latin-1 é an editor saved in a custom
text, decode to U+FFFD and were written back as EF BF BD, so the backup
was not the original file. It is now written from the bytes read.

* fix(config): use a migration in memory when its backup can't be written

A migration is written back only after the original is copied to
settings.json.v<N>.bak. When that copy failed, for example because the
config folder is read-only, the load fell back to the built-in defaults
and reported the file as invalid: the status line rendered the defaults
behind the invalid-config badge, and the TUI showed the defaults and
offered to overwrite the file with them, although the file was valid and
only needed upgrading.

Now the migrated settings are used for that run without being written,
and every load tries the backup again, so the file is upgraded as soon
as the copy can be made. The status line renders the user's lines, with
no badge and nothing on stderr. The update message a migration adds is
held back until then, since counting it down would rewrite the file.

The TUI shows a notice instead of the invalid-config warning. A save
from it writes the backup first; if that still fails, the save fails
with the existing "Could not save configuration" message and the file
is left as it was. Recording installation metadata skips its write in
that case, as it does for an unreadable file.

* fix(config): don't replace a newer backup when saving over a held migration

When a load can't back up settings.json before migrating it, the
migrated settings are used from memory and the next save writes the
backup first. That save backed up the bytes the load had read, which in
the TUI can be minutes old. If the file was edited in the meantime, and
a status line render then backed up the edited file and migrated it,
the save deleted that backup, wrote the older bytes in its place and
wrote the TUI's settings over the file, so the edit was left nowhere.

The save now reads the file again before backing it up. If it still
needs migrating, the bytes it holds now are backed up, under their own
version. If it is already at the current version, another run has
migrated it and kept its own backup, and that backup is left as it is.

* test(config): mark kept settings with colorLevel instead of flexMode

flexMode and compactThreshold are going away with the Terminal Width
options, so these tests tell the user's settings from the defaults by a
non-default colorLevel (and defaultPadding for the second invalid v1 field).

* fix(config): preserve legacy hiding in versionless settings

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
…c#778)

(a) in Edit Colors opens the whole 256-color palette as a grid (the 16
basic colors, the 6x6x6 cube and the 24 grays) instead of asking for a
number. The arrow keys move the cursor and the preview follows it, typing a
number jumps to that color, Enter keeps it and ESC restores the previous
one. Picks are still saved as ansi256:N, and (a) is now offered at
truecolor too.
…irmalloc#791)

With a tag named like the current branch, `git symbolic-ref --short HEAD`
prints "heads/<branch>". The refresh child keys the PR cache by the full
ref with "refs/heads/" stripped since sirmalloc#774, but the render's lookup
through the cached runGit (sirmalloc#639) still used --short. The two then hash
to different cache files: the child writes the PR to one, Git PR and Git
CI Status read the other, and the widgets show "(no PR)" on every render
while a refresh runs again each time its lock goes stale. The child also
never releases the lock it was handed, since that lock belongs to the
other cache file.

The render's lookup now reads `symbolic-ref HEAD` and strips the prefix
the same way, which is also the command Git Branch already runs, so the
cached runGit answers both.

Co-authored-by: effectustasi <216187407+effectustasi@users.noreply.github.com>
…sirmalloc#793)

Enabling Powerline replaced a theme of 'custom' with the default theme,
the same as a first enable with no theme set. 'custom' can only be set
from the Themes menu (picking Custom, or customizing a built-in theme
into widget colors), which is disabled while Powerline is off. So
turning Powerline off and on again switched a customized status line
to nord-aurora, and the widget colors stopped showing until Custom was
picked again.

Only a missing theme now falls back to the default.

Co-authored-by: effectustasi <216187407+effectustasi@users.noreply.github.com>
…olors test

The line and color editor tests no longer sleep a fixed 25ms after each
render and key. They wait for the key's visible effect, and the sleep
helper flushInk is gone. The color menu test that checks the note for a
widget that sets some of its own colors still called flushInk after the
first render, so it no longer compiled. It now waits until the frame
shows the Thinking Effort row, then reads that frame as before.
@eric-engberg
eric-engberg removed this pull request from stack #149 November 5, 2026 23:30
@sonarqubecloud

sonarqubecloud Bot commented Nov 5, 2026

Copy link
Copy Markdown

@eric-engberg
eric-engberg added this pull request to stack #154 November 6, 2026 01:02
@eric-engberg
eric-engberg removed this pull request from stack #154 November 6, 2026 17:31
@eric-engberg

Copy link
Copy Markdown
Owner Author

Merged upstream as sirmalloc#777.

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.