Add 思考/反思/推理/推敲 to THINK_TAG_NAMES so the streaming scrubber, CLI and
gateway stream filters and the final-response stripper all hide them, and
derive the auxiliary-client reasoning strip from the same list instead of a
hard-coded copy. Bare bracketless markers (unverified) are not covered.
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
The display stripper kept byte-identical inline copies of
_STRAY_TOOL_CALL_CLOSER_PATTERN and _UNTERMINATED_TOOL_CALL_PATTERN
(same flags), which had to be edited in lockstep (#102303). Import the
compiled patterns lazily instead.
_output_history_lines / _output_history_rows are new in this PR; they belong with the
rest of the history rendering in hermes_cli/cli_render.py, not appended to cli.py.
cli.py imports them for _replay_output_history; the terminal mixin imports them from
cli_render directly.
tmux configured with `default-terminal xterm-256color` gives its panes a
generic TERM plus TMUX; 96f6c69 only trusted TMUX with a tmux*/screen* TERM,
so that common setup fell to "unknown" and a shrink replayed 5-12 lines.
TMUX/STY now decide right after the TERM prefixes (a non-reflowing TERM such
as st or rxvt launched from a tmux shell still wins) and before the inherited
XTERM_VERSION. Real tmux with TERM=xterm-256color, 149-col lines:
midstream/idle/twostep/drag all 0 dup / 0 lost, drag freeze 1.0 s.
- Gate every paint and chrome render on the live width: a paint or render that
would land after the width changed but before its recovery now waits for it
(checked when it actually runs on the loop, not when it was scheduled).
- Recovery that sees the width move again keeps holding; the next recovery runs
right after the next width change once the 0.85s hold cap passed.
- Track the narrowest width of a drag: narrowing and widening back still refills.
- Chrome floor: after a refill the chrome is drawn down to the bottom row (what
CPR would tell prompt_toolkit), so the next count is exact when it shrank.
- Rows output scrolled while the width changed under it are repainted by the
next refill (twice rather than truncated for good).
- A resize whose viewport holds the whole history erases from its oldest row,
keeping the unrecorded startup banner.
- _terminal_reflows: TERM decides before inherited env (st/urxvt from tmux,
kitty or vscode); rxvt added; vte-256color no longer matches 'vt'.
Screen 4.09 re-wraps its window on a width change and pushes the rows that grew into its
own scrollback, like tmux (checked in real screen inside a tmux pane). STY now counts as
a multiplexer next to TMUX, checked before XTERM_VERSION (screen started from an xterm
inherits it), and TERM=screen* over ssh as reflowing.
- Resize no longer erases the viewport and replays it: prompt_toolkit's erase is aimed
at the chrome's top as the terminal re-wrapped it, the transcript left in place.
- Unknown terminals (TERM=xterm-256color over ssh): the rows the aim would take on a
non-reflowing terminal are replayed, so an erase never takes more than it puts back.
st/screen/mosh/vt/cons TERMs count as non-reflowing; LC_TERMINAL=iTerm2 as reflowing.
- Debounce 0.3 s: tmux re-wraps a pane at once but signals it at most every 250 ms.
- No app.invalidate() paints while a resize recovery is pending.
- Ctrl+L/focus refill paints synchronously (tmux copies the screen on ED from home) and
erases every viewport row (CUP rows are 1-based).
- Bare print() in the chat/stream mixins goes through _cprint; held paints are released
at app exit; narrower excepts.
- tmux-backed E2E cell pins all of it.
Live tmux probes showed the painted-width budget from the previous
commit duplicating rows on reflowing terminals: a shrink re-wraps the
rows the replay counted at their old width (Rich pads lines to the full
width, so every reply line grows) and pushes the extra into scrollback.
Terminals that keep rows in place (xterm) need the opposite count, and
the app cannot see which kind it runs in.
- A shrink replays only on a reflowing terminal (_terminal_reflows: TMUX
yes; STY, XTERM_VERSION, TERM=linux no; unknown counts as reflowing),
counting rows at the new width and the old chrome as re-wrapped to it.
Elsewhere the transcript stays as the terminal truncated it and
prompt_toolkit's own erase covers the chrome: nothing is lost.
- Ctrl+L counts rows at their painted width only where rows stay put.
- _cprint output is held from the resize signal until its recovery ran:
a paint in between erased the re-wrapped chrome from prompt_toolkit's
stale cursor, leaving two rows of it in the transcript that the
replay could not account for (two duplicated lines per shrink).
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.
The bounded replay still stacked a copy per resize/Ctrl+L: counting logical
lines against the terminal height ignores soft-wrapped rows and the prompt
chrome, and CSI 2J makes scroll-on-clear terminals (tmux, VTE) copy the
whole visible screen into scrollback before the replay repaints it.
- _clear_prompt_toolkit_screen erases the viewport row by row (EL) when
scrollback is kept and returns the room above the chrome: terminal rows
minus the layout's preferred height, at the current width. The 3J
rebuild path (display.cli_rebuild_scrollback_on_redraw) keeps 2J+3J and
replays the whole history, which is what rebuilds scrollback.
- _replay_output_history(fit) keeps the newest lines whose wrapped height
fits that room (_output_tail_fitting in cli_render).
- the resize path budgets against the full chrome: the status bar/rules
hidden while the reflow settles come back and pushed the top replayed
rows into scrollback.
Tests: the two tests that pinned CSI 2J on the keep-scrollback path now
assert the row erase; replay stubs take the fit argument; the salvaged
visible-height tests collapse into one wrapped-height invariant.
Fixes#95375
Runtime identity resolved through hermes_cli.__version__ (a static 0.0.0
on source installs, rewritten by release stamping) leaked v0.0.0 into
About, /api/health, User-Agents, and plugin compat, and source updates
showed "couldn't reach update server" because identity and channel
authority disagreed with the checkout.
Now: get_version_info() resolves install stamp -> live git -> unknown,
never pyproject metadata, never a package constant. Source checkouts
derive identity from their reachable release tag; the completion tail of
every successful install/update/historical takeover atomically rewrites
install-stamp.json with that identity; a stale source stamp whose commit
no longer matches HEAD defers to live git. ACP/TUI use derived_version
for display and base_version for protocol fields; all ~44 runtime
__version__ consumers migrated; hermes_cli.__version__ and generated
_version.py are gone; release stamping only touches the native manifests
external builders consume (nix/tauri/cargo) and passes release identity
straight into write_install_stamp.py; pyproject.toml stays inert 0.0.0.
Desktop no longer synthesizes a competing install-stamp.json: the
checkout owns its stamp, and desktop-bootstrap classification keys on
the bootstrap-complete marker. verify-bootstrap-version-stamp.py now
cross-checks the checkout's stamp (baseVersion + commit == HEAD).
Validation: 31-file focused suite green (version identity, stamping,
adoption, providers, gateway, acp/tui runtime identity, api server via
extras env, release graph); desktop tsc + 25 vitest green; real-repo
probe: base=unknown derived=git.0635606.dirty source=git on this
checkout; clean-env imports resolve entirely from this tree; windows
footgun + compat-pointer scans clean.
Mechanical, behaviour-neutral extraction along the existing hermes_cli/cli_*.py
pattern. Six clusters of module-level helpers leave cli.py:
- cli_config_load.py prefill messages, reasoning/service-tier parsing, terminal
env mirroring, CLI defaults + YAML merge, logging bootstrap
- cli_render.py reasoning-tag stripping, ANSI/skin colours, light-mode
detection, markdown/final rendering, output-history
recording, _cprint, ChatConsole, compact banner, panel wrap
- cli_terminal_input.py file drops/attachments, bracketed-paste patch, extended
Enter keys, CPR guards, TUI input height, query images
- cli_shutdown.py process session-id sync, exit watchdog, cleanup steps,
session-finalize notifications, one-shot finalize
- cli_single_query.py kanban goal loops, exit-code mapping, quiet -q runner,
image routing, signal handlers, single-query mode
- cli_auto_maintenance.py state-db / checkpoint startup maintenance
Every moved body is AST-identical to the base copy modulo two mechanical
edits: cli-level names are late-bound with a call-time `from cli import ...`
(the cli_init_mixin pattern) and mutable cli state is read through `_cli().NAME`
(the gateway_service_unit `_gw()` pattern), so every monkeypatch seam on the
`cli` facade still intercepts moved code and no read snapshots stale state.
Mutable module state and every `global`-writing function stay in cli.py; the
facade re-exports the moved names in one import block.
test_bracketed_paste_timeout AST-loads the paste helper from its new home.
cli.py 3959 -> 1820 lines.