Skip to content

Commit 1862ff8

Browse files
authored
Support conversational flows in the CLI TUI (#6293)
* Add conversational flow TUI support * properly support tui
1 parent f2a074e commit 1862ff8

6 files changed

Lines changed: 501 additions & 2 deletions

File tree

lib/cli/src/crewai_cli/crew_run_tui.py

Lines changed: 238 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from textual.containers import Horizontal, Vertical, VerticalScroll
1818
from textual.css.query import NoMatches
1919
from 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+
385397
Header {
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

Comments
 (0)