glaip-sdk 0.0.19__py3-none-any.whl → 0.0.20__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 (49) hide show
  1. glaip_sdk/_version.py +2 -2
  2. glaip_sdk/branding.py +27 -2
  3. glaip_sdk/cli/auth.py +93 -28
  4. glaip_sdk/cli/commands/__init__.py +2 -2
  5. glaip_sdk/cli/commands/agents.py +108 -21
  6. glaip_sdk/cli/commands/configure.py +141 -90
  7. glaip_sdk/cli/commands/mcps.py +81 -29
  8. glaip_sdk/cli/commands/models.py +4 -3
  9. glaip_sdk/cli/commands/tools.py +27 -14
  10. glaip_sdk/cli/commands/update.py +66 -0
  11. glaip_sdk/cli/config.py +13 -2
  12. glaip_sdk/cli/display.py +35 -26
  13. glaip_sdk/cli/io.py +14 -5
  14. glaip_sdk/cli/main.py +185 -73
  15. glaip_sdk/cli/pager.py +2 -1
  16. glaip_sdk/cli/resolution.py +4 -1
  17. glaip_sdk/cli/slash/__init__.py +3 -4
  18. glaip_sdk/cli/slash/agent_session.py +88 -36
  19. glaip_sdk/cli/slash/prompt.py +20 -48
  20. glaip_sdk/cli/slash/session.py +440 -189
  21. glaip_sdk/cli/transcript/__init__.py +71 -0
  22. glaip_sdk/cli/transcript/cache.py +338 -0
  23. glaip_sdk/cli/transcript/capture.py +278 -0
  24. glaip_sdk/cli/transcript/export.py +38 -0
  25. glaip_sdk/cli/transcript/launcher.py +79 -0
  26. glaip_sdk/cli/transcript/viewer.py +624 -0
  27. glaip_sdk/cli/update_notifier.py +29 -5
  28. glaip_sdk/cli/utils.py +256 -74
  29. glaip_sdk/client/agents.py +3 -1
  30. glaip_sdk/client/run_rendering.py +2 -2
  31. glaip_sdk/icons.py +19 -0
  32. glaip_sdk/models.py +6 -0
  33. glaip_sdk/rich_components.py +29 -1
  34. glaip_sdk/utils/__init__.py +1 -1
  35. glaip_sdk/utils/client_utils.py +6 -4
  36. glaip_sdk/utils/display.py +61 -32
  37. glaip_sdk/utils/rendering/formatting.py +6 -5
  38. glaip_sdk/utils/rendering/renderer/base.py +213 -66
  39. glaip_sdk/utils/rendering/renderer/debug.py +73 -16
  40. glaip_sdk/utils/rendering/renderer/panels.py +27 -15
  41. glaip_sdk/utils/rendering/renderer/progress.py +61 -38
  42. glaip_sdk/utils/serialization.py +5 -2
  43. glaip_sdk/utils/validation.py +1 -2
  44. {glaip_sdk-0.0.19.dist-info → glaip_sdk-0.0.20.dist-info}/METADATA +1 -1
  45. glaip_sdk-0.0.20.dist-info/RECORD +80 -0
  46. glaip_sdk/utils/rich_utils.py +0 -29
  47. glaip_sdk-0.0.19.dist-info/RECORD +0 -73
  48. {glaip_sdk-0.0.19.dist-info → glaip_sdk-0.0.20.dist-info}/WHEEL +0 -0
  49. {glaip_sdk-0.0.19.dist-info → glaip_sdk-0.0.20.dist-info}/entry_points.txt +0 -0
@@ -4,9 +4,51 @@ Authors:
4
4
  Raymond Christopher (raymond.christopher@gdplabs.id)
