1717from textual .containers import Horizontal , Vertical , VerticalScroll
1818from textual .css .query import NoMatches
1919from textual .screen import ModalScreen
20- from textual .widgets import Button , Footer , Header , Static
20+ from textual .widgets import Button , Footer , Header , Input , Static
2121
2222
2323_SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
@@ -382,6 +382,18 @@ class CrewRunApp(App[Any]):
382382 height: auto;
383383}
384384
385+ #conversation-input {
386+ display: none;
387+ height: 3;
388+ border-top: hkey #333333;
389+ background: #1c1c1c;
390+ color: #e0e0e0;
391+ }
392+
393+ #conversation-input:focus {
394+ border-top: hkey #1F7982;
395+ }
396+
385397Header {
386398 background: #1c1c1c;
387399 color: #FF5A50;
@@ -483,6 +495,7 @@ def __init__(
483495 total_tasks : int = 0 ,
484496 agent_names : list [str ] | None = None ,
485497 task_names : list [str ] | None = None ,
498+ conversational : bool = False ,
486499 ):
487500 super ().__init__ ()
488501 self .title = f"CrewAI — { crew_name } "
@@ -544,6 +557,13 @@ def __init__(
544557 self ._event_handlers : list [tuple [type , Any ]] = []
545558
546559 self ._crew : Any = None
560+ self ._flow : Any = None
561+ self ._is_conversational = conversational
562+ self ._conversation_messages : list [tuple [str , str ]] = []
563+ self ._conversation_turns = 0
564+ self ._conversation_turn_in_progress = False
565+ self ._conversation_previous_defer_trace_finalization : bool | None = None
566+ self ._conversation_exit_commands = {"exit" , "quit" }
547567 self ._default_inputs : dict [str , Any ] | None = None
548568 self ._crew_result : Any = None
549569 self ._crew_json_path : Any = None
@@ -566,6 +586,10 @@ def compose(self) -> ComposeResult:
566586 yield Static (id = "task-header" )
567587 with VerticalScroll (id = "scroll-area" ):
568588 yield Static (id = "main-content" )
589+ yield Input (
590+ placeholder = "Message the flow..." ,
591+ id = "conversation-input" ,
592+ )
569593 with VerticalScroll (id = "log-panel" ):
570594 yield Static (id = "log-content" )
571595 yield Footer ()
@@ -574,7 +598,9 @@ def on_mount(self) -> None:
574598 self ._start_time = time .time ()
575599 self ._subscribe ()
576600 self ._tick_timer = self .set_interval (1 / 8 , self ._tick )
577- if self ._crew :
601+ if self ._is_conversational and self ._flow :
602+ self ._start_conversational_session ()
603+ elif self ._crew :
578604 self ._run_crew_worker ()
579605 elif self ._crew_json_path :
580606 self ._load_and_run_worker ()
@@ -725,6 +751,140 @@ def _on_crew_failed(self, error: str) -> None:
725751 self ._tick_timer = self .set_interval (1 / 2 , self ._tick )
726752 self ._unsubscribe_if_no_running_memory_save (wait_for_queued = True )
727753
754+ # ── Conversational flow execution ───────────────────────
755+
756+ def _start_conversational_session (self ) -> None :
757+ from crewai .events .listeners .tracing .utils import (
758+ set_suppress_tracing_messages ,
759+ set_tui_mode ,
760+ )
761+
762+ set_tui_mode (True )
763+ set_suppress_tracing_messages (True )
764+ with self ._lock :
765+ self ._status = "chatting"
766+ self ._current_step = None
767+ self ._elapsed_frozen = None
768+ self ._conversation_previous_defer_trace_finalization = getattr (
769+ self ._flow , "defer_trace_finalization" , False
770+ )
771+ self ._flow .defer_trace_finalization = True
772+
773+ try :
774+ input_widget = self .query_one ("#conversation-input" , Input )
775+ input_widget .display = True
776+ input_widget .focus ()
777+ except Exception : # noqa: S110
778+ pass
779+
780+ def _finalize_conversational_session (self ) -> None :
781+ if not (self ._is_conversational and self ._flow ):
782+ return
783+ try :
784+ self ._flow .finalize_session_traces ()
785+ except Exception : # noqa: S110
786+ pass
787+ previous = self ._conversation_previous_defer_trace_finalization
788+ if previous is not None :
789+ try :
790+ self ._flow .defer_trace_finalization = previous
791+ except Exception : # noqa: S110
792+ pass
793+
794+ def on_input_submitted (self , event : Input .Submitted ) -> None :
795+ if event .input .id != "conversation-input" :
796+ return
797+ if not self ._is_conversational :
798+ return
799+
800+ message = event .value .strip ()
801+ event .input .value = ""
802+ if not message :
803+ return
804+ if message .lower () in self ._conversation_exit_commands :
805+ self ._finalize_conversational_session ()
806+ self ._unsubscribe ()
807+ self .exit (self ._crew_result )
808+ return
809+ if self ._conversation_turn_in_progress :
810+ return
811+
812+ with self ._lock :
813+ self ._conversation_messages .append (("user" , message ))
814+ self ._conversation_turn_in_progress = True
815+ self ._conversation_turns += 1
816+ self ._status = "working"
817+ self ._current_step = ("yellow" , "Thinking…" , "" )
818+ self ._is_streaming = False
819+ self ._streaming_text = ""
820+ self ._task_full_output = ""
821+ self ._current_llm_text = ""
822+
823+ event .input .disabled = True
824+ self ._run_conversation_turn_worker (message )
825+
826+ @work (thread = True , exclusive = True , group = "conversation" )
827+ def _run_conversation_turn_worker (self , message : str ) -> None :
828+ from crewai .events .listeners .tracing .utils import (
829+ set_suppress_tracing_messages ,
830+ set_tui_mode ,
831+ )
832+
833+ set_tui_mode (True )
834+ set_suppress_tracing_messages (True )
835+ try :
836+ result = self ._flow .handle_turn (message )
837+ if hasattr (result , "get_full_text" ) and hasattr (result , "result" ):
838+ for _chunk in result :
839+ pass
840+ result = result .result
841+ self .call_from_thread (self ._on_conversation_turn_done , result )
842+ except Exception as e :
843+ self .call_from_thread (self ._on_conversation_turn_failed , str (e ))
844+
845+ def _on_conversation_turn_done (self , result : Any ) -> None :
846+ with self ._lock :
847+ output = self ._stringify_output (result )
848+ self ._conversation_messages .append (("assistant" , output ))
849+ self ._crew_result = result
850+ self ._conversation_turn_in_progress = False
851+ self ._status = "chatting"
852+ self ._is_streaming = False
853+ self ._streaming_text = ""
854+ self ._current_step = None
855+ self ._enable_conversation_input ()
856+ self ._tick ()
857+ self ._scroll_to_result ()
858+
859+ def _on_conversation_turn_failed (self , error : str ) -> None :
860+ with self ._lock :
861+ self ._status = "failed"
862+ self ._error = error
863+ self ._conversation_turn_in_progress = False
864+ self ._is_streaming = False
865+ self ._current_step = None
866+ self ._enable_conversation_input ()
867+ self ._tick ()
868+
869+ def _enable_conversation_input (self ) -> None :
870+ try :
871+ input_widget = self .query_one ("#conversation-input" , Input )
872+ input_widget .disabled = False
873+ input_widget .focus ()
874+ except Exception : # noqa: S110
875+ pass
876+
877+ def _stringify_output (self , result : Any ) -> str :
878+ raw_result = getattr (result , "raw" , result )
879+ if raw_result is None :
880+ return ""
881+ if isinstance (raw_result , str ):
882+ return raw_result
883+ try :
884+ return _json .dumps (raw_result , default = str , ensure_ascii = False )
885+ except TypeError :
886+ return str (raw_result )
887+
728888 # ── Actions ─────────────────────────────────────────────
729889
730890 def action_toggle_sidebar (self ) -> None :
@@ -783,6 +943,7 @@ def action_log_toggle(self) -> None:
783943 self ._refresh_log_panel ()
784944
785945 async def action_quit (self ) -> None :
946+ self ._finalize_conversational_session ()
786947 self ._unsubscribe ()
787948 self .exit (self ._crew_result )
788949
@@ -958,6 +1119,30 @@ def _render_sidebar(self) -> None:
9581119 t = Text ()
9591120 sidebar_width = 30
9601121
1122+ if self ._is_conversational :
1123+ t .append (" CONVERSATION\n " , style = f"bold { _C_PRIMARY } " )
1124+ t .append ("\n " )
1125+ if self ._conversation_turn_in_progress :
1126+ t .append (f" { self ._spinner ()} " , style = _C_PRIMARY )
1127+ t .append ("Working\n " , style = f"bold { _C_TEXT } " )
1128+ elif self ._status == "failed" :
1129+ t .append (" ✘ Failed\n " , style = _C_RED )
1130+ else :
1131+ t .append (" ● Ready\n " , style = _C_GREEN )
1132+ t .append (f" Turns { self ._conversation_turns } \n " , style = _C_DIM )
1133+ t .append ("\n " )
1134+ t .append (" TOKENS\n " , style = f"bold { _C_PRIMARY } " )
1135+ t .append ("\n " )
1136+ out = self ._output_tokens + self ._live_out_tokens
1137+ t .append (f" ↑ { self ._input_tokens :,} \n " , style = _C_DIM )
1138+ t .append (f" ↓ { out :,} \n " , style = _C_DIM )
1139+ t .append ("\n " )
1140+ t .append (" COMMANDS\n " , style = f"bold { _C_PRIMARY } " )
1141+ t .append ("\n " )
1142+ t .append (" quit / exit\n " , style = _C_DIM )
1143+ widget .update (t )
1144+ return
1145+
9611146 t .append (" TASKS\n " , style = f"bold { _C_PRIMARY } " )
9621147 t .append ("\n " )
9631148
@@ -1011,6 +1196,22 @@ def _render_task_header(self) -> None:
10111196 widget = self .query_one ("#task-header" , Static )
10121197 t = Text ()
10131198
1199+ if self ._is_conversational :
1200+ if self ._status == "failed" :
1201+ t .append ("✘ " , style = f"bold { _C_RED } " )
1202+ t .append ("Failed" , style = f"bold { _C_RED } " )
1203+ if self ._error :
1204+ t .append (f"\n { self ._error [:120 ]} " , style = _C_RED )
1205+ elif self ._conversation_turn_in_progress :
1206+ t .append (f"{ self ._spinner ()} " , style = _C_PRIMARY )
1207+ t .append ("Flow is responding" , style = f"bold { _C_PRIMARY } " )
1208+ else :
1209+ t .append ("● " , style = f"bold { _C_GREEN } " )
1210+ t .append ("Conversational flow ready" , style = f"bold { _C_GREEN } " )
1211+ t .append (" Type a message below" , style = _C_DIM )
1212+ widget .update (t )
1213+ return
1214+
10141215 if self ._status == "completed" :
10151216 elapsed = self ._elapsed_frozen or (time .time () - self ._start_time )
10161217 t .append ("✔ " , style = f"bold { _C_GREEN } " )
@@ -1062,6 +1263,41 @@ def _render_main_content(self) -> None:
10621263 t = Text ()
10631264 should_scroll = False
10641265
1266+ if self ._is_conversational :
1267+ if not self ._conversation_messages and not self ._is_streaming :
1268+ t .append (" Start the conversation below.\n " , style = _C_MUTED )
1269+ for role , content in self ._conversation_messages :
1270+ if role == "user" :
1271+ t .append ("\n You\n " , style = f"bold { _C_TEAL } " )
1272+ else :
1273+ t .append ("\n Assistant\n " , style = f"bold { _C_PRIMARY } " )
1274+ rendered = _format_json_in_text (_unescape_text (content ))
1275+ for line in rendered .split ("\n " ):
1276+ style = _C_TEXT if role == "assistant" else _C_DIM
1277+ t .append (f" { line } \n " , style = style )
1278+
1279+ if self ._is_streaming and self ._streaming_text :
1280+ text = _unescape_text (self ._filtered_streaming_text ())
1281+ if text .strip ():
1282+ t .append ("\n Assistant\n " , style = f"bold { _C_PRIMARY } " )
1283+ for line in text .rstrip ().split ("\n " )[- 40 :]:
1284+ t .append (f" { line } \n " , style = _C_TEXT )
1285+ should_scroll = True
1286+
1287+ if self ._status == "failed" and self ._error :
1288+ t .append ("\n Error\n " , style = f"bold { _C_RED } " )
1289+ t .append (f" { self ._error } \n " , style = _C_RED )
1290+
1291+ widget .update (t )
1292+ if should_scroll :
1293+ try :
1294+ self .query_one ("#scroll-area" , VerticalScroll ).scroll_end (
1295+ animate = False
1296+ )
1297+ except Exception : # noqa: S110
1298+ pass
1299+ return
1300+
10651301 # Plan section
10661302 if self ._plan and self ._plan .get ("steps" ):
10671303 plan_title = self ._plan .get ("plan" , "Plan" )
0 commit comments