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