crewui 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
crewui/__init__.py ADDED
@@ -0,0 +1,42 @@
1
+ """crewui - a Textual TUI for any sequential CrewAI crew.
2
+
3
+ ``CrewAIPipelineTUI`` is a Textual App that renders a sidebar task tracker, an
4
+ agent output log, and a pipeline log for a CrewAI ``Crew``. The host builds the
5
+ crew and hands it in; the TUI stays out of crew construction entirely.
6
+
7
+ The package depends only on ``crewai`` and ``textual``. Live metrics come
8
+ straight from CrewAI (token usage off ``kickoff()``'s result); everything
9
+ host-specific is delegated to injected callbacks, so nothing here reaches up
10
+ into the host application:
11
+
12
+ - ``on_start()`` - fired in the worker thread right before kickoff (e.g. to
13
+ bind a run id and stamp the start time)
14
+ - ``on_complete(result)`` - fired right after kickoff (e.g. to persist run
15
+ metrics)
16
+ - ``get_token_cost(input_tokens, output_tokens)`` - returns the USD estimate to
17
+ display; cost is not a CrewAI metric, so the host supplies it
18
+
19
+ Human review (``Task(human_input=True)``) is routed to the TUI's input box
20
+ instead of a blocking terminal ``input()`` - see ``crewui.app``.
21
+
22
+ Typical usage::
23
+
24
+ from crewui import CrewAIPipelineTUI
25
+
26
+ CrewAIPipelineTUI(
27
+ crew=build_my_crew(),
28
+ record_prefix="myapp",
29
+ pipeline_name="My Pipeline",
30
+ ).run()
31
+
32
+ The class owns a default theme; a derived class ships its own look by setting
33
+ its own ``CSS_PATH``.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from crewui.app import CrewAIPipelineTUI
39
+
40
+ __version__ = "0.1.0"
41
+
42
+ __all__ = ["CrewAIPipelineTUI", "__version__"]
crewui/__main__.py ADDED
@@ -0,0 +1,15 @@
1
+ """``python -m crewui`` entry point.
2
+
3
+ Kept to two lines and a guard so it mirrors the console script exactly: both
4
+ routes go through ``crewui.cli:main``, so a wheel cannot ship one working and
5
+ the other broken.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+
12
+ from crewui.cli import main
13
+
14
+ if __name__ == "__main__":
15
+ sys.exit(main())
crewui/_helpers.py ADDED
@@ -0,0 +1,78 @@
1
+ """crewui._helpers - Pure functions extracted from CrewAIPipelineTUI.
2
+
3
+ The TUI class itself (CrewAIPipelineTUI) is App + widgets + threading and needs
4
+ a textual.pilot harness to test properly. The pure-logic helpers it relies on -
5
+ truncation, log routing, step-message formatting, sidebar layout, metrics
6
+ block formatting - have no Textual or threading dependency and live here so
7
+ they can be branch-covered by ordinary unit tests.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ from crewai.agents.parser import AgentAction, AgentFinish
15
+
16
+
17
+ def truncate(text: str, limit: int) -> str:
18
+ """Return ``text`` truncated to ``limit`` characters.
19
+
20
+ Returns the input unchanged when it is already at or below the limit.
21
+ """
22
+ return text[:limit] if len(text) > limit else text
23
+
24
+
25
+ def route_log_record(record_name: str, prefix: str) -> str:
26
+ """Decide which TUI log pane a logging record belongs in.
27
+
28
+ Returns ``"agent"`` when ``record_name`` starts with ``prefix`` (the host
29
+ app's record prefix), else ``"crew"``.
30
+ """
31
+ return "agent" if record_name.startswith(prefix) else "crew"
32
+
33
+
34
+ def task_layout(tasks: list[Any]) -> list[tuple[str, str]]:
35
+ """Build the sidebar entries for a sequential pipeline.
36
+
37
+ For each task that has an assigned agent, return a ``(heading, role)``
38
+ pair in pipeline order. ``heading`` is the task's display name
39
+ (``Task.name``), falling back to the agent role when a task carries no
40
+ name; ``role`` is the agent role shown on the task's status row. Because
41
+ the heading is per-task rather than per-agent, an agent that runs more than
42
+ one task in the pipeline gets a distinct heading for each.
43
+
44
+ Tasks with no agent are skipped, so a partially-wired crew never raises.
45
+ """
46
+ layout: list[tuple[str, str]] = []
47
+ for task in tasks:
48
+ agent = getattr(task, "agent", None)
49
+ if agent is None:
50
+ continue
51
+ role = agent.role
52
+ layout.append((task.name or role, role))
53
+ return layout
54
+
55
+
56
+ def format_metrics_block(total_tokens: int, estimated_cost_usd: float, status: str = "done") -> str:
57
+ """Render the fixed-width metrics summary shown in the sidebar."""
58
+ return f" Tokens: {total_tokens:,}\n Cost: ${estimated_cost_usd:.4f}\n Status: {status}"
59
+
60
+
61
+ def format_step_message(step: object) -> str:
62
+ """Format a CrewAI step (AgentAction / AgentFinish / other) as rich-text.
63
+
64
+ AgentAction yields a Thought + tool-call line and an optional result block.
65
+ AgentFinish yields an Answer line. Anything else is rendered as its
66
+ truncated ``str()``. Trusts the crewai parser types - the caller's
67
+ callback is responsible for swallowing any unexpected exceptions, since
68
+ the step-callback contract is fire-and-forget telemetry.
69
+ """
70
+ if isinstance(step, AgentAction):
71
+ tool_call = f"[cyan]> {step.tool}[/cyan]({truncate(step.tool_input, 120)})"
72
+ msg = f"[yellow]Thought:[/yellow] {step.thought}\n{tool_call}"
73
+ if step.result:
74
+ msg += f"\n[dim]{truncate(step.result, 300)}[/dim]"
75
+ return msg
76
+ if isinstance(step, AgentFinish):
77
+ return f"[bold green]Answer:[/bold green] {truncate(str(step.output), 500)}"
78
+ return truncate(str(step), 300)
crewui/app.py ADDED
@@ -0,0 +1,308 @@
1
+ """crewui.app - the Textual App for a sequential CrewAI pipeline.
2
+
3
+ ``CrewAIPipelineTUI`` renders a sidebar task tracker, an agent output log, and
4
+ a pipeline log for a sequential crew. The host builds the crew and hands it in,
5
+ so the TUI stays out of crew construction.
6
+
7
+ Human review (``Task(human_input=True)``) is handled by routing CrewAI's
8
+ feedback prompt to the input box instead of a blocking terminal ``input()`` -
9
+ see ``_make_tui_human_input_provider`` and ``_await_feedback``.
10
+
11
+ The sidebar title is the host-supplied ``pipeline_name``; ``record_prefix`` is
12
+ used only to route log records to the agent vs pipeline pane. The sidebar reads
13
+ each task's display name (``Task.name``) and agent role straight off
14
+ ``crew.tasks``, so the caller only wires the crew - no separate task map.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import threading
21
+ from collections.abc import Callable
22
+ from pathlib import Path
23
+ from typing import TYPE_CHECKING
24
+
25
+ from crewai import Crew
26
+ from textual import work
27
+ from textual.app import App, ComposeResult
28
+ from textual.containers import Horizontal, Vertical
29
+ from textual.css.query import NoMatches
30
+ from textual.widgets import Input, Label, RichLog, Static
31
+
32
+ from crewui._helpers import (
33
+ format_metrics_block,
34
+ format_step_message,
35
+ route_log_record,
36
+ task_layout,
37
+ truncate,
38
+ )
39
+
40
+ if TYPE_CHECKING:
41
+ from crewai.core.providers.human_input import SyncHumanInputProvider
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+
46
+ class CrewAIPipelineTUI(App[None]):
47
+ # The class owns the default theme. Absolute (not the bare "default.tcss")
48
+ # because Textual resolves a relative CSS_PATH against the *concrete*
49
+ # class's module file - so a derived class would otherwise look for the
50
+ # stylesheet next to its own module. A derived class overrides the theme
51
+ # by setting its own CSS_PATH.
52
+ CSS_PATH = str(Path(__file__).parent / "default.tcss")
53
+
54
+ def __init__(
55
+ self,
56
+ crew: Crew,
57
+ record_prefix: str = "pipeline",
58
+ pipeline_name: str = "",
59
+ dry_run: bool = False,
60
+ on_start: Callable[[], None] | None = None,
61
+ on_complete: Callable[[object], None] | None = None,
62
+ get_token_cost: Callable[[int, int], float] | None = None,
63
+ ) -> None:
64
+ super().__init__()
65
+ self._crew = crew
66
+ self._record_prefix = record_prefix
67
+ self._pipeline_name = pipeline_name
68
+ self._dry_run = dry_run
69
+ self._on_start = on_start
70
+ self._on_complete = on_complete
71
+ self._get_token_cost = get_token_cost
72
+ self._task_widgets: list[tuple[Label, Label]] = []
73
+ # Human-review bridge: the worker thread parks on this event while the
74
+ # operator types feedback into the input box on the UI thread.
75
+ self._feedback_event: threading.Event | None = None
76
+ self._feedback_value: str = ""
77
+ self._log_handler: _TUILogHandler | None = None
78
+ self._crew.step_callback = self._make_step_callback()
79
+
80
+ def compose(self) -> ComposeResult:
81
+ with Horizontal():
82
+ with Vertical(id="sidebar"):
83
+ yield Label(self._pipeline_name, id="sidebar-title")
84
+
85
+ for heading, role in task_layout(self._crew.tasks):
86
+ yield Label(heading, classes="phase-heading")
87
+ name_lbl = Label(role, classes="task-name")
88
+ status_lbl = Label("Waiting", classes="task-status")
89
+ self._task_widgets.append((name_lbl, status_lbl))
90
+ yield name_lbl
91
+ yield status_lbl
92
+
93
+ yield Static("", id="metrics")
94
+
95
+ with Vertical(id="main"):
96
+ with Vertical(id="messages-pane"):
97
+ yield Label("Agent Output", classes="pane-title")
98
+ yield RichLog(id="agent-log", highlight=True, markup=True, wrap=True)
99
+ yield Input(
100
+ placeholder="Human review (idle)",
101
+ disabled=True,
102
+ id="human-input",
103
+ )
104
+ with Vertical(id="logs-pane"):
105
+ yield Label("Pipeline Logs", id="logs-title", classes="pane-title")
106
+ yield RichLog(id="crew-log", highlight=True, markup=True)
107
+
108
+ def on_mount(self) -> None:
109
+ # Held so on_unmount can detach it. A handler left on the root logger
110
+ # after the app stops would route later records through call_from_thread
111
+ # into a dead app - harmless in a one-shot CLI, a leak anywhere the host
112
+ # keeps running after the TUI closes.
113
+ self._log_handler = _TUILogHandler(self)
114
+ logging.getLogger().addHandler(self._log_handler)
115
+ if self._dry_run:
116
+ self._write_crew("[yellow]Dry run mode: pipeline not started.[/yellow]")
117
+ # Render the metrics block zeroed so the sidebar reads as a complete
118
+ # preview rather than a blank panel - no run happened, so the
119
+ # figures are zero and the status says so.
120
+ self.query_one("#metrics", Static).update(
121
+ format_metrics_block(total_tokens=0, estimated_cost_usd=0.0, status="dry run")
122
+ )
123
+ else:
124
+ self._start_run()
125
+
126
+ def on_unmount(self) -> None:
127
+ if self._log_handler is not None:
128
+ logging.getLogger().removeHandler(self._log_handler)
129
+ self._log_handler = None
130
+
131
+ @work(thread=True)
132
+ def _start_run(self) -> None:
133
+ from crewai.core.providers.human_input import reset_provider, set_provider
134
+
135
+ if self._on_start is not None:
136
+ self._on_start()
137
+
138
+ for i, task in enumerate(self._crew.tasks):
139
+ orig: Callable[..., None] | None = task.callback
140
+ task.callback = self._make_task_callback(i, orig)
141
+
142
+ self.call_from_thread(self._set_task_running, 0)
143
+
144
+ # Route CrewAI's human_input feedback prompt to the input box instead of
145
+ # a blocking terminal input(). Set in this (worker) thread so kickoff's
146
+ # get_provider() - same thread - picks it up; reset when the run ends.
147
+ token = set_provider(_make_tui_human_input_provider(self))
148
+ try:
149
+ result = self._crew.kickoff()
150
+ self.call_from_thread(self._on_done, result)
151
+ except Exception as exc:
152
+ self.call_from_thread(self._write_agent, f"[bold red]Pipeline error: {exc}[/bold red]")
153
+ self.call_from_thread(self._write_crew, f"[bold red]Pipeline error: {exc}[/bold red]")
154
+ finally:
155
+ reset_provider(token)
156
+
157
+ def _make_task_callback(
158
+ self, idx: int, orig: Callable[..., None] | None
159
+ ) -> Callable[..., None]:
160
+ def _cb(output: object) -> None:
161
+ self.call_from_thread(self._set_task_done, idx)
162
+ if orig is not None:
163
+ orig(output)
164
+
165
+ return _cb
166
+
167
+ def _make_step_callback(self) -> Callable[[object], None]:
168
+ def _cb(step: object) -> None:
169
+ try:
170
+ msg = format_step_message(step)
171
+ except Exception as exc:
172
+ logger.debug("step callback error: %s", exc)
173
+ return
174
+ self.call_from_thread(self._write_agent, msg)
175
+
176
+ return _cb
177
+
178
+ def _set_task_running(self, idx: int) -> None:
179
+ if idx < len(self._task_widgets):
180
+ name_lbl, status_lbl = self._task_widgets[idx]
181
+ name_lbl.add_class("running")
182
+ status_lbl.add_class("running")
183
+ status_lbl.update("Running...")
184
+
185
+ def _set_task_done(self, idx: int) -> None:
186
+ if idx < len(self._task_widgets):
187
+ name_lbl, status_lbl = self._task_widgets[idx]
188
+ name_lbl.remove_class("running")
189
+ name_lbl.add_class("done")
190
+ status_lbl.remove_class("running")
191
+ status_lbl.add_class("done")
192
+ status_lbl.update("Done")
193
+ next_idx = idx + 1
194
+ if next_idx < len(self._task_widgets):
195
+ self._set_task_running(next_idx)
196
+
197
+ def _on_done(self, result: object) -> None:
198
+ raw = getattr(result, "raw", str(result))
199
+ self._write_agent("[bold green]Pipeline complete.[/bold green]")
200
+ self._write_agent(truncate(raw, 2000))
201
+
202
+ # Hand the result to the host for persistence; a save failure must not
203
+ # take the UI down, so swallow and surface it in the pipeline log.
204
+ if self._on_complete is not None:
205
+ try:
206
+ self._on_complete(result)
207
+ except Exception as exc:
208
+ logger.debug("on_complete callback error: %s", exc)
209
+ self._write_crew(f"[yellow]Metrics error: {exc}[/yellow]")
210
+
211
+ usage = getattr(result, "token_usage", None)
212
+ if usage is None:
213
+ return
214
+
215
+ input_tokens = getattr(usage, "prompt_tokens", 0)
216
+ output_tokens = getattr(usage, "completion_tokens", 0)
217
+ cost = self._get_token_cost(input_tokens, output_tokens) if self._get_token_cost else 0.0
218
+ try:
219
+ self.query_one("#metrics", Static).update(
220
+ format_metrics_block(
221
+ total_tokens=getattr(usage, "total_tokens", input_tokens + output_tokens),
222
+ estimated_cost_usd=cost,
223
+ )
224
+ )
225
+ except NoMatches:
226
+ logger.debug("metrics widget not mounted")
227
+
228
+ def _write_agent(self, msg: str) -> None:
229
+ try:
230
+ self.query_one("#agent-log", RichLog).write(msg)
231
+ except NoMatches:
232
+ logger.debug("agent-log widget not mounted, dropping message")
233
+
234
+ def _write_crew(self, msg: str) -> None:
235
+ try:
236
+ self.query_one("#crew-log", RichLog).write(msg)
237
+ except NoMatches:
238
+ logger.debug("crew-log widget not mounted, dropping message")
239
+
240
+ # -- human review --
241
+
242
+ def _await_feedback(self) -> str:
243
+ """Worker-thread side of the human-review gate.
244
+
245
+ Opens the input box on the UI thread, parks until the operator submits,
246
+ then returns what they typed. Empty (just Enter) means "accept", per
247
+ CrewAI's feedback loop.
248
+ """
249
+ self._feedback_event = threading.Event()
250
+ self._feedback_value = ""
251
+ self.call_from_thread(self._open_feedback_gate)
252
+ self._feedback_event.wait()
253
+ return self._feedback_value
254
+
255
+ def _open_feedback_gate(self) -> None:
256
+ self._write_agent(
257
+ "[bold yellow]Human review requested - reply below (Enter to accept).[/bold yellow]"
258
+ )
259
+ inp = self.query_one("#human-input", Input)
260
+ inp.placeholder = "Your feedback - Enter to accept, or type changes"
261
+ inp.disabled = False
262
+ inp.focus()
263
+
264
+ def on_input_submitted(self, event: Input.Submitted) -> None:
265
+ if event.input.id != "human-input" or self._feedback_event is None:
266
+ return
267
+ self._feedback_value = event.value
268
+ event.input.value = ""
269
+ event.input.disabled = True
270
+ event.input.placeholder = "Human review (idle)"
271
+ self._feedback_event.set()
272
+
273
+
274
+ def _make_tui_human_input_provider(app: CrewAIPipelineTUI) -> SyncHumanInputProvider:
275
+ """Build a CrewAI human-input provider that routes the feedback prompt to
276
+ the TUI input box instead of a blocking terminal ``input()``.
277
+
278
+ Isolated here, with a deferred import, because it leans on crewai's
279
+ semi-internal provider API (``crewai.core.providers.human_input``). That is
280
+ the sanctioned injection point but may move between versions - this is the
281
+ one place to fix if it does. ``output_to_review`` is accepted (crewai passes the
282
+ answer under review) but unused: the step-callback already streams that
283
+ answer into the agent-log pane before the gate opens.
284
+ """
285
+ from crewai.core.providers.human_input import SyncHumanInputProvider
286
+
287
+ # crewai ships no stubs, so SyncHumanInputProvider is Any and mypy --strict
288
+ # rejects subclassing it; the subclass is the sanctioned injection point.
289
+ class _TUIHumanInputProvider(SyncHumanInputProvider): # type: ignore[misc]
290
+ @staticmethod
291
+ def _prompt_input(crew: Crew | None = None, output_to_review: str | None = None) -> str:
292
+ return app._await_feedback()
293
+
294
+ return _TUIHumanInputProvider()
295
+
296
+
297
+ class _TUILogHandler(logging.Handler):
298
+ def __init__(self, app: CrewAIPipelineTUI) -> None:
299
+ super().__init__()
300
+ self._app = app
301
+
302
+ def emit(self, record: logging.LogRecord) -> None:
303
+ msg = self.format(record)
304
+ target = route_log_record(record.name, self._app._record_prefix)
305
+ if target == "agent":
306
+ self._app.call_from_thread(self._app._write_agent, msg)
307
+ else:
308
+ self._app.call_from_thread(self._app._write_crew, msg)
crewui/cli.py ADDED
@@ -0,0 +1,65 @@
1
+ """crewui.cli - the command-line surface.
2
+
3
+ ``crewui`` is a library first: the point of the package is ``from crewui import
4
+ CrewAIPipelineTUI`` inside a host app. The CLI carries just enough to make an
5
+ install self-evidently working - a ``--version`` that both entry points share,
6
+ and a ``demo`` subcommand that runs the bundled offline pipeline so a fresh
7
+ ``pipx install crewui`` shows something on screen without an API key.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import sys
14
+ from collections.abc import Sequence
15
+
16
+ from crewui import __version__
17
+
18
+
19
+ def build_parser() -> argparse.ArgumentParser:
20
+ parser = argparse.ArgumentParser(
21
+ prog="crewui",
22
+ description="A Textual TUI for sequential CrewAI pipelines.",
23
+ )
24
+ parser.add_argument(
25
+ "--version",
26
+ action="version",
27
+ version=f"crewui {__version__}",
28
+ )
29
+ sub = parser.add_subparsers(dest="command")
30
+ demo = sub.add_parser(
31
+ "demo",
32
+ help="Run the bundled offline demo pipeline in the TUI (no API key needed).",
33
+ )
34
+ demo.add_argument(
35
+ "--dry-run",
36
+ action="store_true",
37
+ help="Render the layout without walking the pipeline.",
38
+ )
39
+ return parser
40
+
41
+
42
+ def main(argv: Sequence[str] | None = None) -> int:
43
+ """Entry point for the ``crewui`` console script and ``python -m crewui``.
44
+
45
+ Returns a process exit code. With no subcommand, prints help and exits 0 -
46
+ the install is proven working by ``--version`` and ``demo``, so a bare
47
+ invocation is informational, not an error.
48
+ """
49
+ parser = build_parser()
50
+ args = parser.parse_args(argv)
51
+
52
+ if args.command == "demo":
53
+ # Imported lazily so ``crewui --version`` does not pull in textual and
54
+ # construct crewai agents just to print a string.
55
+ from crewui.demo import run_demo
56
+
57
+ run_demo(dry_run=args.dry_run)
58
+ return 0
59
+
60
+ parser.print_help()
61
+ return 0
62
+
63
+
64
+ if __name__ == "__main__":
65
+ sys.exit(main())
crewui/default.tcss ADDED
@@ -0,0 +1,105 @@
1
+ Screen {
2
+ layout: horizontal;
3
+ background: $background;
4
+ }
5
+
6
+ #sidebar {
7
+ width: 34;
8
+ border-right: solid $surface;
9
+ padding: 1 1;
10
+ layout: vertical;
11
+ overflow-y: auto;
12
+ }
13
+
14
+ #sidebar-title {
15
+ color: $accent;
16
+ text-style: bold;
17
+ margin-bottom: 1;
18
+ }
19
+
20
+ .phase-heading {
21
+ color: $accent;
22
+ text-style: bold;
23
+ margin-top: 1;
24
+ }
25
+
26
+ .task-name {
27
+ color: $text-muted;
28
+ padding-left: 2;
29
+ }
30
+
31
+ .task-name.running {
32
+ color: $warning;
33
+ text-style: bold;
34
+ }
35
+
36
+ .task-name.done {
37
+ color: $success;
38
+ }
39
+
40
+ .task-status {
41
+ color: $text-muted;
42
+ padding-left: 4;
43
+ height: 1;
44
+ }
45
+
46
+ .task-status.running {
47
+ color: $warning;
48
+ }
49
+
50
+ .task-status.done {
51
+ color: $success;
52
+ }
53
+
54
+ #metrics {
55
+ dock: bottom;
56
+ border-top: solid $surface;
57
+ padding: 1;
58
+ color: $text-muted;
59
+ height: 6;
60
+ }
61
+
62
+ #main {
63
+ layout: vertical;
64
+ width: 1fr;
65
+ }
66
+
67
+ #messages-pane {
68
+ height: 60%;
69
+ border-bottom: solid $surface;
70
+ layout: vertical;
71
+ }
72
+
73
+ .pane-title {
74
+ padding: 0 1;
75
+ color: $accent;
76
+ text-style: bold;
77
+ height: 1;
78
+ }
79
+
80
+ #agent-log {
81
+ height: 1fr;
82
+ padding: 0 1;
83
+ }
84
+
85
+ #human-input {
86
+ margin: 0 1 1 1;
87
+ opacity: 0.4;
88
+ }
89
+
90
+ #logs-pane {
91
+ height: 40%;
92
+ layout: vertical;
93
+ }
94
+
95
+ #logs-title {
96
+ padding: 0 1;
97
+ color: $text-muted;
98
+ text-style: bold;
99
+ height: 1;
100
+ }
101
+
102
+ #crew-log {
103
+ height: 1fr;
104
+ padding: 0 1;
105
+ }
crewui/demo.py ADDED
@@ -0,0 +1,158 @@
1
+ """crewui.demo - a self-contained, offline demonstration of the TUI.
2
+
3
+ ``crewui demo`` builds a small three-phase sequential crew and drives it through
4
+ ``CrewAIPipelineTUI`` without any LLM call or network access. It exists so that
5
+ ``pipx install crewui`` yields something immediately runnable - a live look at
6
+ the sidebar tracker, the agent-output stream, and the pipeline log - and so the
7
+ release smoke test has an entry point that exercises the real App end to end.
8
+
9
+ The crew is a genuine ``crewai.Crew`` of genuine ``Agent`` / ``Task`` objects
10
+ (so the sidebar reads real ``Task.name`` / ``agent.role`` values), but its
11
+ ``kickoff`` is replaced with a scripted walk that emits canned step messages
12
+ and fires each task callback in turn. That keeps the demo deterministic and
13
+ free of API keys while still driving every code path the App uses to render a
14
+ run: step callbacks, per-task status transitions, and the final result +
15
+ token-usage block.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import time
22
+ import types
23
+ from dataclasses import dataclass
24
+
25
+ from crewai import Agent, Crew, Process, Task
26
+ from crewai.agents.parser import AgentAction, AgentFinish
27
+
28
+ from crewui.app import CrewAIPipelineTUI
29
+
30
+ # The demo's scripted phases: (task name, agent role, thought, tool, tool input,
31
+ # tool result, final answer). Nothing here is sent anywhere - it is canned prose
32
+ # rendered into the panes so the layout and status flow are visible.
33
+ _PHASES = [
34
+ (
35
+ "Reconnaissance",
36
+ "Scout",
37
+ "Map the surface before touching anything.",
38
+ "enumerate",
39
+ "example.com",
40
+ "found 3 subdomains, 2 open ports",
41
+ "Surface mapped: api.example.com, www.example.com, mail.example.com.",
42
+ ),
43
+ (
44
+ "Analysis",
45
+ "Analyst",
46
+ "Weigh what the scout turned up.",
47
+ "assess",
48
+ "3 subdomains",
49
+ "api.example.com exposes an unauthenticated debug route",
50
+ "One notable exposure on api.example.com; the rest look routine.",
51
+ ),
52
+ (
53
+ "Report",
54
+ "Scribe",
55
+ "Write it up so a human can act on it.",
56
+ "compose",
57
+ "1 finding",
58
+ "drafted a 1-paragraph summary",
59
+ "Report ready: 1 finding worth a closer look on api.example.com.",
60
+ ),
61
+ ]
62
+
63
+
64
+ @dataclass
65
+ class _Usage:
66
+ """Stand-in for CrewAI's token-usage object; only these three fields are
67
+ read by the App's metrics block."""
68
+
69
+ prompt_tokens: int
70
+ completion_tokens: int
71
+ total_tokens: int
72
+
73
+
74
+ @dataclass
75
+ class _Result:
76
+ """Stand-in for a CrewOutput: the App reads ``.raw`` and ``.token_usage``."""
77
+
78
+ raw: str
79
+ token_usage: _Usage
80
+
81
+
82
+ def _scripted_kickoff(crew: Crew) -> _Result:
83
+ """Walk the crew's tasks without calling an LLM.
84
+
85
+ For each task: stream a Thought + tool-call and an Answer through the crew's
86
+ ``step_callback`` (both set by the App before this runs), pause briefly so
87
+ the transition is visible, then fire the task's ``callback`` to advance the
88
+ sidebar. Returns a canned result carrying ``raw`` + ``token_usage`` so the
89
+ App renders the completion panel and metrics block.
90
+ """
91
+ for task, phase in zip(crew.tasks, _PHASES, strict=True):
92
+ _, _, thought, tool, tool_input, tool_result, answer = phase
93
+ if crew.step_callback is not None:
94
+ crew.step_callback(
95
+ AgentAction(
96
+ thought=thought, tool=tool, tool_input=tool_input, text="", result=tool_result
97
+ )
98
+ )
99
+ time.sleep(0.6)
100
+ if crew.step_callback is not None:
101
+ crew.step_callback(AgentFinish(thought=thought, output=answer, text=answer))
102
+ if task.callback is not None:
103
+ task.callback(answer)
104
+ time.sleep(0.4)
105
+
106
+ return _Result(
107
+ raw="Demo pipeline complete - 3 phases, 1 finding.",
108
+ token_usage=_Usage(prompt_tokens=1200, completion_tokens=340, total_tokens=1540),
109
+ )
110
+
111
+
112
+ def build_demo_crew() -> Crew:
113
+ """Build the offline demo crew with a scripted ``kickoff``.
114
+
115
+ A dummy ``OPENAI_API_KEY`` is set only if none is present so that
116
+ constructing the agents never prompts; no request is ever made because
117
+ ``kickoff`` is replaced before the App can call it.
118
+ """
119
+ os.environ.setdefault("OPENAI_API_KEY", "crewui-demo-no-network")
120
+
121
+ agents = []
122
+ tasks = []
123
+ for name, role, *_ in _PHASES:
124
+ agent = Agent(
125
+ role=role,
126
+ goal=f"{role} phase of the demo pipeline",
127
+ backstory=f"The {role.lower()} in a small demonstration crew.",
128
+ llm="gpt-4o-mini",
129
+ verbose=False,
130
+ )
131
+ agents.append(agent)
132
+ tasks.append(
133
+ Task(
134
+ description=f"{name} phase",
135
+ expected_output="a short summary",
136
+ agent=agent,
137
+ name=name,
138
+ )
139
+ )
140
+
141
+ crew = Crew(agents=agents, tasks=tasks, process=Process.sequential)
142
+ # Replace kickoff with the scripted walk. Crew is a pydantic model, so a
143
+ # plain attribute assignment is rejected; object.__setattr__ installs the
144
+ # bound method past that guard. This is the whole reason the demo can run
145
+ # with no API key and no network.
146
+ object.__setattr__(crew, "kickoff", types.MethodType(_scripted_kickoff, crew))
147
+ return crew
148
+
149
+
150
+ def run_demo(dry_run: bool = False) -> None:
151
+ """Launch the TUI against the offline demo crew."""
152
+ CrewAIPipelineTUI(
153
+ crew=build_demo_crew(),
154
+ record_prefix="crewui.demo",
155
+ pipeline_name="crewui demo pipeline",
156
+ dry_run=dry_run,
157
+ get_token_cost=lambda inp, out: (inp * 3 + out * 15) / 1_000_000,
158
+ ).run()
crewui/py.typed ADDED
File without changes
@@ -0,0 +1,164 @@
1
+ Metadata-Version: 2.4
2
+ Name: crewui
3
+ Version: 0.1.0
4
+ Summary: A Textual TUI for sequential CrewAI pipelines
5
+ Author: Nathan Bottomley-Cook
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/coolhandle01/crewui
8
+ Project-URL: Issues, https://github.com/coolhandle01/crewui/issues
9
+ Keywords: crewai,crewai-tools,textual,tui,terminal,console,ai-agents,agentic,pipeline,observability,monitoring
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Framework :: AsyncIO
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development :: User Interfaces
22
+ Classifier: Topic :: System :: Monitoring
23
+ Classifier: Topic :: Terminals
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: <3.14,>=3.10
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: crewai<1.16,>=1.15.6
29
+ Requires-Dist: textual>=0.60
30
+ Provides-Extra: dev
31
+ Requires-Dist: pytest>=8; extra == "dev"
32
+ Requires-Dist: pytest-cov>=5; extra == "dev"
33
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
34
+ Requires-Dist: mypy>=1.11; extra == "dev"
35
+ Requires-Dist: pylint>=3.2; extra == "dev"
36
+ Requires-Dist: ruff==0.16.0; extra == "dev"
37
+ Dynamic: license-file
38
+
39
+ # crewui
40
+
41
+ A [Textual](https://textual.textualize.io/) terminal UI for a sequential
42
+ [CrewAI](https://github.com/crewAIInc/crewAI) pipeline. You build a `Crew`;
43
+ crewui renders it — a sidebar task tracker, a live agent-output stream, a
44
+ pipeline log, and a token/cost summary — and, when a task asks for human
45
+ review, routes the feedback prompt to an input box instead of a blocking
46
+ terminal `input()`.
47
+
48
+ crewui is a presentation layer and nothing more. It reads a `Crew` and touches
49
+ only two things beyond it: the step/task callback contract and, for human
50
+ review, one semi-internal crewai provider hook. Everything application-specific
51
+ — where a run id is recorded, how run metrics are computed and priced — is
52
+ injected through callbacks, so the package never reaches up into your app.
53
+
54
+ ## Install
55
+
56
+ ```bash
57
+ pipx install crewui # the CLI + the demo, isolated
58
+ # or, as a library in your project:
59
+ pip install crewui
60
+ ```
61
+
62
+ See it run without writing any code or setting an API key:
63
+
64
+ ```bash
65
+ crewui demo # drives a scripted three-phase pipeline in the TUI
66
+ crewui demo --dry-run # render the layout without walking the pipeline
67
+ ```
68
+
69
+ The demo is fully offline — no LLM call, no network — so it doubles as a smoke
70
+ test that the install works.
71
+
72
+ ## Use it in your app
73
+
74
+ ```python
75
+ from crewui import CrewAIPipelineTUI
76
+
77
+ CrewAIPipelineTUI(
78
+ crew=build_my_crew(), # any sequential crewai.Crew
79
+ record_prefix="myapp", # log records under this name render in the agent pane
80
+ pipeline_name="My Pipeline", # sidebar title
81
+ ).run()
82
+ ```
83
+
84
+ The sidebar reads each task's display name (`Task.name`) and agent role straight
85
+ off `crew.tasks`, so wiring the crew is the whole job — there is no separate task
86
+ map to maintain.
87
+
88
+ ### Injecting host behaviour
89
+
90
+ Three optional callbacks carry everything crewui deliberately does not know:
91
+
92
+ | Callback | When it fires | Typical use |
93
+ |---|---|---|
94
+ | `on_start()` | worker thread, right before `kickoff()` | bind a run id, stamp the start time |
95
+ | `on_complete(result)` | right after `kickoff()` | persist run metrics |
96
+ | `get_token_cost(input_tokens, output_tokens)` | when rendering the summary | return the USD estimate to display |
97
+
98
+ A save failure in `on_complete` is swallowed and surfaced in the pipeline log —
99
+ persistence never takes the UI down.
100
+
101
+ ### Theming
102
+
103
+ `CrewAIPipelineTUI` ships a usable dark theme and owns it as an absolute
104
+ `CSS_PATH`. A subclass ships its own look by setting its own `CSS_PATH`:
105
+
106
+ ```python
107
+ class MyTUI(CrewAIPipelineTUI):
108
+ CSS_PATH = "my_app.tcss"
109
+ ```
110
+
111
+ ### Human review
112
+
113
+ A `Task(human_input=True)` pauses the run for feedback. crewui opens the input
114
+ box, parks the worker thread until you submit, and feeds what you type back into
115
+ crewai's feedback loop (empty input — just Enter — means "accept"). The routing
116
+ leans on `crewai.core.providers.human_input`, a semi-internal API isolated to a
117
+ single factory in `crewui/app.py` so there is one place to fix if it moves
118
+ between crewai versions.
119
+
120
+ ## The crewai version pin, and the fork
121
+
122
+ crewui declares `crewai>=1.15.6,<1.16`.
123
+
124
+ - **The floor (1.15.6)** is the first release where the agent executor's
125
+ `ask_for_human_input` compatibility property is back. A 1.14.5 executor
126
+ regression crashed `human_input=True` with
127
+ `'AgentExecutor' object has no attribute 'ask_for_human_input'`; below the
128
+ floor, the human-review feature does not work.
129
+ - **The ceiling (<1.16)** is deliberate, not lazy. The human-input routing is
130
+ the one place crewui reaches past crewai's public surface, so a minor bump is
131
+ a review event: re-check that seam, then move the ceiling.
132
+
133
+ There is a **second, separate** human-input bug that a stock PyPI crewai still
134
+ carries: the feedback prompt tells the operator to review "the Final Result
135
+ above", but crewai only renders that result when `verbose=True` — so under a TUI
136
+ with verbose off, the operator is asked to review output that was never printed.
137
+ The downstream project this library was extracted from pins a
138
+ [crewai fork](https://github.com/coolhandle01/crewai) that adds a
139
+ Result-for-Review panel so the referenced output is always shown. **crewui does
140
+ not pin that fork** — it works against stock crewai, and simply streams each
141
+ answer into the agent-output pane before the review gate opens, which is the
142
+ generic equivalent of what the fork's panel does. If you need the fork's exact
143
+ behaviour, pin it in *your* application, not here.
144
+
145
+ ## Development
146
+
147
+ ```bash
148
+ python -m venv .venv && . .venv/bin/activate
149
+ pip install -e ".[dev]"
150
+
151
+ ruff check .
152
+ mypy
153
+ pylint crewui
154
+ pytest --cov=crewui --cov-branch --cov-report=term-missing --cov-fail-under=95
155
+ ```
156
+
157
+ The pure helpers in `crewui/_helpers.py` are unit-tested; the App itself is
158
+ driven through Textual's `pilot` harness in `tests/test_app.py` — status
159
+ transitions, the dry-run preview, the metrics block, the error path, and the
160
+ human-review gate all run against a fake offline crew.
161
+
162
+ ## Licence
163
+
164
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,14 @@
1
+ crewui/__init__.py,sha256=ibQcjcIs_X2EpBTxVje2cjv0TCS5HI9L92eQPANkU_E,1479
2
+ crewui/__main__.py,sha256=oxFD0ILWMa6IzhdyVv9Ny88R4ZfaF9g7p2Z3qvncHNI,341
3
+ crewui/_helpers.py,sha256=YaSRuyzUhIOmqIUYfw9-SdMk1_rUgsjU3tI2gcgZ8ho,3235
4
+ crewui/app.py,sha256=-wYmmB-8fDYFGzVmXGeb7pajvHGr29eb9MJoIrplkfs,12701
5
+ crewui/cli.py,sha256=xp4qtMkV_3is69uXhHffyD7tgnp0w3w7gzW3fVY_mmw,1973
6
+ crewui/default.tcss,sha256=-OOwuZEr3sLGXRPhAWvDB9_QIcSTog13p6WJ3nWzQzA,1356
7
+ crewui/demo.py,sha256=zMciXTv3fKYOU4c2MHl2qSWPkUIuBdtBlH8DgVL-Ugc,5631
8
+ crewui/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ crewui-0.1.0.dist-info/licenses/LICENSE,sha256=8of4gImqhbFqBBXO4BxTR1Z0l6NXyQnGg1TTKm--WmY,1078
10
+ crewui-0.1.0.dist-info/METADATA,sha256=oMp7i3LIvGPz7WmyMr8YPqunL691cNT8jFjVF0Pv0eo,6521
11
+ crewui-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
12
+ crewui-0.1.0.dist-info/entry_points.txt,sha256=6KvX8lnhMe08KVnT2OCXTVqpTc9N5c4JNt3jo5JBLho,43
13
+ crewui-0.1.0.dist-info/top_level.txt,sha256=GtxE0pBpJDt9WbY08D_1CgSRE3Ply5X3Tuwl3XkTwSY,7
14
+ crewui-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ crewui = crewui.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nathan Bottomley-Cook
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ crewui