5
5
  """
6
6
 
7
- from typing import Any
7
+ from typing import TYPE_CHECKING, Any
8
8
 
9
- from glaip_sdk.utils.rich_utils import RICH_AVAILABLE
9
+ from glaip_sdk.branding import SUCCESS, SUCCESS_STYLE
10
+ from glaip_sdk.icons import ICON_AGENT
11
+
12
+ if TYPE_CHECKING: # pragma: no cover - import-time typing helpers
13
+ from rich.console import Console
14
+ from rich.text import Text
15
+
16
+ from glaip_sdk.rich_components import AIPPanel
17
+
18
+
19
+ def _check_rich_available() -> bool:
20
+ """Check if Rich and our custom components can be imported."""
21
+ try:
22
+ __import__("rich.console")
23
+ __import__("rich.text")
24
+ __import__("glaip_sdk.rich_components")
25
+ return True
26
+ except Exception:
27
+ return False
28
+
29
+
30
+ RICH_AVAILABLE = _check_rich_available()
31
+
32
+
33
+ def _create_console() -> "Console":
34
+ """Return a Console instance with lazy import to ease mocking."""
35
+ from rich.console import Console # Local import for test friendliness
36
+
37
+ return Console()
38
+
39
+
40
+ def _create_text(*args: Any, **kwargs: Any) -> "Text":
41
+ """Return a Text instance with lazy import to ease mocking."""
42
+ from rich.text import Text # Local import for test friendliness
43
+
44
+ return Text(*args, **kwargs)
45
+
46
+
47
+ def _create_panel(*args: Any, **kwargs: Any) -> "AIPPanel":
48
+ """Return an AIPPanel instance with lazy import to ease mocking."""
49
+ from glaip_sdk.rich_components import AIPPanel # Local import for test friendliness
50
+
51
+ return AIPPanel(*args, **kwargs)
10
52
 
11
53
 
12
54
  def print_agent_output(output: str, title: str = "Agent Output") -> None:
@@ -17,17 +59,11 @@ def print_agent_output(output: str, title: str = "Agent Output") -> None:
17
59
  title: Title for the output panel
18
60
  """
19
61
  if RICH_AVAILABLE:
