glaip-sdk 0.3.0__py3-none-any.whl → 0.4.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.
Files changed (50) hide show
  1. glaip_sdk/cli/auth.py +2 -1
  2. glaip_sdk/cli/commands/agents.py +1 -1
  3. glaip_sdk/cli/commands/configure.py +2 -1
  4. glaip_sdk/cli/commands/mcps.py +191 -44
  5. glaip_sdk/cli/commands/transcripts.py +1 -1
  6. glaip_sdk/cli/display.py +1 -1
  7. glaip_sdk/cli/hints.py +58 -0
  8. glaip_sdk/cli/io.py +6 -3
  9. glaip_sdk/cli/main.py +2 -1
  10. glaip_sdk/cli/slash/agent_session.py +2 -1
  11. glaip_sdk/cli/slash/session.py +1 -1
  12. glaip_sdk/cli/transcript/capture.py +1 -1
  13. glaip_sdk/cli/transcript/viewer.py +13 -646
  14. glaip_sdk/cli/update_notifier.py +2 -1
  15. glaip_sdk/cli/utils.py +63 -110
  16. glaip_sdk/client/agents.py +2 -4
  17. glaip_sdk/client/main.py +2 -18
  18. glaip_sdk/client/mcps.py +11 -1
  19. glaip_sdk/client/run_rendering.py +90 -111
  20. glaip_sdk/client/shared.py +21 -0
  21. glaip_sdk/models.py +8 -7
  22. glaip_sdk/utils/display.py +23 -15
  23. glaip_sdk/utils/rendering/__init__.py +6 -13
  24. glaip_sdk/utils/rendering/formatting.py +5 -30
  25. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  26. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +9 -0
  27. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +70 -1
  28. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  29. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  30. glaip_sdk/utils/rendering/models.py +1 -0
  31. glaip_sdk/utils/rendering/renderer/__init__.py +10 -28
  32. glaip_sdk/utils/rendering/renderer/base.py +214 -1469
  33. glaip_sdk/utils/rendering/renderer/debug.py +24 -0
  34. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  35. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  36. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  37. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  38. glaip_sdk/utils/rendering/state.py +204 -0
  39. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  40. glaip_sdk/utils/rendering/{steps.py → steps/event_processor.py} +53 -440
  41. glaip_sdk/utils/rendering/steps/format.py +176 -0
  42. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  43. glaip_sdk/utils/rendering/timing.py +36 -0
  44. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  45. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  46. glaip_sdk/utils/validation.py +13 -21
  47. {glaip_sdk-0.3.0.dist-info → glaip_sdk-0.4.0.dist-info}/METADATA +1 -1
  48. {glaip_sdk-0.3.0.dist-info → glaip_sdk-0.4.0.dist-info}/RECORD +50 -34
  49. {glaip_sdk-0.3.0.dist-info → glaip_sdk-0.4.0.dist-info}/WHEEL +0 -0
  50. {glaip_sdk-0.3.0.dist-info → glaip_sdk-0.4.0.dist-info}/entry_points.txt +0 -0
@@ -6,16 +6,12 @@ Authors:
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from collections.abc import Callable, Iterable
10
- from dataclasses import dataclass
11
- from datetime import datetime, timezone
9
+ from collections.abc import Callable
12
10
  from pathlib import Path
13
11
  from typing import Any
14
12
 
15
13
  import click
16
14
  from rich.console import Console
17
- from rich.markdown import Markdown
18
- from rich.text import Text
19
15
 
20
16
  try: # pragma: no cover - optional dependency
21
17
  import questionary
@@ -26,34 +22,19 @@ except Exception: # pragma: no cover - optional dependency
26
22
 
27
23
  from glaip_sdk.cli.transcript.cache import suggest_filename
28
24
  from glaip_sdk.cli.utils import prompt_export_choice_questionary, questionary_safe_ask
