Skip to content

fix(tui): keep the Main Menu highlight on Ctrl+S and draw the preview at the configured Color Level - #784

Merged
sirmalloc merged 8 commits into
sirmalloc:mainfrom
eric-engberg:fix/tui-cursor-and-preview-colors
Nov 5, 2026
Merged

sirmalloc merged 8 commits into
sirmalloc:mainfrom
eric-engberg:fix/tui-cursor-and-preview-colors

Conversation

@eric-engberg

Copy link
Copy Markdown
Contributor

What

Two TUI fixes:

  1. Ctrl+S on the Main Menu keeps the highlight where it is. Before, saving moved the highlight back to the entry last opened, so the next Enter opened a different screen from the one highlighted. When the highlighted entry goes away with the save (Save & Exit, Exit without saving), the highlight moves to the Exit that replaces them.
  2. The preview and Edit Colors draw the colors the status line prints, even when the terminal reports fewer colors than the configured Color Level. Before, at the default 256 Color in a terminal seen as 16-color (such as TERM=screen in tmux or GNU screen, or TERM=xterm over ssh, with no COLORTERM), cyan, magenta and bright black widgets were drawn black. A Truecolor setting in a terminal seen as 256-color was previewed in 256-color approximations.

Why

  1. List reset its highlight to initialSelection whenever its number of selectable items changed:

    useEffect(() => {
        const maxIndex = Math.max(selectableItems.length - 1, 0);
        setSelectedIndex(Math.min(initialSelection, maxIndex));
    }, [initialSelection, selectableItems.length]);

    Before Claude Code is installed, the Main Menu has 11 selectable entries with unsaved changes (Save & Exit, Exit without saving) and 10 after a save (Exit). App passes initialSelection={menuSelections.main}, and that value is set only when an entry is opened or left, not when the highlight moves. So Ctrl+S re-ran the effect, and the highlight jumped back to the entry last opened.

    On main: change Color Level in Terminal Options, Escape, Down to Install to Claude Code, Ctrl+S. Right after ✓ Configuration saved appears, the highlight moves to ▶ 💻 Terminal Options, and Enter opens Terminal Options.

  2. chalk works out the escape code of an ansi256() or hex() style when the style is created, at the level chalk has at that moment. COLOR_MAP in colors.ts creates its styles when the module loads, so their codes follow the level chalk detected for the terminal. The piped render sets chalk.level from the settings 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 preview and Edit Colors read their codes from that map.

    Settings {"version":4,"colorLevel":2,"lines":[[{"id":"1","type":"model"}]]}, run in a pty with TERM=screen and neither COLORTERM nor TERM_PROGRAM set (cat -v):

    TUI preview on main TUI preview on this branch Piped render
    Bun 1.4.2 ^[[30mModel: Claude (black) ^[[38;5;30mModel: Claude ^[[38;5;30mModel: Claude
    Node v26.10.0 ^[[30mModel: Claude ^[[38;5;30mModel: Claude ^[[38;5;30mModel: Claude

    With "colorLevel":3 and TERM=xterm-256color, the preview on main drew ^[[38;5;37m under both runtimes. The piped render and this branch's preview print ^[[38;2;6;152;154m.

How

  1. List gets a keepHighlightOnItemsChange prop, and the Main Menu sets it. List keeps track of the initialSelection and items it last fitted the highlight to. A new initialSelection still moves the highlight there. With the prop set, when only the number of items changes, the highlight stays on the item it was on, matched by value. If that item is gone, it keeps the same position among the selectable items, or moves to the last one if the list is now shorter. selectableItems is now memoized, so the effect can depend on the list itself. On the Main Menu this also covers the startup install check: if it finishes after the highlight has moved, Configure Status Line becoming selectable no longer sends the highlight back to the first entry.

    Other lists still move the highlight to initialSelection when their item count changes, as on main. Line selection and Powerline Setup rely on that. They feed the highlight back into initialSelection, so with Back highlighted, (a) adding a line highlights the new line, and (t) turning Powerline on highlights Separator. Keeping the highlight on the same item would leave it on Back in both.

  2. The TUI calls updateColorMap() each time it sets chalk.level: when settings load, after an import (applyTuiImport), and in both Color Level paths of Terminal Options (with and without the custom-colors warning). The piped render already does this. The App test sandbox rebuilds the map when it puts chalk's level back, so later test files get the codes they expect. Its renderApp() helper gets a getRawFrame() that keeps the escape codes.

New tests in App.test.ts:

  • Ctrl+S with Install to Claude Code highlighted keeps it, and Enter opens the install screen. Three more cases start from Save & Exit, Exit without saving, and the GitHub star entry, and check where the highlight lands and the entry above it.
  • With the color map built at 16 colors and settings at 256 Color, the preview draws cyan as 38;5;30. Picking Truecolor in Terminal Options redraws it as 38;2;6;152;154, with and without the custom-colors warning. applyTuiImport with an imported 256 Color level rebuilds the map.

New tests for the lists that keep moving the highlight:

  • LineSelector.test.ts: (a) with Back highlighted highlights the new Line 3, and Enter opens it.
  • PowerlineSetup.test.ts: (t) turning Powerline on without a confirm highlights Separator, and Enter opens the separator editor.

Left as is:

  • With the save guard for a settings.json that failed to load, Ctrl+S opens a confirm screen first. Coming back from it mounts the Main Menu again, so the highlight returns to the entry last opened. That comes from leaving the menu, not from its item count changing.
  • COLOR_MAP still takes its codes from chalk's global level, so any new code that sets chalk.level has to call updateColorMap() too. Building each color tier from its own fixed-level chalk instance would remove that need, but it changes colors.ts for the piped render as well, so it is left for a separate change.

Demo

Powerline

Ctrl+S on the Main Menu. Color Level has an unsaved change, and Terminal Options was the entry last opened. Down to Install to Claude Code, Ctrl+S, then Enter. Before, the save moves the highlight back to Terminal Options and Enter opens it. After, Install stays highlighted and Enter opens the install screen.

Before:

Ctrl+S on the Main Menu in Powerline mode before the fix

After:

Ctrl+S on the Main Menu in Powerline mode after the fix

The preview in a terminal that reports 16 colors (TERM=screen, COLORTERM and TERM_PROGRAM unset), with Color Level at 256 Color. The segments have cyan, bright black, magenta and yellow backgrounds. The TUI opens, is closed with Ctrl+C, and the example payload is then piped, so the real status line prints under the preview. Before, the first three segments are drawn in the terminal's black (dark gray in this theme) and yellow is the terminal's own yellow. After, the preview matches the piped line.

Before:

Preview colors in a 16-color terminal in Powerline mode before the fix

After:

Preview colors in a 16-color terminal in Powerline mode after the fix

Plain

Ctrl+S on the Main Menu, as above.

Before:

Ctrl+S on the Main Menu in plain mode before the fix

After:

Ctrl+S on the Main Menu in plain mode after the fix

The preview in a 16-color terminal, as above, with cyan, bright black, magenta and yellow text. Before, Model, Context Length and Git Branch are drawn in the terminal's black. After, the preview matches the piped line.

Before:

Preview colors in a 16-color terminal in plain mode before the fix

After:

Preview colors in a 16-color terminal in plain mode after the fix

Testing

  • On main as of fix: use env override and settings for usable context ratio calculation #407 (71e8b42): merges cleanly, bun run lint is clean, and bun test gives 3948 pass, 0 fail. The built CLI under Bun 1.4.4 and Node 26.13.0 matches main in plain and Powerline mode with the baseline configs, and the TUI opens and exits cleanly. It adds no JavaScript or Node API newer than Node 14, the build's target.
  • bun run lint is clean.
  • bun test: 3178 pass, 0 fail across 178 files, against 3168 pass, 0 fail on main. 10 new tests: 8 in App.test.ts, 1 in LineSelector.test.ts, 1 in PowerlineSetup.test.ts.
  • The 8 App.test.ts tests fail on main, under Bun and under Node. The LineSelector and PowerlineSetup tests pass on main, where those lists already behave this way. Both fail when every list keeps its highlight on the same item, under Bun and under Node.
  • App.test.ts, TerminalOptionsMenu.test.ts, LineSelector.test.ts and PowerlineSetup.test.ts under Node (v26.10.0, Vitest): 65 of 65 pass, against 55 of 55 on main.
  • Built dist/ under Bun 1.4.2 and Node v26.10.0: the baseline plain and Powerline renders agree across runtimes and match main. The TUI opens and exits cleanly under both.
  • The TUI in a pty with TERM=screen: the table in "Why".

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.
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.
# Conflicts:
#	src/tui/App.tsx
#	src/tui/__tests__/App.test.ts
#	src/tui/__tests__/helpers/render-app.ts
#	src/tui/components/MainMenu.tsx
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.
… 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.
# Conflicts:
#	src/tui/__tests__/App.test.ts
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.
@axisrow

axisrow commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

Reproduced on current main (66e9a08):

Red: 5 failing / 68 total. Both halves reproduce:

  • Ctrl+S moves the highlight to the last-opened entry: expected to contain ▶ 🚪 Exit, received ▶ ⭐ Like ccstatusline? Star us on GitHub
  • the preview draws at the terminal's detected level instead of the configured one: Expected: "[38;5;30m", Received: "[30m" (same for the full preview frame)

Green: with the full PR head checked out, 68 passing / 0 failing.

One note: the PR body says 8 App tests fail on main; on this run 4 App tests (+1 import test) failed — the remaining highlight cases (Install / Save & Exit / GitHub star) passed, likely timing-sensitive. Both bug halves reproduce either way.

bun test on macOS.

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.

3 participants