Skip to content

perf(painter): resend only the menu rows that changed - #1296

Open
bhouse-nexthop wants to merge 1 commit into
nushell:mainfrom
bhouse-nexthop:painter-keep-unchanged-menu-rows
Open

bhouse-nexthop wants to merge 1 commit into
nushell:mainfrom
bhouse-nexthop:painter-keep-unchanged-menu-rows

Conversation

@bhouse-nexthop

@bhouse-nexthop bhouse-nexthop commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Every repaint erased from the prompt down and printed the whole completion menu again, even when nothing in it had changed. On a 9600 baud serial console that is the dominant cost: a 64-entry ColumnarMenu is about 3.6 KB with colors, so each arrow key under an open menu takes several seconds to draw.

The painter now remembers the menu rows it drew. While the menu opens on the same row of an unscrolled screen of the same size, a repaint erases only the rows above the menu and resends only the menu rows that differ. Each remembered row carries the SGR state in effect where it starts, so a style that spans rows (a DescriptionMenu description) comes out the same on a redrawn row.

The menu is drawn whole whenever row-for-row reuse can't be proven to match a full paint:

  • the screen scrolled, was resized, or the anchor is stale
  • any row wraps, or holds a bare line feed (a ListMenu multi-line description)
  • text after the cursor reaches the menu
  • the menu is taller than the screen
  • TERM=dumb
  • resending the changed rows would cost more bytes than a full redraw

Anything that writes past the painter forgets the remembered rows: external messages, resizes, clears, $EDITOR, and the exit path.

There is no public API change.

Before / After

Bytes sent per event: a 64-entry ColumnarMenu, 80x24, DefaultCompleter, partial completions off, measured through the engine.

Event ANSI on, before ANSI on, after ANSI off, before ANSI off, after
Open menu 3672 3672 1375 1375
MenuRight 3672 349 1375 144
MenuDown 3672 586 1375 233
Repaint, menu unchanged 3672 87 1375 55
Type a character 3673 3673 1376 1376

Typing still redraws the menu whole. The engine marks the anchor stale whenever a host completer runs (#1130), and a stale anchor drops the remembered rows. #1297 lets an in-process completer opt out of that. Tab with partial completions on goes through the same path.

Additional notes

The tests compare screens rendered by a vt100 emulator, cell by cell with attributes and cursor, against a full paint of the same frame. They cover ANSI on and off; right and multi-line prompts; status lines; menus that grow, shrink, wrap or end on the last row; wide characters; tabs; bare line feeds; SGR spanning rows, including resets inside parameter lists and zero-valued color arguments; and the DescriptionMenu and ListMenu paths through the engine.

Two engine tests (columnar_menu_below_the_status_line_ignores_the_hidden_hint, status_line_stays_above_a_columnar_menu_taller_than_the_screen) fail on main when the checkout path is long, because DefaultPrompt prints the cwd and wraps. They pass from a short path, with or without this change.

Every repaint erased from the prompt down and printed the whole menu
again, even when the menu had not changed. Over a slow link that is the
dominant cost: a 64-entry completion grid is about 1.2 KB, so on a 9600
baud serial console each arrow key under an open menu took over a second
to draw.

The painter now remembers the menu rows it drew. While the menu opens on
the same row of an unscrolled screen of the same size, a repaint erases
only the rows above the menu and redraws only the menu rows that differ.
Moving the selection with the arrow keys, or with Tab when partial
completions are off, redraws the one or two rows whose highlight moved,
and a repaint whose menu is unchanged sends only the prompt and buffer.
The menu is drawn whole when any of that fails, and when most rows
changed, since each changed row pays for its own cursor move and erase.

Each remembered row carries the style in effect where it starts, since a
description menu opens a color on one row and resets it rows later; a
redrawn row starts from that style and ends reset.

Every path that writes past the painter, or moves the anchor, forgets the
remembered rows: external messages, resizes, a stale anchor, clears and
the exit path. A row wider than the screen wraps and moves the rows below
it, and a bare line feed (a list menu's multi-line description) starts
the next row mid-screen, so such a menu is always drawn whole, as is one
the text after the cursor runs into. A menu taller than the screen and a
dumb terminal are always drawn whole.

Typing under a menu fed by a host completer, and Tab with partial
completions on, still redraw it whole: the engine marks the anchor stale
whenever such a completer runs, and a stale anchor drops the remembered
rows.

Signed-off-by: Brad House <bhouse@nexthop.ai>
@bhouse-nexthop
bhouse-nexthop force-pushed the painter-keep-unchanged-menu-rows branch 2 times, most recently from 3c07781 to d9c1848 Compare November 12, 2026 05:27
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.

1 participant