20
- # Lazy import Rich components
21
- from rich.console import Console
22
- from rich.text import Text
23
-
24
- from glaip_sdk.rich_components import AIPPanel
25
-
26
- console = Console()
27
- panel = AIPPanel(
28
- Text(output, style="green"),
62
+ console = _create_console()
63
+ panel = _create_panel(
64
+ _create_text(output, style=SUCCESS),
29
65
  title=title,
30
- border_style="green",
66
+ border_style=SUCCESS,
31
67
  )
32
68
  console.print(panel)
33
69
  else:
@@ -36,7 +72,7 @@ def print_agent_output(output: str, title: str = "Agent Output") -> None:
36
72
  print("=" * (len(title) + 8))
37
73
 
38
74
 
39
- def print_agent_created(agent: Any, title: str = "🤖 Agent Created") -> None:
75
+ def print_agent_created(agent: Any, title: str = f"{ICON_AGENT} Agent Created") -> None:
40
76
  """Print agent creation success with rich formatting.
41
77
 
42
78
  Args:
@@ -44,21 +80,16 @@ def print_agent_created(agent: Any, title: str = "🤖 Agent Created") -> None:
44
80
  title: Title for the output panel
45
81
  """
46
82
  if RICH_AVAILABLE:
47
- # Lazy import Rich components
48
- from rich.console import Console
49
-
50
- from glaip_sdk.rich_components import AIPPanel
51
-
52
- console = Console()
53
- panel = AIPPanel(
54
- f"[green]✅ Agent '{agent.name}' created successfully![/green]\n\n"
83
+ console = _create_console()
84
+ panel = _create_panel(
85
+ f"[{SUCCESS_STYLE}]✅ Agent '{agent.name}' created successfully![/]\n\n"
55
86
  f"ID: {agent.id}\n"
56
87
  f"Model: {getattr(agent, 'model', 'N/A')}\n"
57
88
  f"Type: {getattr(agent, 'type', 'config')}\n"
58
89
  f"Framework: {getattr(agent, 'framework', 'langchain')}\n"
59
90
  f"Version: {getattr(agent, 'version', '1.0')}",
60
91
  title=title,
61
- border_style="green",
92
+ border_style=SUCCESS,
62
93
  )
63
94
  console.print(panel)
64
95
  else:
@@ -77,11 +108,10 @@ def print_agent_updated(agent: Any) -> None:
77
108
  agent: The updated agent object
78
109
  """
79
110
  if RICH_AVAILABLE:
80
- # Lazy import Rich components
81
- from rich.console import Console
82
-
83
- console = Console()
84
- console.print(f"[green]✅ Agent '{agent.name}' updated successfully[/green]")
111
+ console = _create_console()
112
+ console.print(
113
+ f"[{SUCCESS_STYLE}]✅ Agent '{agent.name}' updated successfully[/]"
114
+ )
85
115
  else:
86
116
  print(f"✅ Agent '{agent.name}' updated successfully")
87
117
 
@@ -93,10 +123,9 @@ def print_agent_deleted(agent_id: str) -> None:
93
123
  agent_id: The deleted agent's ID
94
124
  """
95
125
  if RICH_AVAILABLE:
96
- # Lazy import Rich components
97
- from rich.console import Console
98
-
99
- console = Console()
100
- console.print(f"[green]✅ Agent deleted successfully (ID: {agent_id})[/green]")
126
+ console = _create_console()
127
+ console.print(
128
+ f"[{SUCCESS_STYLE}]✅ Agent deleted successfully (ID: {agent_id})[/]"
129
+ )
101
130
  else:
102
131
  print(f"✅ Agent deleted successfully (ID: {agent_id})")
@@ -6,11 +6,14 @@ Authors:
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import json
9
10
  import re
10
11
  import time
11
12
  from collections.abc import Callable
12
13
  from typing import Any
13
14
 
15
+ from glaip_sdk.icons import ICON_AGENT_STEP, ICON_DELEGATE, ICON_TOOL_STEP
16
+
14
17
  # Constants for argument formatting
15
18
  DEFAULT_ARGS_MAX_LEN = 100
16
19
  IMPORTANT_PARAMETER_KEYS = [
@@ -127,8 +130,6 @@ def pretty_args(args: dict | None, max_len: int = DEFAULT_ARGS_MAX_LEN) -> str:
127
130
 
128
131
  # Convert to JSON string and truncate if needed
129
132
  try:
130
- import json
131
-
132
133
  args_str = json.dumps(masked_args, ensure_ascii=False, separators=(",", ":"))
133
134
  return _truncate_string(args_str, max_len)
134
135
  except (TypeError, ValueError, Exception):
@@ -174,11 +175,11 @@ def get_spinner_char() -> str:
174
175
  def get_step_icon(step_kind: str) -> str:
175
176
  """Get the appropriate icon for a step kind."""
176
177
  if step_kind == "tool":
177
- return "⚙️" # Gear emoji for tool
178
+ return ICON_TOOL_STEP
178
179
  if step_kind == "delegate":
179
- return "🤝" # Handshake for delegate
180
+ return ICON_DELEGATE
180
181
  if step_kind == "agent":
181
- return "🧠" # Brain emoji for agent
182
+ return ICON_AGENT_STEP
182
183
  return ""
183
184
 
184
185
 
@@ -8,16 +8,20 @@ from __future__ import annotations
8
8
 
9
9
  import json
10
10
  import logging
11
- from dataclasses import dataclass
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime, timezone
12
13
  from time import monotonic
13
14
  from typing import Any
14
15
 
16
+ from rich.align import Align
15
17
  from rich.console import Console as RichConsole
16
18
  from rich.console import Group
17
19
  from rich.live import Live
18
20
  from rich.markdown import Markdown
21
+ from rich.spinner import Spinner
19
22
  from rich.text import Text
20
23
 
24
+ from glaip_sdk.icons import ICON_AGENT, ICON_AGENT_STEP, ICON_DELEGATE, ICON_TOOL_STEP
21
25
  from glaip_sdk.rich_components import AIPPanel
22
26
  from glaip_sdk.utils.rendering.formatting import (
23
27
  format_main_title,
@@ -49,6 +53,25 @@ logger = logging.getLogger("glaip_sdk.run_renderer")
49
53
  LESS_THAN_1MS = "[<1ms]"
50
54
 
51
55
 
56
+ def _coerce_received_at(value: Any) -> datetime | None:
57
+ """Coerce a received_at value to an aware datetime if possible."""
58
+ if value is None:
59
+ return None
60
+
61
+ if isinstance(value, datetime):
62
+ return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
63
+
64
+ if isinstance(value, str):
65
+ try:
66
+ normalised = value.replace("Z", "+00:00")
67
+ dt = datetime.fromisoformat(normalised)
68
+ except ValueError:
69
+ return None
70
+ return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
71
+
72
+ return None
73
+
74
+
52
75
  @dataclass
53
76
  class RendererState:
54
77
  """Internal state for the renderer."""
@@ -56,10 +79,13 @@ class RendererState:
56
79
  buffer: list[str] | None = None
57
80
  final_text: str = ""
58
81
  streaming_started_at: float | None = None
59
- printed_final_panel: bool = False
82
+ printed_final_output: bool = False
60
83
  finalizing_ui: bool = False
61
84
  final_duration_seconds: float | None = None
62
85
  final_duration_text: str | None = None
86
+ events: list[dict[str, Any]] = field(default_factory=list)
87
+ meta: dict[str, Any] = field(default_factory=dict)
88
+ streaming_started_event_ts: datetime | None = None
63
89
 
64
90
  def __post_init__(self) -> None:
65
91
  """Initialize renderer state after dataclass creation.
@@ -127,7 +153,10 @@ class RichStreamRenderer:
127
153
 
128
154
  # Set up initial state
129
155
  self._started_at = monotonic()
130
- self.stream_processor.streaming_started_at = self._started_at
156
+ try:
157
+ self.state.meta = json.loads(json.dumps(meta))
158
+ except Exception:
159
+ self.state.meta = dict(meta)
131
160
 
132
161
  # Print compact header and user request (parity with old renderer)
133
162
  self._render_header(meta)
@@ -147,7 +176,7 @@ class RichStreamRenderer:
147
176
 
148
177
  def _build_header_parts(self, meta: dict[str, Any]) -> list[str]:
149
178
  """Build header text parts from metadata."""
150
- parts: list[str] = ["🤖"]
179
+ parts: list[str] = [ICON_AGENT]
151
180
  agent_name = meta.get("agent_name", "agent")
152
181
  if agent_name:
153
182
  parts.append(agent_name)
@@ -193,29 +222,71 @@ class RichStreamRenderer:
193
222
  )
194
223
  )
195
224
 
225
+ def _ensure_streaming_started_baseline(self, timestamp: float) -> None:
226
+ """Synchronize streaming start state across renderer components."""
227
+ self.state.streaming_started_at = timestamp
228
+ self.stream_processor.streaming_started_at = timestamp
229
+ self._started_at = timestamp
230
+
196
231
  def on_event(self, ev: dict[str, Any]) -> None:
197
232
  """Handle streaming events from the backend."""
198
- # Reset event tracking
233
+ received_at = self._resolve_received_timestamp(ev)
234
+ self._capture_event(ev, received_at)
199
235
  self.stream_processor.reset_event_tracking()
200
236
 
201
- # Track streaming start time
202
- if self.state.streaming_started_at is None:
203
- self.state.streaming_started_at = monotonic()
237
+ self._sync_stream_start(ev, received_at)
204
238
 
205
- # Extract event metadata
206
239
  metadata = self.stream_processor.extract_event_metadata(ev)
207
- kind = metadata["kind"]
208
- context_id = metadata["context_id"]
209
- content = metadata["content"]
240
+ self.stream_processor.update_timing(metadata["context_id"])
210
241
 
211
- # Render debug event panel if verbose mode is enabled
212
- if self.verbose:
213
- render_debug_event(ev, self.console, self.state.streaming_started_at)
242
+ self._maybe_render_debug(ev, received_at)
243
+ self._dispatch_event(ev, metadata)
244
+
245
+ def _resolve_received_timestamp(self, ev: dict[str, Any]) -> datetime:
246
+ """Return the timestamp an event was received, normalising inputs."""
247
+ received_at = _coerce_received_at(ev.get("received_at"))
248
+ if received_at is None:
249
+ received_at = datetime.now(timezone.utc)
250
+
251
+ if self.state.streaming_started_event_ts is None:
252
+ self.state.streaming_started_event_ts = received_at
253
+
254
+ return received_at
214
255
 
215
- # Update timing
216
- self.stream_processor.update_timing(context_id)
256
+ def _sync_stream_start(
257
+ self, ev: dict[str, Any], received_at: datetime | None
258
+ ) -> None:
259
+ """Ensure renderer and stream processor share a streaming baseline."""
260
+ baseline = self.state.streaming_started_at
261
+ if baseline is None:
262
+ baseline = monotonic()
263
+ self._ensure_streaming_started_baseline(baseline)
264
+ elif getattr(self.stream_processor, "streaming_started_at", None) is None:
265
+ self._ensure_streaming_started_baseline(baseline)
266
+
267
+ if ev.get("status") == "streaming_started":
268
+ self.state.streaming_started_event_ts = received_at
269
+ self._ensure_streaming_started_baseline(monotonic())
270
+
271
+ def _maybe_render_debug(
272
+ self, ev: dict[str, Any], received_at: datetime
273
+ ) -> None: # pragma: no cover - guard rails for verbose mode
274
+ """Render debug view when verbose mode is enabled."""
275
+ if not self.verbose:
276
+ return
277
+
278
+ render_debug_event(
279
+ ev,
280
+ self.console,
281
+ received_ts=received_at,
282
+ baseline_ts=self.state.streaming_started_event_ts,
283
+ )
284
+
285
+ def _dispatch_event(self, ev: dict[str, Any], metadata: dict[str, Any]) -> None:
286
+ """Route events to the appropriate renderer handlers."""
287
+ kind = metadata["kind"]
288
+ content = metadata["content"]
217
289
 
218
- # Handle different event types
219
290
  if kind == "status":
220
291
  self._handle_status_event(ev)
221
292
  elif kind == "content":
@@ -225,14 +296,13 @@ class RichStreamRenderer:
225
296
  elif kind in {"agent_step", "agent_thinking_step"}:
226
297
  self._handle_agent_step_event(ev)
227
298
  else:
228
- # Update live display for unhandled events
229
299
  self._ensure_live()
230
300
 
231
301
  def _handle_status_event(self, ev: dict[str, Any]) -> None:
232
302
  """Handle status events."""
233
303
  status = ev.get("status")
234
304
  if status == "streaming_started":
235
- self.state.streaming_started_at = monotonic()
305
+ return
236
306
 
237
307
  def _handle_content_event(self, content: str) -> None:
238
308
  """Handle content streaming events."""
@@ -252,16 +322,7 @@ class RichStreamRenderer:
252
322
  self._update_final_duration(meta_payload.get("time"))
253
323
 
254
324
  self._ensure_live()
255
-
256
- # In verbose mode, show the final result in a panel
257
- if self.verbose and content.strip():
258
- final_panel = create_final_panel(
259
- content,
260
- title=self._final_panel_title(),
261
- theme=self.cfg.theme,
262
- )
263
- self.console.print(final_panel)
264
- self.state.printed_final_panel = True
325
+ self._print_final_panel_if_needed()
265
326
 
266
327
  def _handle_agent_step_event(self, ev: dict[str, Any]) -> None:
267
328
  """Handle agent step events."""
@@ -307,17 +368,22 @@ class RichStreamRenderer:
307
368
  self._shutdown_live()
308
369
 
309
370
  def _print_final_panel_if_needed(self) -> None:
310
- """Print final result panel if verbose mode and content available."""
311
- if self.verbose and not self.state.printed_final_panel:
312
- body = ("".join(self.state.buffer) or "").strip()
313
- if body:
314
- final_panel = create_final_panel(
315
- body,
316
- title=self._final_panel_title(),
317
- theme=self.cfg.theme,
318
- )
319
- self.console.print(final_panel)
320
- self.state.printed_final_panel = True
371
+ """Print final result when configuration requires it."""
372
+ if self.state.printed_final_output:
373
+ return
374
+
375
+ body = (self.state.final_text or "".join(self.state.buffer) or "").strip()
376
+ if not body:
377
+ return
378
+
379
+ if self.verbose:
380
+ final_panel = create_final_panel(
381
+ body,
382
+ title=self._final_panel_title(),
383
+ theme=self.cfg.theme,
384
+ )
385
+ self.console.print(final_panel)
386
+ self.state.printed_final_output = True
321
387
 
322
388
  def on_complete(self, stats: RunStats) -> None:
323
389
  """Handle completion event."""
@@ -348,7 +414,7 @@ class RichStreamRenderer:
348
414
  # Stop live display
349
415
  self._stop_live_display()
350
416
 
351
- # Print final panel if needed
417
+ # Render final output based on configuration
352
418
  self._print_final_panel_if_needed()
353
419
 
354
420
  def _ensure_live(self) -> None:
@@ -469,6 +535,37 @@ class RichStreamRenderer:
469
535
  if self.cfg.live:
470
536
  self._ensure_live()
471
537
 
538
+ # ------------------------------------------------------------------
539
+ # Transcript helpers
540
+ # ------------------------------------------------------------------
541
+ def _capture_event(
542
+ self, ev: dict[str, Any], received_at: datetime | None = None
543
+ ) -> None:
544
+ """Capture a deep copy of SSE events for transcript replay."""
545
+ try:
546
+ captured = json.loads(json.dumps(ev))
547
+ except Exception:
548
+ captured = ev
549
+
550
+ if received_at is not None:
551
+ try:
552
+ captured["received_at"] = received_at.isoformat()
553
+ except Exception:
554
+ try:
555
+ captured["received_at"] = str(received_at)
556
+ except Exception:
557
+ captured["received_at"] = repr(received_at)
558
+
559
+ self.state.events.append(captured)
560
+
561
+ def get_aggregated_output(self) -> str:
562
+ """Return the concatenated assistant output collected so far."""
563
+ return ("".join(self.state.buffer or [])).strip()
564
+
565
+ def get_transcript_events(self) -> list[dict[str, Any]]:
566
+ """Return captured SSE events."""
567
+ return list(self.state.events)
568
+
472
569
  def _maybe_insert_thinking_gap(
473
570
  self, task_id: str | None, context_id: str | None
474
571
  ) -> None:
@@ -993,11 +1090,11 @@ class RichStreamRenderer:
993
1090
  def _get_step_icon(self, step_kind: str) -> str:
994
1091
  """Get icon for step kind."""
995
1092
  if step_kind == "tool":
996
- return "⚙️"
1093
+ return ICON_TOOL_STEP
997
1094
  elif step_kind == "delegate":
998
- return "🤝"
1095
+ return ICON_DELEGATE
999
1096
  elif step_kind == "agent":
1000
- return "🧠"
1097
+ return ICON_AGENT_STEP
1001
1098
  return ""
1002
1099
 
1003
1100
  def _format_step_status(self, step: Step) -> str:
@@ -1049,30 +1146,64 @@ class RichStreamRenderer:
1049
1146
  running_by_ctx.setdefault(key, []).append(st)
1050
1147
  return running_by_ctx
1051
1148
 
1052
- def _render_steps_text(self) -> Text:
1149
+ def _is_parallel_tool(
1150
+ self,
1151
+ step: Step,
1152
+ running_by_ctx: dict[tuple[str | None, str | None], list],
1153
+ ) -> bool:
1154
+ """Return True if multiple tools are running in the same context."""
1155
+ key = (step.task_id, step.context_id)
1156
+ return len(running_by_ctx.get(key, [])) > 1
1157
+
1158
+ def _compose_step_renderable(
1159
+ self,
1160
+ step: Step,
1161
+ running_by_ctx: dict[tuple[str | None, str | None], list],
1162
+ ) -> Any:
1163
+ """Compose a single renderable for the steps panel."""
1164
+ finished = is_step_finished(step)
1165
+ status_br = self._format_step_status(step)
1166
+ display_name = self._get_step_display_name(step)
1167
+
1168
+ if (
1169
+ not finished
1170
+ and step.kind == "tool"
1171
+ and self._is_parallel_tool(step, running_by_ctx)
1172
+ ):
1173
+ status_br = status_br.replace("]", " 🔄]")
1174
+
1175
+ icon = self._get_step_icon(step.kind)
1176
+ text_line = Text(style="dim")
1177
+ text_line.append(icon)
1178
+ text_line.append(" ")
1179
+ text_line.append(display_name)
1180
+ if status_br:
1181
+ text_line.append(" ")
1182
+ text_line.append(status_br)
1183
+ if finished:
1184
+ text_line.append(" ✓")
1185
+
1186
+ if finished:
1187
+ return text_line
1188
+
1189
+ spinner = Spinner("dots", text=text_line, style="dim")
1190
+ return Align.left(spinner)
1191
+
1192
+ def _render_steps_text(self) -> Any:
1053
1193
  """Render the steps panel content."""
1054
1194
  if not (self.steps.order or self.steps.children):
1055
1195
  return Text("No steps yet", style="dim")
1056
1196
 
1057
1197
  running_by_ctx = self._check_parallel_tools()
1058
- lines: list[str] = []
1059
-
1198
+ renderables: list[Any] = []
1060
1199
  for sid in self.steps.order:
1061
- st = self.steps.by_id[sid]
1062
- status_br = self._format_step_status(st)
1063
- display_name = self._get_step_display_name(st)
1064
- tail = " ✓" if is_step_finished(st) else ""
1065
-
1066
- # Add parallel indicator for running tools
1067
- if st.kind == "tool" and not is_step_finished(st):
1068
- key = (st.task_id, st.context_id)
1069
- if len(running_by_ctx.get(key, [])) > 1:
1070
- status_br = status_br.replace("]", " 🔄]")
1200
+ line = self._compose_step_renderable(self.steps.by_id[sid], running_by_ctx)
1201
+ renderables.append(line)
1071
1202
 
1072
- icon = self._get_step_icon(st.kind)
1073
- lines.append(f"{icon} {display_name} {status_br}{tail}")
1203
+ if not renderables:
1204
+ return Text("No steps yet", style="dim")
1074
1205
 
1075
- return Text("\n".join(lines), style="dim")
1206
+ return Group(*renderables)
1076
1207
 
1077
1208
  def _should_skip_finished_panel(self, sid: str, status: str) -> bool:
1078
1209
  """Check if a finished panel should be skipped."""
@@ -1154,18 +1285,27 @@ class RichStreamRenderer:
1154
1285
  return self._format_elapsed_time(dur) if isinstance(dur, int | float) else None
1155
1286
 
1156
1287
  def _process_running_tool_panel(
1157
- self, title: str, meta: dict[str, Any], body: str
1158
- ) -> tuple[str, str]:
1288
+ self,
1289
+ title: str,
1290
+ meta: dict[str, Any],
1291
+ body: str,
1292
+ *,
1293
+ include_spinner: bool = False,
1294
+ ) -> tuple[str, str] | tuple[str, str, str | None]:
1159
1295
  """Process a running tool panel."""
1160
1296
  elapsed_str = self._calculate_elapsed_time(meta)
1161
1297
  adjusted_title = f"{title} · {elapsed_str}"
1162
1298
  chip = f"⏱ {elapsed_str}"
1299
+ spinner_message: str | None = None
1163
1300
 
1164
- if not body:
1165
- body = chip
1301
+ if not body.strip():
1302
+ body = ""
1303
+ spinner_message = f"{title} running... {elapsed_str}"
1166
1304
  else:
1167
1305
  body = f"{body}\n\n{chip}"
1168
1306
 
1307
+ if include_spinner:
1308
+ return adjusted_title, body, spinner_message
1169
1309
  return adjusted_title, body
1170
1310
 
1171
1311
  def _process_finished_tool_panel(self, title: str, meta: dict[str, Any]) -> str:
@@ -1188,8 +1328,12 @@ class RichStreamRenderer:
1188
1328
  body = "".join(chunks)
1189
1329
  adjusted_title = title
1190
1330
 
1331
+ spinner_message: str | None = None
1332
+
1191
1333
  if status == "running":
1192
- adjusted_title, body = self._process_running_tool_panel(title, meta, body)
1334
+ adjusted_title, body, spinner_message = self._process_running_tool_panel(
1335
+ title, meta, body, include_spinner=True
1336
+ )
1193
1337
  elif status == "finished":
1194
1338
  adjusted_title = self._process_finished_tool_panel(title, meta)
1195
1339
 
@@ -1199,10 +1343,13 @@ class RichStreamRenderer:
1199
1343
  status=status,
1200
1344
  theme=self.cfg.theme,
1201
1345
  is_delegation=is_delegation,
1346
+ spinner_message=spinner_message,
1202
1347
  )
1203
1348
 
1204
1349
  def _render_tool_panels(self) -> list[AIPPanel]:
1205
1350
  """Render tool execution output panels."""
1351
+ if not getattr(self.cfg, "show_delegate_tool_panels", False):
1352
+ return []
1206
1353
  panels: list[AIPPanel] = []
1207
1354
  for sid in self.tool_order:
1208
1355
  meta = self.tool_panels.get(sid) or {}