EvoScientist 0.0.1.dev2__py3-none-any.whl → 0.0.1.dev4__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.
@@ -0,0 +1,604 @@
1
+ """Rich display functions for streaming CLI output.
2
+
3
+ Contains all rendering logic: tool call lines, sub-agent sections,
4
+ todo panels, streaming display layout, and final results display.
5
+ Also provides the shared console and formatter globals.
6
+ """
7
+
8
+ import asyncio
9
+ import os
10
+ import sys
11
+ from typing import Any
12
+
13
+ from rich.console import Console, Group # type: ignore[import-untyped]
14
+ from rich.live import Live # type: ignore[import-untyped]
15
+ from rich.markdown import Markdown # type: ignore[import-untyped]
16
+ from rich.panel import Panel # type: ignore[import-untyped]
17
+ from rich.spinner import Spinner # type: ignore[import-untyped]
18
+ from rich.text import Text # type: ignore[import-untyped]
19
+
20
+ from .formatter import ToolResultFormatter
21
+ from .state import StreamState, SubAgentState, _build_todo_stats, _parse_todo_items
22
+ from .utils import DisplayLimits, ToolStatus, format_tool_compact, is_success
23
+ from .events import stream_agent_events
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Shared globals
27
+ # ---------------------------------------------------------------------------
28
+
29
+ console = Console(
30
+ legacy_windows=(sys.platform == 'win32'),
31
+ no_color=os.getenv('NO_COLOR') is not None,
32
+ )
33
+
34
+ formatter = ToolResultFormatter()
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Todo formatting
39
+ # ---------------------------------------------------------------------------
40
+
41
+ def _format_single_todo(item: dict) -> Text:
42
+ """Format a single todo item with status symbol."""
43
+ status = str(item.get("status", "todo")).lower()
44
+ content_text = str(item.get("content", item.get("task", item.get("title", ""))))
45
+
46
+ if status in ("done", "completed", "complete"):
47
+ symbol = "\u2713"
48
+ label = "done "
49
+ style = "green dim"
50
+ elif status in ("active", "in_progress", "in-progress", "working"):
51
+ symbol = "\u25cf"
52
+ label = "active"
53
+ style = "yellow"
54
+ else:
55
+ symbol = "\u25cb"
56
+ label = "todo "
57
+ style = "dim"
58
+
59
+ line = Text()
60
+ line.append(f" {symbol} ", style=style)
61
+ line.append(label, style=style)
62
+ line.append(" ", style="dim")
63
+ # Truncate long content
64
+ if len(content_text) > 60:
65
+ content_text = content_text[:57] + "\u2026"
66
+ line.append(content_text, style=style)
67
+ return line
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Tool result formatting
72
+ # ---------------------------------------------------------------------------
73
+
74
+ def format_tool_result_compact(_name: str, content: str, max_lines: int = 5) -> list:
75
+ """Format tool result as tree output.
76
+
77
+ Special handling for write_todos: shows formatted checklist with status symbols.
78
+ """
79
+ elements = []
80
+
81
+ if not content.strip():
82
+ elements.append(Text(" \u2514 (empty)", style="dim"))
83
+ return elements
84
+
85
+ # Special handling for write_todos
86
+ if _name == "write_todos":
87
+ items = _parse_todo_items(content)
88
+ if items:
89
+ stats = _build_todo_stats(items)
90
+ stats_line = Text()
91
+ stats_line.append(" \u2514 ", style="dim")
92
+ stats_line.append(stats, style="dim")
93
+ elements.append(stats_line)
94
+ elements.append(Text("", style="dim")) # blank line
95
+
96
+ max_preview = 4
97
+ for item in items[:max_preview]:
98
+ elements.append(_format_single_todo(item))
99
+
100
+ remaining = len(items) - max_preview
101
+ if remaining > 0:
102
+ elements.append(Text(f" ... {remaining} more", style="dim italic"))
103
+
104
+ return elements
105
+
106
+ lines = content.strip().split("\n")
107
+ total_lines = len(lines)
108
+
109
+ display_lines = lines[:max_lines]
110
+ for i, line in enumerate(display_lines):
111
+ prefix = "\u2514" if i == 0 else " "
112
+ if len(line) > 80:
113
+ line = line[:77] + "\u2026"
114
+ style = "dim" if is_success(content) else "red dim"
115
+ elements.append(Text(f" {prefix} {line}", style=style))
116
+
117
+ remaining = total_lines - max_lines
118
+ if remaining > 0:
119
+ elements.append(Text(f" ... +{remaining} lines", style="dim italic"))
120
+
121
+ return elements
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Tool call line rendering
126
+ # ---------------------------------------------------------------------------
127
+
128
+ def _render_tool_call_line(tc: dict, tr: dict | None) -> Text:
129
+ """Render a single tool call line with status indicator."""
130
+ is_task = tc.get('name', '').lower() == 'task'
131
+
132
+ if tr is not None:
133
+ content = tr.get('content', '')
134
+ if is_success(content):
135
+ style = "bold green"
136
+ indicator = "\u2713" if is_task else ToolStatus.SUCCESS.value
137
+ else:
138
+ style = "bold red"
139
+ indicator = "\u2717" if is_task else ToolStatus.ERROR.value
140
+ else:
141
+ style = "bold yellow" if not is_task else "bold cyan"
142
+ indicator = "\u25b6" if is_task else ToolStatus.RUNNING.value
143
+
144
+ # Try to get display name from args first
145
+ tool_compact = format_tool_compact(tc['name'], tc.get('args'))
146
+
147
+ # If args were empty and we have a result, try to infer memory operations from result
148
+ tool_name = tc.get('name', '').lower()
149
+ if tool_name in ('write_file', 'edit_file') and tr is not None:
150
+ result_content = tr.get('content', '')
151
+ if '/MEMORY.md' in result_content or 'MEMORY.md' in result_content:
152
+ tool_compact = "Updating memory"
153
+ elif tool_name == 'read_file' and tr is not None:
154
+ result_content = tr.get('content', '')
155
+ # read_file result doesn't contain path, check if args is empty and result looks like memory
156
+ args = tc.get('args') or {}
157
+ if not args.get('path') and '# EvoScientist Memory' in result_content:
158
+ tool_compact = "Reading memory"
159
+
160
+ tool_text = Text()
161
+ tool_text.append(f"{indicator} ", style=style)
162
+ tool_text.append(tool_compact, style=style)
163
+ return tool_text
164
+
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # Sub-agent section rendering
168
+ # ---------------------------------------------------------------------------
169
+
170
+ def _render_subagent_section(sa: 'SubAgentState', compact: bool = False) -> list:
171
+ """Render a sub-agent's activity as a bordered section.
172
+
173
+ Args:
174
+ sa: Sub-agent state to render
175
+ compact: If True, render minimal 1-line summary (completed sub-agents)
176
+
177
+ Header uses "Cooking with {name}" style matching task tool format.
178
+ Active sub-agents show bordered tool list; completed ones collapse to 1 line.
179
+ """
180
+ elements = []
181
+ BORDER = "dim cyan" if sa.is_active else "dim"
182
+
183
+ # Filter out tool calls with empty names
184
+ valid_calls = [tc for tc in sa.tool_calls if tc.get("name")]
185
+
186
+ # Split into completed and pending
187
+ completed = []
188
+ pending = []
189
+ for tc in valid_calls:
190
+ tr = sa.get_result_for(tc)
191
+ if tr is not None:
192
+ completed.append((tc, tr))
193
+ else:
194
+ pending.append(tc)
195
+
196
+ succeeded = sum(1 for _, tr in completed if tr.get("success", True))
197
+ _ = len(completed) - succeeded # failed count, unused for now
198
+
199
+ # Build display name
200
+ display_name = f"Cooking with {sa.name}"
201
+ if sa.description:
202
+ desc = sa.description.split("\n")[0].strip()
203
+ desc = desc[:50] + "\u2026" if len(desc) > 50 else desc
204
+ display_name += f" \u2014 {desc}"
205
+
206
+ # --- Compact mode: 1-line summary for completed sub-agents ---
207
+ if compact:
208
+ line = Text()
209
+ if not sa.is_active:
210
+ line.append("\u2713 ", style="green")
211
+ line.append(display_name, style="green dim")
212
+ total = len(valid_calls)
213
+ line.append(f" ({total} tools)", style="dim")
214
+ else:
215
+ line.append("\u25b6 ", style="cyan")
216
+ line.append(display_name, style="bold cyan")
217
+ elements.append(line)
218
+ return elements
219
+
220
+ # --- Full mode: bordered section for Live streaming ---
221
+
222
+ # Header
223
+ header = Text()
224
+ header.append("\u250c ", style=BORDER)
225
+ if sa.is_active:
226
+ header.append(f"\u25b6 {display_name}", style="bold cyan")
227
+ else:
228
+ header.append(f"\u2713 {display_name}", style="bold green")
229
+ elements.append(header)
230
+
231
+ # Show every tool call with its status
232
+ for tc, tr in completed:
233
+ tc_line = Text("\u2502 ", style=BORDER)
234
+ tc_name = format_tool_compact(tc["name"], tc.get("args"))
235
+ if tr.get("success", True):
236
+ tc_line.append(f"\u2713 {tc_name}", style="green")
237
+ else:
238
+ tc_line.append(f"\u2717 {tc_name}", style="red")
239
+ content = tr.get("content", "")
240
+ first_line = content.strip().split("\n")[0][:70]
241
+ if first_line:
242
+ err_line = Text("\u2502 ", style=BORDER)
243
+ err_line.append(f"\u2514 {first_line}", style="red dim")
244
+ elements.append(tc_line)
245
+ elements.append(err_line)
246
+ continue
247
+ elements.append(tc_line)
248
+
249
+ # Pending/running tools
250
+ for tc in pending:
251
+ tc_line = Text("\u2502 ", style=BORDER)
252
+ tc_name = format_tool_compact(tc["name"], tc.get("args"))
253
+ tc_line.append(f"\u25cf {tc_name}", style="bold yellow")
254
+ elements.append(tc_line)
255
+ spinner_line = Text("\u2502 ", style=BORDER)
256
+ spinner_line.append("\u21bb running...", style="yellow dim")
257
+ elements.append(spinner_line)
258
+
259
+ # Footer
260
+ if not sa.is_active:
261
+ total = len(valid_calls)
262
+ footer = Text(f"\u2514 done ({total} tools)", style="dim green")
263
+ elements.append(footer)
264
+ elif valid_calls:
265
+ footer = Text("\u2514 running...", style="dim cyan")
266
+ elements.append(footer)
267
+
268
+ return elements
269
+
270
+
271
+ # ---------------------------------------------------------------------------
272
+ # Todo panel
273
+ # ---------------------------------------------------------------------------
274
+
275
+ def _render_todo_panel(todo_items: list[dict]) -> Panel:
276
+ """Render a bordered Task List panel from todo items.
277
+
278
+ Matches the style: cyan border, status icons per item.
279
+ """
280
+ lines = Text()
281
+ for i, item in enumerate(todo_items):
282
+ if i > 0:
283
+ lines.append("\n")
284
+ status = str(item.get("status", "todo")).lower()
285
+ content_text = str(item.get("content", item.get("task", item.get("title", ""))))
286
+
287
+ if status in ("done", "completed", "complete"):
288
+ symbol = "\u2713" # checkmark
289
+ style = "green dim"
290
+ elif status in ("active", "in_progress", "in-progress", "working"):
291
+ symbol = "\u23f3" # hourglass
292
+ style = "yellow"
293
+ else:
294
+ symbol = "\u25a1" # empty square
295
+ style = "dim"
296
+
297
+ lines.append(f"{symbol} ", style=style)
298
+ lines.append(content_text, style=style)
299
+
300
+ return Panel(
301
+ lines,
302
+ title="Task List",
303
+ title_align="center",
304
+ border_style="cyan",
305
+ padding=(0, 1),
306
+ )
307
+
308
+
309
+ # ---------------------------------------------------------------------------
310
+ # Streaming display layout
311
+ # ---------------------------------------------------------------------------
312
+
313
+ def create_streaming_display(
314
+ thinking_text: str = "",
315
+ response_text: str = "",
316
+ latest_text: str = "",
317
+ tool_calls: list | None = None,
318
+ tool_results: list | None = None,
319
+ is_thinking: bool = False,
320
+ is_responding: bool = False,
321
+ is_waiting: bool = False,
322
+ is_processing: bool = False,
323
+ show_thinking: bool = True,
324
+ subagents: list | None = None,
325
+ todo_items: list | None = None,
326
+ ) -> Any:
327
+ """Create Rich display layout for streaming output.
328
+
329
+ Returns:
330
+ Rich Group for Live display
331
+ """
332
+ elements = []
333
+ tool_calls = tool_calls or []
334
+ tool_results = tool_results or []
335
+ subagents = subagents or []
336
+
337
+ # Initial waiting state
338
+ if is_waiting and not thinking_text and not response_text and not tool_calls:
339
+ spinner = Spinner("dots", text=" Thinking...", style="cyan")
340
+ elements.append(spinner)
341
+ return Group(*elements)
342
+
343
+ # Thinking panel
344
+ if show_thinking and thinking_text:
345
+ thinking_title = "Thinking"
346
+ if is_thinking:
347
+ thinking_title += " ..."
348
+ display_thinking = thinking_text
349
+ if len(display_thinking) > DisplayLimits.THINKING_STREAM:
350
+ display_thinking = "..." + display_thinking[-DisplayLimits.THINKING_STREAM:]
351
+ elements.append(Panel(
352
+ Text(display_thinking, style="dim"),
353
+ title=thinking_title,
354
+ border_style="blue",
355
+ padding=(0, 1),
356
+ ))
357
+
358
+ # Tool calls and results paired display
359
+ # Collapse older completed tools to prevent overflow in Live mode
360
+ # Task tool calls are ALWAYS visible (they represent sub-agent delegations)
361
+ MAX_VISIBLE_TOOLS = 4
362
+ MAX_VISIBLE_RUNNING = 3
363
+
364
+ if tool_calls:
365
+ # Split into categories
366
+ completed_regular = [] # completed non-task tools
367
+ task_tools = [] # task tools (always visible)
368
+ running_regular = [] # running non-task tools
369
+
370
+ for i, tc in enumerate(tool_calls):
371
+ has_result = i < len(tool_results)
372
+ tr = tool_results[i] if has_result else None
373
+ is_task = tc.get('name') == 'task'
374
+
375
+ if is_task:
376
+ # Skip task calls with empty args (still streaming)
377
+ if tc.get('args'):
378
+ task_tools.append((tc, tr))
379
+ elif has_result:
380
+ completed_regular.append((tc, tr))
381
+ else:
382
+ running_regular.append((tc, None))
383
+
384
+ # --- Completed regular tools (collapsible) ---
385
+ slots = max(0, MAX_VISIBLE_TOOLS - len(running_regular))
386
+ hidden = completed_regular[:-slots] if slots and len(completed_regular) > slots else (completed_regular if not slots else [])
387
+ visible = completed_regular[-slots:] if slots else []
388
+
389
+ if hidden:
390
+ ok = sum(1 for _, tr in hidden if is_success(tr.get('content', '')))
391
+ fail = len(hidden) - ok
392
+ summary = Text()
393
+ summary.append(f"\u2713 {ok} completed", style="dim green")
394
+ if fail > 0:
395
+ summary.append(f" | {fail} failed", style="dim red")
396
+ elements.append(summary)
397
+
398
+ for tc, tr in visible:
399
+ elements.append(_render_tool_call_line(tc, tr))
400
+ content = tr.get('content', '') if tr else ''
401
+ if tr and not is_success(content):
402
+ result_elements = format_tool_result_compact(
403
+ tr['name'], content, max_lines=5,
404
+ )
405
+ elements.extend(result_elements)
406
+
407
+ # --- Running regular tools (limit visible) ---
408
+ hidden_running = len(running_regular) - MAX_VISIBLE_RUNNING
409
+ if hidden_running > 0:
410
+ summary = Text()
411
+ summary.append(f"\u25cf {hidden_running} more running...", style="dim yellow")
412
+ elements.append(summary)
413
+ running_regular = running_regular[-MAX_VISIBLE_RUNNING:]
414
+
415
+ for tc, tr in running_regular:
416
+ elements.append(_render_tool_call_line(tc, tr))
417
+ spinner = Spinner("dots", text=" Running...", style="yellow")
418
+ elements.append(spinner)
419
+
420
+ # Task tool calls are rendered as part of sub-agent sections below
421
+
422
+ # Response text handling
423
+ has_pending_tools = len(tool_calls) > len(tool_results)
424
+ any_active_subagent = any(sa.is_active for sa in subagents)
425
+ has_used_tools = len(tool_calls) > 0
426
+ all_done = not has_pending_tools and not any_active_subagent and not is_processing
427
+
428
+ # Intermediate narration (tools still running) -- dim italic above Task List
429
+ if latest_text and has_used_tools and not all_done:
430
+ preview = latest_text.strip()
431
+ if preview:
432
+ last_line = preview.split("\n")[-1].strip()
433
+ if last_line:
434
+ if len(last_line) > 60:
435
+ last_line = last_line[:57] + "\u2026"
436
+ elements.append(Text(f" {last_line}", style="dim italic"))
437
+
438
+ # Task List panel (persistent, updates on write_todos / read_todos)
439
+ todo_items = todo_items or []
440
+ if todo_items:
441
+ elements.append(Text("")) # blank separator
442
+ elements.append(_render_todo_panel(todo_items))
443
+
444
+ # Sub-agent activity sections
445
+ # Active: full bordered view; Completed: compact 1-line summary
446
+ for sa in subagents:
447
+ if sa.tool_calls or sa.is_active:
448
+ elements.extend(_render_subagent_section(sa, compact=not sa.is_active))
449
+
450
+ # Processing state after tool execution
451
+ if is_processing and not is_thinking and not is_responding and not response_text:
452
+ # Check if any sub-agent is active
453
+ any_active = any(sa.is_active for sa in subagents)
454
+ if not any_active:
455
+ spinner = Spinner("dots", text=" Analyzing results...", style="cyan")
456
+ elements.append(spinner)
457
+
458
+ # Final response -- render as Markdown when all work is done
459
+ if response_text and all_done:
460
+ elements.append(Text("")) # blank separator
461
+ elements.append(Markdown(response_text))
462
+ elif is_responding and not thinking_text and not has_pending_tools:
463
+ elements.append(Text("Generating response...", style="dim"))
464
+
465
+ return Group(*elements) if elements else Text("Processing...", style="dim")
466
+
467
+
468
+ # ---------------------------------------------------------------------------
469
+ # Final results display
470
+ # ---------------------------------------------------------------------------
471
+
472
+ def display_final_results(
473
+ state: StreamState,
474
+ thinking_max_length: int = DisplayLimits.THINKING_FINAL,
475
+ show_thinking: bool = True,
476
+ show_tools: bool = True,
477
+ ) -> None:
478
+ """Display final results after streaming completes."""
479
+ if show_thinking and state.thinking_text:
480
+ display_thinking = state.thinking_text
481
+ if len(display_thinking) > thinking_max_length:
482
+ half = thinking_max_length // 2
483
+ display_thinking = display_thinking[:half] + "\n\n... (truncated) ...\n\n" + display_thinking[-half:]
484
+ console.print(Panel(
485
+ Text(display_thinking, style="dim"),
486
+ title="Thinking",
487
+ border_style="blue",
488
+ ))
489
+
490
+ if show_tools and state.tool_calls:
491
+ shown_sa_names: set[str] = set()
492
+
493
+ for i, tc in enumerate(state.tool_calls):
494
+ has_result = i < len(state.tool_results)
495
+ tr = state.tool_results[i] if has_result else None
496
+ content = tr.get('content', '') if tr is not None else ''
497
+ is_task = tc.get('name', '').lower() == 'task'
498
+
499
+ # Task tools: show delegation line + compact sub-agent summary
500
+ if is_task:
501
+ console.print(_render_tool_call_line(tc, tr))
502
+ sa_name = tc.get('args', {}).get('subagent_type', '')
503
+ task_desc = tc.get('args', {}).get('description', '')
504
+ matched_sa = None
505
+ for sa in state.subagents:
506
+ if sa.name == sa_name or (task_desc and task_desc in (sa.description or '')):
507
+ matched_sa = sa
508
+ break
509
+ if matched_sa:
510
+ shown_sa_names.add(matched_sa.name)
511
+ for elem in _render_subagent_section(matched_sa, compact=True):
512
+ console.print(elem)
513
+ continue
514
+
515
+ # Regular tools: show tool call line + result
516
+ console.print(_render_tool_call_line(tc, tr))
517
+ if has_result and tr is not None:
518
+ result_elements = format_tool_result_compact(
519
+ tr['name'],
520
+ content,
521
+ max_lines=10,
522
+ )
523
+ for elem in result_elements:
524
+ console.print(elem)
525
+
526
+ # Render any sub-agents not already shown via task tool calls
527
+ for sa in state.subagents:
528
+ if sa.name not in shown_sa_names and (sa.tool_calls or sa.is_active):
529
+ for elem in _render_subagent_section(sa, compact=True):
530
+ console.print(elem)
531
+
532
+ console.print()
533
+
534
+ # Task List panel in final output
535
+ if state.todo_items:
536
+ console.print(_render_todo_panel(state.todo_items))
537
+ console.print()
538
+
539
+ if state.response_text:
540
+ # Strip trailing standalone "..." lines
541
+ clean_response = state.response_text.rstrip()
542
+ while clean_response.endswith("\n...") or clean_response.rstrip() == "...":
543
+ clean_response = clean_response.rstrip().removesuffix("...").rstrip()
544
+ console.print()
545
+ console.print(Markdown(clean_response or state.response_text))
546
+ console.print()
547
+
548
+
549
+ # ---------------------------------------------------------------------------
550
+ # Async-to-sync bridge
551
+ # ---------------------------------------------------------------------------
552
+
553
+ def _run_streaming(
554
+ agent: Any,
555
+ message: str,
556
+ thread_id: str,
557
+ show_thinking: bool,
558
+ interactive: bool,
559
+ ) -> None:
560
+ """Run async streaming and render with Rich Live display.
561
+
562
+ Bridges the async stream_agent_events() into synchronous Rich Live rendering
563
+ using asyncio.run().
564
+
565
+ Args:
566
+ agent: Compiled agent graph
567
+ message: User message
568
+ thread_id: Thread ID
569
+ show_thinking: Whether to show thinking panel
570
+ interactive: If True, use simplified final display (no panel)
571
+ """
572
+ state = StreamState()
573
+
574
+ async def _consume() -> None:
575
+ async for event in stream_agent_events(agent, message, thread_id):
576
+ event_type = state.handle_event(event)
577
+ live.update(create_streaming_display(
578
+ **state.get_display_args(),
579
+ show_thinking=show_thinking,
580
+ ))
581
+ if event_type in (
582
+ "tool_call", "tool_result",
583
+ "subagent_start", "subagent_tool_call",
584
+ "subagent_tool_result", "subagent_end",
585
+ ):
586
+ live.refresh()
587
+
588
+ with Live(console=console, refresh_per_second=10, transient=True) as live:
589
+ live.update(create_streaming_display(is_waiting=True))
590
+ asyncio.run(_consume())
591
+
592
+ if interactive:
593
+ display_final_results(
594
+ state,
595
+ thinking_max_length=500,
596
+ show_thinking=False,
597
+ show_tools=True,
598
+ )
599
+ else:
600
+ console.print()
601
+ display_final_results(
602
+ state,
603
+ show_tools=True,
604
+ )