@@ -354,6 +354,8 @@ def _render_frame(self) -> Any: ...
354354
355355 def _flush (self ) -> None : ...
356356
357+ def _on_log (self , msg : str ) -> None : ...
358+
357359 def _input_prompt (self ) -> FormattedText :
358360 """Styled input prompt: short model name + caret.
359361
@@ -396,18 +398,52 @@ def _read_multiline(self) -> str | None:
396398 return text
397399
398400 def _ask_question_blocking (self ) -> None :
401+ """Answer the pending question on the main thread.
402+
403+ Containment boundary for the whole method: the question text is
404+ model-controlled, and a render/resolve crash here would kill the
405+ TUI from the main thread — outside every tool-containment
406+ try/except in the worker (the worker only blocks on
407+ ``q.event.wait`` in ``_ask_sync``). On any unexpected exception
408+ the question is answered with an error string (the blocked
409+ worker unblocks and the failure reaches the model as a tool
410+ result instead of wedging it forever), and the message goes to
411+ the status bar.
412+ """
399413 q = self .question
400414 if q is None :
401415 return
416+ try :
417+ answer = self ._render_and_ask (q )
418+ except (EOFError , KeyboardInterrupt ):
419+ raise
420+ except Exception as e : # noqa: BLE001 - containment boundary
421+ self .question = None
422+ q .answer = f"Error: question render failed — { e } "
423+ q .event .set ()
424+ self ._data_event .set ()
425+ self ._on_log (f"error: question render failed — { e } " )
426+ return
427+ q .answer = answer
428+ q .event .set ()
429+ self .question = None
430+ self ._data_event .set () # re-render promptly after the answer
431+
432+ def _render_and_ask (self , q : UiQuestion ) -> str :
433+ """Render the question UI and read one answer (may raise)."""
402434 self .console .print (self ._render_frame ())
403435 self .console .print ()
404436 self ._flush ()
437+ # prompt/options come from the model: coerce to str so a
438+ # non-string "question" field can never raise rich's
439+ # TypeError from Text.append / concatenation on the main thread
440+ prompt_text = str (q .prompt )
405441 options = q .options or []
406442 keys = q .keys or []
407443 if keys and options and len (keys ) == len (options ):
408444 # keyed choices (e.g. y/n confirm): type the key to pick —
409445 # same list look as the Question tool, keys instead of numbers
410- self .console .print (Text (q . prompt ))
446+ self .console .print (Text (prompt_text ))
411447 for key , opt in zip (keys , options , strict = True ):
412448 line = Text (f" { key } ) " , style = "cyan" )
413449 line .append (opt )
@@ -419,7 +455,7 @@ def _ask_question_blocking(self) -> None:
419455 prompt = "> "
420456 elif options :
421457 # option labels get a numbered list: type the number to pick
422- self .console .print (Text (q . prompt ))
458+ self .console .print (Text (prompt_text ))
423459 for i , opt in enumerate (options , 1 ):
424460 line = Text (f" { i } ) " , style = "cyan" )
425461 line .append (opt )
@@ -430,19 +466,15 @@ def _ask_question_blocking(self) -> None:
430466 self .console .print (f"[dim]{ hint } [/dim]" )
431467 prompt = "> "
432468 else :
433- prompt = q . prompt + " > "
469+ prompt = prompt_text + " > "
434470 try :
435471 with _safe_patch_stdout ():
436472 answer = self .prompt_session .prompt (prompt , multiline = False )
437473 except (EOFError , KeyboardInterrupt ):
438474 answer = ""
439475 if keys :
440- q .answer = _resolve_keyed_choice (answer , options , keys )
441- else :
442- q .answer = _resolve_numbered_choice (answer , options )
443- q .event .set ()
444- self .question = None
445- self ._data_event .set () # re-render promptly after the answer
476+ return _resolve_keyed_choice (answer , options , keys )
477+ return _resolve_numbered_choice (answer , options )
446478
447479 def _ask_sync (self , q : UiQuestion ) -> str :
448480 """Block the worker thread until the main thread answers.
0 commit comments