29
- from glaip_sdk.icons import ICON_AGENT, ICON_DELEGATE, ICON_TOOL_STEP
30
- from glaip_sdk.rich_components import AIPPanel
31
- from glaip_sdk.utils.rendering.formatting import (
32
- build_connector_prefix,
33
- glyph_for_status,
34
- normalise_display_label,
25
+ from glaip_sdk.utils.rendering.layout.progress import is_delegation_tool
26
+ from glaip_sdk.utils.rendering.viewer import (
27
+ ViewerContext as PresenterViewerContext,
28
+ prepare_viewer_snapshot as presenter_prepare_viewer_snapshot,
29
+ render_post_run_view as presenter_render_post_run_view,
30
+ render_transcript_events as presenter_render_transcript_events,
31
+ render_transcript_view as presenter_render_transcript_view,
35
32
  )
36
- from glaip_sdk.utils.rendering.renderer.debug import render_debug_event
37
- from glaip_sdk.utils.rendering.renderer.panels import create_final_panel
38
- from glaip_sdk.utils.rendering.renderer.progress import (
39
- format_elapsed_time,
40
- is_delegation_tool,
41
- )
42
- from glaip_sdk.utils.rendering.steps import StepManager
43
33
 
44
34
  EXPORT_CANCELLED_MESSAGE = "[dim]Export cancelled.[/dim]"
45
35
 
46
36
 
47
- @dataclass(slots=True)
48
- class ViewerContext:
49
- """Runtime context for the viewer session."""
50
-
51
- manifest_entry: dict[str, Any]
52
- events: list[dict[str, Any]]
53
- default_output: str
54
- final_output: str
55
- stream_started_at: float | None
56
- meta: dict[str, Any]
37
+ ViewerContext = PresenterViewerContext
57
38
 
58
39
 
59
40
  class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
@@ -109,66 +90,12 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
109
90
  self.console.print(f"[dim]{' · '.join(subtitle_parts)}[/]")
110
91
  self.console.print()
111
92
 
112
- query = self._get_user_query()
113
-
114
93
  if self._view_mode == "default":
115
- self._render_default_view(query)
94
+ presenter_render_post_run_view(self.console, self.ctx)
116
95
  else:
117
- self._render_transcript_view(query)
118
-
119
- def _render_default_view(self, query: str | None) -> None:
120
- """Render the default summary view.
121
-
122
- Args:
123
- query: Optional user query to display.
124
- """
125
- if query:
126
- self._render_user_query(query)
127
- self._render_steps_summary()
128
- self._render_final_panel()
129
-
130
- def _render_transcript_view(self, query: str | None) -> None:
131
- """Render the full transcript view with events.
132
-
133
- Args:
134
- query: Optional user query to display.
135
- """
136
- if not self.ctx.events:
137
- self.console.print("[dim]No SSE events were captured for this run.[/dim]")
138
- return
139
-
140
- if query:
141
- self._render_user_query(query)
142
-
143
- self._render_steps_summary()
144
- self._render_final_panel()
145
-
146
- self.console.print("[bold]Transcript Events[/bold]")
147
- self.console.print("[dim]────────────────────────────────────────────────────────[/dim]")
148
-
149
- base_received_ts: datetime | None = None
150
- for event in self.ctx.events:
151
- received_ts = self._parse_received_timestamp(event)
152
- if base_received_ts is None and received_ts is not None:
153
- base_received_ts = received_ts
154
- render_debug_event(
155
- event,
156
- self.console,
157
- received_ts=received_ts,
158
- baseline_ts=base_received_ts,
159
- )
160
- self.console.print()
161
-
162
- def _render_final_panel(self) -> None:
163
- """Render the final result panel."""
164
- content = self.ctx.final_output or self.ctx.default_output or "No response content captured."
165
- title = "Final Result"
166
- duration_text = self._extract_final_duration()
167
- if duration_text:
168
- title += f" · {duration_text}"
169
- panel = create_final_panel(content, title=title, theme="dark")
170
- self.console.print(panel)
171
- self.console.print()
96
+ snapshot, state = presenter_prepare_viewer_snapshot(self.ctx, glyphs=None)
97
+ presenter_render_transcript_view(self.console, snapshot)
98
+ presenter_render_transcript_events(self.console, state.events)
172
99
 
173
100
  # ------------------------------------------------------------------
174
101
  # Interaction loops
@@ -338,523 +265,6 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
338
265
  self.console.print("[dim]Ctrl+T to toggle transcript · type `e` to export · press Enter to exit[/dim]")
339
266
  self.console.print()
340
267
 
341
- def _get_user_query(self) -> str | None:
342
- """Extract user query from metadata or manifest.
343
-
344
- Returns:
345
- User query string or None.
346
- """
347
- meta = self.ctx.meta or {}
348
- manifest = self.ctx.manifest_entry or {}
349
- return meta.get("input_message") or meta.get("query") or meta.get("message") or manifest.get("input_message")
350
-
351
- def _render_user_query(self, query: str) -> None:
352
- """Render user query in a panel.
353
-
354
- Args:
355
- query: User query string to render.
356
- """
357
- panel = AIPPanel(
358
- Markdown(f"Query: {query}"),
359
- title="User Request",
360
- border_style="#d97706",
361
- )
362
- self.console.print(panel)
363
- self.console.print()
364
-
365
- def _render_steps_summary(self) -> None:
366
- """Render steps summary panel."""
367
- stored_lines = self.ctx.meta.get("transcript_step_lines")
368
- if stored_lines:
369
- body = Text("\n".join(stored_lines), style="dim")
370
- else:
371
- tree_text = self._build_tree_summary_text()
372
- if tree_text is not None:
373
- body = tree_text
374
- else:
375
- panel_content = self._format_steps_summary(self._build_step_summary())
376
- body = Text(panel_content, style="dim")
377
- panel = AIPPanel(body, title="Steps", border_style="blue")
378
- self.console.print(panel)
379
- self.console.print()
380
-
381
- @staticmethod
382
- def _format_steps_summary(steps: list[dict[str, Any]]) -> str:
383
- """Format steps summary as text.
384
-
385
- Args:
386
- steps: List of step dictionaries.
387
-
388
- Returns:
389
- Formatted text string.
390
- """
391
- if not steps:
392
- return " No steps yet"
393
-
394
- lines = []
395
- for step in steps:
396
- icon = ICON_DELEGATE if step.get("is_delegate") else ICON_TOOL_STEP
397
- duration = step.get("duration")
398
- duration_str = f" [{duration}]" if duration else ""
399
- status = " ✓" if step.get("finished") else ""
400
- title = step.get("title") or step.get("name") or "Step"
401
- lines.append(f" {icon} {title}{duration_str}{status}")
402
- return "\n".join(lines)
403
-
404
- @staticmethod
405
- def _extract_event_time(event: dict[str, Any]) -> float | None:
406
- """Extract timestamp from event metadata.
407
-
408
- Args:
409
- event: Event dictionary.
410
-
411
- Returns:
412
- Time value as float or None.
413
- """
414
- metadata = event.get("metadata") or {}
415
- time_value = metadata.get("time")
416
- try:
417
- if isinstance(time_value, (int, float)):
418
- return float(time_value)
419
- except Exception:
420
- return None
421
- return None
422
-
423
- @staticmethod
424
- def _parse_received_timestamp(event: dict[str, Any]) -> datetime | None:
425
- """Parse received timestamp from event.
426
-
427
- Args:
428
- event: Event dictionary.
429
-
430
- Returns:
431
- Parsed datetime or None.
432
- """
433
- value = event.get("received_at")
434
- if not value:
435
- return None
436
- if isinstance(value, str):
437
- try:
438
- normalised = value.replace("Z", "+00:00")
439
- parsed = datetime.fromisoformat(normalised)
440
- except ValueError:
441
- return None
442
- return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
443
- return None
444
-
445
- def _extract_final_duration(self) -> str | None:
446
- """Extract final duration from events.
447
-
448
- Returns:
449
- Duration string or None.
450
- """
451
- for event in self.ctx.events:
452
- metadata = event.get("metadata") or {}
453
- if metadata.get("kind") == "final_response":
454
- time_value = metadata.get("time")
455
- try:
456
- if isinstance(time_value, (int, float)):
457
- return f"{float(time_value):.2f}s"
458
- except Exception:
459
- return None
460
- return None
461
-
462
- def _build_step_summary(self) -> list[dict[str, Any]]:
463
- """Build step summary from stored steps or events.
464
-
465
- Returns:
466
- List of step dictionaries.
467
- """
468
- stored = self.ctx.meta.get("transcript_steps")
469
- if isinstance(stored, list) and stored:
470
- return [
471
- {
472
- "title": entry.get("display_name") or entry.get("name") or "Step",
473
- "is_delegate": entry.get("kind") == "delegate",
474
- "finished": entry.get("status") == "finished",
475
- "duration": self._format_duration_from_ms(entry.get("duration_ms")),
476
- }
477
- for entry in stored
478
- ]
479
-
480
- steps: dict[str, dict[str, Any]] = {}
481
- order: list[str] = []
482
-
483
- for event in self.ctx.events:
484
- metadata = event.get("metadata") or {}
485
- if not self._is_step_event(metadata):
486
- continue
487
-
488
- for name, info in self._iter_step_candidates(event, metadata):
489
- step = self._ensure_step_entry(steps, order, name)
490
- self._apply_step_update(step, metadata, info, event)
491
-
492
- return [steps[name] for name in order]
493
-
494
- def _build_tree_summary_text(self) -> Text | None:
495
- """Render hierarchical tree from captured SSE events when available."""
496
- manager = StepManager()
497
- processed = False
498
-
499
- for event in self.ctx.events:
500
- payload = self._coerce_step_event(event)
501
- if not payload:
502
- continue
503
- try:
504
- manager.apply_event(payload)
505
- processed = True
506
- except ValueError:
507
- continue
508
-
509
- if not processed or not manager.order:
510
- return None
511
-
512
- lines: list[str] = []
513
- roots = manager.order
514
- total_roots = len(roots)
515
- for index, root_id in enumerate(roots):
516
- self._render_tree_branch(
517
- manager=manager,
518
- step_id=root_id,
519
- ancestor_state=(),
520
- is_last=index == total_roots - 1,
521
- lines=lines,
522
- )
523
-
524
- if not lines:
525
- return None
526
-
527
- self._decorate_root_presentation(manager, roots[0], lines)
528
-
529
- return Text("\n".join(lines), style="dim")
530
-
531
- def _render_tree_branch(
532
- self,
533
- *,
534
- manager: StepManager,
535
- step_id: str,
536
- ancestor_state: tuple[bool, ...],
537
- is_last: bool,
538
- lines: list[str],
539
- ) -> None:
540
- """Recursively render a tree branch of steps.
541
-
542
- Args:
543
- manager: StepManager instance.
544
- step_id: ID of step to render.
545
- ancestor_state: Tuple of ancestor branch states.
546
- is_last: Whether this is the last child.
547
- lines: List to append rendered lines to.
548
- """
549
- step = manager.by_id.get(step_id)
550
- if not step:
551
- return
552
- suppress = self._should_hide_step(step)
553
- children = manager.children.get(step_id, [])
554
-
555
- if not suppress:
556
- branch_state = ancestor_state
557
- if branch_state:
558
- branch_state = branch_state + (is_last,)
559
- lines.append(self._format_tree_line(step, branch_state))
560
- next_ancestor_state = ancestor_state + (is_last,)
561
- else:
562
- next_ancestor_state = ancestor_state
563
-
564
- if not children:
565
- return
566
-
567
- total_children = len(children)
568
- for idx, child_id in enumerate(children):
569
- self._render_tree_branch(
570
- manager=manager,
571
- step_id=child_id,
572
- ancestor_state=next_ancestor_state if not suppress else ancestor_state,
573
- is_last=idx == total_children - 1,
574
- lines=lines,
575
- )
576
-
577
- def _should_hide_step(self, step: Any) -> bool:
578
- """Check if a step should be hidden.
579
-
580
- Args:
581
- step: Step object.
582
-
583
- Returns:
584
- True if step should be hidden.
585
- """
586
- if getattr(step, "parent_id", None) is None:
587
- return False
588
- name = getattr(step, "name", "") or ""
589
- return self._looks_like_uuid(name)
590
-
591
- def _decorate_root_presentation(
592
- self,
593
- manager: StepManager,
594
- root_id: str,
595
- lines: list[str],
596
- ) -> None:
597
- """Decorate root step presentation with friendly label.
598
-
599
- Args:
600
- manager: StepManager instance.
601
- root_id: Root step ID.
602
- lines: Lines list to modify.
603
- """
604
- if not lines:
605
- return
606
-
607
- root_step = manager.by_id.get(root_id)
608
- if not root_step:
609
- return
610
-
611
- original_label = getattr(root_step, "display_label", None)
612
- root_step.display_label = self._friendly_root_label(root_step, original_label)
613
- lines[0] = self._format_tree_line(root_step, ())
614
- if original_label is not None:
615
- root_step.display_label = original_label
616
-
617
- query = self._get_user_query()
618
- if query:
619
- lines.insert(1, f" {query}")
620
-
621
- def _coerce_step_event(self, event: dict[str, Any]) -> dict[str, Any] | None:
622
- """Coerce event to step event format.
623
-
624
- Args:
625
- event: Event dictionary.
626
-
627
- Returns:
628
- Step event dictionary or None.
629
- """
630
- metadata = event.get("metadata")
631
- if not isinstance(metadata, dict):
632
- return None
633
- if not isinstance(metadata.get("step_id"), str):
634
- return None
635
- return {
636
- "metadata": metadata,
637
- "status": event.get("status"),
638
- "task_state": event.get("task_state"),
639
- "content": event.get("content"),
640
- "task_id": event.get("task_id"),
641
- "context_id": event.get("context_id"),
642
- }
643
-
644
- def _format_tree_line(self, step: Any, branch_state: tuple[bool, ...]) -> str:
645
- """Format a tree line for a step.
646
-
647
- Args:
648
- step: Step object.
649
- branch_state: Branch state tuple.
650
-
651
- Returns:
652
- Formatted line string.
653
- """
654
- prefix = build_connector_prefix(branch_state)
655
- raw_label = normalise_display_label(getattr(step, "display_label", None))
656
- title, summary = self._split_label(raw_label)
657
- line = f"{prefix}{title}"
658
-
659
- if summary:
660
- line += f" — {self._truncate_summary(summary)}"
661
-
662
- badge = self._format_duration_badge(step)
663
- if badge:
664
- line += f" {badge}"
665
-
666
- glyph = glyph_for_status(getattr(step, "status_icon", None))
667
- failure_reason = getattr(step, "failure_reason", None)
668
- if glyph and glyph != "spinner":
669
- if failure_reason and glyph == "✗":
670
- line += f" {glyph} {failure_reason}"
671
- else:
672
- line += f" {glyph}"
673
- elif failure_reason:
674
- line += f" ✗ {failure_reason}"
675
-
676
- return line
677
-
678
- def _friendly_root_label(self, step: Any, fallback: str | None) -> str:
679
- """Generate friendly label for root step.
680
-
681
- Args:
682
- step: Step object.
683
- fallback: Fallback label string.
684
-
685
- Returns:
686
- Friendly label string.
687
- """
688
- agent_name = self.ctx.manifest_entry.get("agent_name") or (self.ctx.meta or {}).get("agent_name")
689
- agent_id = self.ctx.manifest_entry.get("agent_id") or getattr(step, "name", "")
690
-
691
- if not agent_name:
692
- return fallback or agent_id or ICON_AGENT
693
-
694
- parts = [ICON_AGENT, agent_name]
695
- if agent_id and agent_id != agent_name:
696
- parts.append(f"({agent_id})")
697
- return " ".join(parts)
698
-
699
- @staticmethod
700
- def _format_duration_badge(step: Any) -> str | None:
701
- """Format duration badge for a step.
702
-
703
- Args:
704
- step: Step object.
705
-
706
- Returns:
707
- Duration badge string or None.
708
- """
709
- duration_ms = getattr(step, "duration_ms", None)
710
- if duration_ms is None:
711
- return None
712
- try:
713
- duration_ms = int(duration_ms)
714
- except Exception:
715
- return None
716
-
717
- if duration_ms <= 0:
718
- payload = "<1ms"
719
- elif duration_ms >= 1000:
720
- payload = f"{duration_ms / 1000:.2f}s"
721
- else:
722
- payload = f"{duration_ms}ms"
723
-
724
- return f"[{payload}]"
725
-
726
- @staticmethod
727
- def _split_label(label: str) -> tuple[str, str | None]:
728
- """Split label into title and summary.
729
-
730
- Args:
731
- label: Label string.
732
-
733
- Returns:
734
- Tuple of (title, summary).
735
- """
736
- if " — " in label:
737
- title, summary = label.split(" — ", 1)
738
- return title.strip(), summary.strip()
739
- return label.strip(), None
740
-
741
- @staticmethod
742
- def _truncate_summary(summary: str, limit: int = 48) -> str:
743
- """Truncate summary to specified length.
744
-
745
- Args:
746
- summary: Summary string.
747
- limit: Maximum length.
748
-
749
- Returns:
750
- Truncated summary string.
751
- """
752
- summary = summary.strip()
753
- if len(summary) <= limit:
754
- return summary
755
- return summary[: limit - 1].rstrip() + "…"
756
-
757
- @staticmethod
758
- def _looks_like_uuid(value: str) -> bool:
759
- """Check if string looks like a UUID.
760
-
761
- Args:
762
- value: String to check.
763
-
764
- Returns:
765
- True if value looks like UUID.
766
- """
767
- stripped = value.replace("-", "").replace(" ", "")
768
- if len(stripped) not in {32, 36}:
769
- return False
770
- return all(ch in "0123456789abcdefABCDEF" for ch in stripped)
771
-
772
- @staticmethod
773
- def _format_duration_from_ms(value: Any) -> str | None:
774
- """Format duration from milliseconds.
775
-
776
- Args:
777
- value: Duration value in milliseconds.
778
-
779
- Returns:
780
- Formatted duration string or None.
781
- """
782
- try:
783
- if value is None:
784
- return None
785
- duration_ms = float(value)
786
- except Exception:
787
- return None
788
-
789
- if duration_ms <= 0:
790
- return "<1ms"
791
- if duration_ms < 1000:
792
- return f"{int(duration_ms)}ms"
793
- return f"{duration_ms / 1000:.2f}s"
794
-
795
- @staticmethod
796
- def _is_step_event(metadata: dict[str, Any]) -> bool:
797
- """Check if metadata represents a step event.
798
-
799
- Args:
800
- metadata: Event metadata dictionary.
801
-
802
- Returns:
803
- True if metadata represents a step event.
804
- """
805
- kind = metadata.get("kind")
806
- return kind in {"agent_step", "agent_thinking_step"}
807
-
808
- def _iter_step_candidates(
809
- self, event: dict[str, Any], metadata: dict[str, Any]
810
- ) -> Iterable[tuple[str, dict[str, Any]]]:
811
- """Iterate step candidates from event.
812
-
813
- Args:
814
- event: Event dictionary.
815
- metadata: Event metadata dictionary.
816
-
817
- Yields:
818
- Tuples of (step_name, step_info).
819
- """
820
- tool_info = metadata.get("tool_info") or {}
821
-
822
- yielded = False
823
- for candidate in self._iter_tool_call_candidates(tool_info):
824
- yielded = True
825
- yield candidate
826
-
827
- if yielded:
828
- return
829
-
830
- direct_tool = self._extract_direct_tool(tool_info)
831
- if direct_tool is not None:
832
- yield direct_tool
833
- return
834
-
835
- completed = self._extract_completed_name(event)
836
- if completed is not None:
837
- yield completed, {}
838
-
839
- @staticmethod
840
- def _iter_tool_call_candidates(
841
- tool_info: dict[str, Any],
842
- ) -> Iterable[tuple[str, dict[str, Any]]]:
843
- """Iterate tool call candidates from tool_info.
844
-
845
- Args:
846
- tool_info: Tool info dictionary.
847
-
848
- Yields:
849
- Tuples of (tool_name, tool_call_info).
850
- """
851
- tool_calls = tool_info.get("tool_calls")
852
- if isinstance(tool_calls, list):
853
- for call in tool_calls:
854
- name = call.get("name")
855
- if name:
856
- yield name, call
857
-
858
268
  @staticmethod
859
269
  def _extract_direct_tool(
860
270
  tool_info: dict[str, Any],
@@ -949,49 +359,6 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
949
359
  if duration is not None:
950
360
  step["duration"] = duration
951
361
 
952
- @staticmethod
953
- def _is_step_finished(metadata: dict[str, Any], event: dict[str, Any]) -> bool:
954
- """Check if step is finished.
955
-
956
- Args:
957
- metadata: Event metadata.
958
- event: Event dictionary.
959
-
960
- Returns:
961
- True if step is finished.
962
- """
963
- status = metadata.get("status")
964
- return status == "finished" or bool(event.get("final"))
965
-
966
- def _compute_step_duration(
967
- self, step: dict[str, Any], info: dict[str, Any], metadata: dict[str, Any]
968
- ) -> str | None:
969
- """Calculate a formatted duration string for a step if possible."""
970
- event_time = metadata.get("time")
971
- started_at = step.get("started_at")
972
- duration_value: float | None = None
973
-
974
- if isinstance(event_time, (int, float)) and isinstance(started_at, (int, float)):
975
- try:
976
- delta = float(event_time) - float(started_at)
977
- if delta >= 0:
978
- duration_value = delta
979
- except Exception:
980
- duration_value = None
981
-
982
- if duration_value is None:
983
- exec_time = info.get("execution_time")
984
- if isinstance(exec_time, (int, float)):
985
- duration_value = float(exec_time)
986
-
987
- if duration_value is None:
988
- return None
989
-
990
- try:
991
- return format_elapsed_time(duration_value)
992
- except Exception:
993
- return None
994
-
995
362
 
996
363
  def run_viewer_session(
997
364
  console: Console,