cli_model_switch_mixin: the identical snapshot->stage->agent.switch_model->rollback
block in _apply_model_switch_result and _confirm_and_apply_cli_model_switch (2x47
lines) -> _stage_and_swap_model(result, old_model) -> bool. cli_loops_mixin: the
identical reversed-history assistant-text extraction in the loop-tick and goal
post-turn hooks -> _last_assistant_response_text(). Four _StubCLI test doubles bind
the new helper to the real implementation.
The command path (/model <name> --provider <p>) called
_confirm_expensive_model_switch() inline. That modal blocks its calling
thread on a response queue (see _prompt_text_input_modal); on the
prompt_toolkit main thread the TUI event loop freezes, the modal never
renders, and the switch silently cancels after the 120s timeout — the
user sees a frozen terminal and 'Model switch cancelled.' without ever
seeing the warning. The picker path already dispatched confirm+apply on
a worker thread; the command path now mirrors that contract.
Extract the inline confirm+apply block into
_confirm_and_apply_cli_model_switch() (preserving --once restore and
persist semantics) and dispatch it on a daemon thread when a TUI app is
present, keeping the synchronous path for non-interactive/test use.
Tests: new test_model_switch_confirm_thread.py pins (a) confirm runs off
the main thread when _app is present, and (b) the no-app path stays
synchronous. Existing _StubCLI helpers forward to the extracted method.