The viewport-sized replay (#95375) still duplicated rows when the width
changed while a reply streamed: the review's e2e cell showed the warmup
reply twice after two widens.
- A widen leaves the transcript alone. It wraps nothing into extra rows,
so prompt_toolkit's own stale-cursor erase still covers the chrome, and
no replay size is right for both terminals that keep rows in place
(xterm) and reflowing ones. The 3J rebuild opt-in still rebuilds.
- Output is recorded in _OUTPUT_HISTORY when it is painted, not when a
worker requests it: a replay no longer prints lines still queued for
the app loop (they printed again right after).
- Each recorded line carries the terminal width it was painted at, and
the replay budget counts it at that width: a line painted after the
terminal shrank but before the debounced handler ran wraps into more
rows than the old width says, which pushed the budget into scrollback.
- The room above the chrome is the renderer's last paint (it can be
taller than the preferred height), not a re-fit of the future chrome.
- A line taller than the room keeps its bottom rows (_ansi_drop_cells)
instead of vanishing.
- The two blank separator lines of every turn go through _cprint, so
the replay budget sees them (print() through patch_stdout bypassed it).
Tests: widen skips the replay; worker prints enter the history when
painted, tagged with the width; the tail fit counts painted widths and
keeps a tall line's bottom; the width-change test asserts the fit.
Mechanical, behaviour-neutral extraction along the existing cli_*_mixin.py
pattern: the ten _init_* constructor phases become CLIInitMixin
(hermes_cli/cli_init_mixin.py) and the fourteen _tui_* run-loop phases
(input dispatch, after-turn, startup banner/prewarm/maintenance, application
build, signal handlers, shutdown) become CLITuiRuntimeMixin
(hermes_cli/cli_tui_runtime_mixin.py). __init__ and run() stay in cli.py as
the orchestrators. Every moved body is AST-identical to the base copy; cli.py
module names are resolved lazily through cli so monkeypatch seams survive.
cli.py 4835 -> 3960 lines.