glaip-sdk 0.0.4__py3-none-any.whl → 0.0.5__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 (47) hide show
  1. glaip_sdk/__init__.py +5 -5
  2. glaip_sdk/branding.py +18 -17
  3. glaip_sdk/cli/__init__.py +1 -1
  4. glaip_sdk/cli/agent_config.py +82 -0
  5. glaip_sdk/cli/commands/__init__.py +3 -3
  6. glaip_sdk/cli/commands/agents.py +570 -673
  7. glaip_sdk/cli/commands/configure.py +2 -2
  8. glaip_sdk/cli/commands/mcps.py +148 -143
  9. glaip_sdk/cli/commands/models.py +1 -1
  10. glaip_sdk/cli/commands/tools.py +250 -179
  11. glaip_sdk/cli/display.py +244 -0
  12. glaip_sdk/cli/io.py +106 -0
  13. glaip_sdk/cli/main.py +14 -18
  14. glaip_sdk/cli/resolution.py +59 -0
  15. glaip_sdk/cli/utils.py +305 -264
  16. glaip_sdk/cli/validators.py +235 -0
  17. glaip_sdk/client/__init__.py +3 -224
  18. glaip_sdk/client/agents.py +631 -191
  19. glaip_sdk/client/base.py +66 -4
  20. glaip_sdk/client/main.py +226 -0
  21. glaip_sdk/client/mcps.py +143 -18
  22. glaip_sdk/client/tools.py +146 -11
  23. glaip_sdk/config/constants.py +10 -1
  24. glaip_sdk/models.py +42 -2
  25. glaip_sdk/rich_components.py +29 -0
  26. glaip_sdk/utils/__init__.py +18 -171
  27. glaip_sdk/utils/agent_config.py +181 -0
  28. glaip_sdk/utils/client_utils.py +159 -79
  29. glaip_sdk/utils/display.py +100 -0
  30. glaip_sdk/utils/general.py +94 -0
  31. glaip_sdk/utils/import_export.py +140 -0
  32. glaip_sdk/utils/rendering/formatting.py +6 -1
  33. glaip_sdk/utils/rendering/renderer/__init__.py +67 -8
  34. glaip_sdk/utils/rendering/renderer/base.py +340 -247
  35. glaip_sdk/utils/rendering/renderer/debug.py +3 -2
  36. glaip_sdk/utils/rendering/renderer/panels.py +11 -10
  37. glaip_sdk/utils/rendering/steps.py +1 -1
  38. glaip_sdk/utils/resource_refs.py +192 -0
  39. glaip_sdk/utils/rich_utils.py +29 -0
  40. glaip_sdk/utils/serialization.py +285 -0
  41. glaip_sdk/utils/validation.py +273 -0
  42. {glaip_sdk-0.0.4.dist-info → glaip_sdk-0.0.5.dist-info}/METADATA +6 -5
  43. glaip_sdk-0.0.5.dist-info/RECORD +55 -0
  44. glaip_sdk/cli/commands/init.py +0 -93
  45. glaip_sdk-0.0.4.dist-info/RECORD +0 -41
  46. {glaip_sdk-0.0.4.dist-info → glaip_sdk-0.0.5.dist-info}/WHEEL +0 -0
  47. {glaip_sdk-0.0.4.dist-info → glaip_sdk-0.0.5.dist-info}/entry_points.txt +0 -0
@@ -16,9 +16,9 @@ from rich.console import Console as RichConsole
16
16
  from rich.console import Group
17
17
  from rich.live import Live
18
18
  from rich.markdown import Markdown
19
- from rich.panel import Panel
20
19
  from rich.text import Text
21
20
 
21
+ from glaip_sdk.rich_components import AIPPanel
22
22
  from glaip_sdk.utils.rendering.formatting import (
23
23
  format_main_title,
24
24
  get_spinner_char,
@@ -26,6 +26,7 @@ from glaip_sdk.utils.rendering.formatting import (
26
26
  )
27
27
  from glaip_sdk.utils.rendering.models import RunStats
28
28
  from glaip_sdk.utils.rendering.renderer.config import RendererConfig
29
+ from glaip_sdk.utils.rendering.renderer.debug import render_debug_event
29
30
  from glaip_sdk.utils.rendering.renderer.panels import (
30
31
  create_final_panel,
31
32
  create_main_panel,
@@ -41,8 +42,6 @@ from glaip_sdk.utils.rendering.renderer.progress import (
41
42
  from glaip_sdk.utils.rendering.renderer.stream import StreamProcessor
42
43
  from glaip_sdk.utils.rendering.steps import StepManager
43
44
 
44
- from .debug import render_debug_event
45
-
46
45
  # Configure logger
47
46
  logger = logging.getLogger("glaip_sdk.run_renderer")
48
47
 
@@ -143,7 +142,7 @@ class RichStreamRenderer:
143
142
  )
144
143
  if query:
145
144
  self.console.print(
146
- Panel(
145
+ AIPPanel(
147
146
  Markdown(f"**Query:** {query}"),
148
147
  title="User Request",
149
148
  border_style="yellow",
@@ -225,7 +224,7 @@ class RichStreamRenderer:
225
224
  # Update live display
226
225
  self._ensure_live()
227
226
 
228
- def on_complete(self, stats: RunStats):
227
+ def on_complete(self, _stats: RunStats):
229
228
  """Handle completion event."""
230
229
  self.state.finalizing_ui = True
231
230
 
@@ -253,6 +252,19 @@ class RichStreamRenderer:
253
252
  self.live.stop()
254
253
  self.live = None
255
254
 
255
+ # If no explicit final_response was printed, but we have buffered content,
256
+ # print a final result panel so users still see the outcome (especially in --verbose).
257
+ try:
258
+ if self.verbose and not self.state.printed_final_panel:
259
+ body = ("".join(self.state.buffer) or "").strip()
260
+ if body:
261
+ final_panel = create_final_panel(body, theme=self.cfg.theme)
262
+ self.console.print(final_panel)
263
+ self.state.printed_final_panel = True
264
+ except Exception:
265
+ # Non-fatal; renderer best-effort
266
+ pass
267
+
256
268
  def _ensure_live(self):
257
269
  """Ensure live display is updated."""
258
270
  # Lazily create Live if needed
@@ -270,7 +282,13 @@ class RichStreamRenderer:
270
282
  if self.live:
271
283
  panels = [self._render_main_panel()]
272
284
  steps_renderable = self._render_steps_text()
273
- panels.append(Panel(steps_renderable, title="Steps", border_style="blue"))
285
+ panels.append(
286
+ AIPPanel(
287
+ steps_renderable,
288
+ title="Steps",
289
+ border_style="blue",
290
+ )
291
+ )
274
292
  panels.extend(self._render_tool_panels())
275
293
  self.live.update(Group(*panels))
276
294
 
@@ -286,79 +304,81 @@ class RichStreamRenderer:
286
304
  # Implementation would track thinking states
287
305
  pass
288
306
 
289
- def _handle_agent_step(
307
+ def _ensure_tool_panel(
308
+ self, name: str, args: Any, task_id: str, context_id: str
309
+ ) -> str:
310
+ """Ensure a tool panel exists and return its ID."""
311
+ formatted_title = format_tool_title(name)
312
+ is_delegation = is_delegation_tool(name)
313
+ tool_sid = f"tool_{name}_{task_id}_{context_id}"
314
+
315
+ if tool_sid not in self.tool_panels:
316
+ self.tool_panels[tool_sid] = {
317
+ "title": formatted_title,
318
+ "status": "running",
319
+ "started_at": monotonic(),
320
+ "server_started_at": self.stream_processor.server_elapsed_time,
321
+ "chunks": [],
322
+ "args": args or {},
323
+ "output": None,
324
+ "is_delegation": is_delegation,
325
+ }
326
+ # Add Args section once
327
+ if args:
328
+ try:
329
+ args_content = (
330
+ "**Args:**\n```json\n"
331
+ + json.dumps(args, indent=2)
332
+ + "\n```\n\n"
333
+ )
334
+ except Exception:
335
+ args_content = f"**Args:**\n{args}\n\n"
336
+ self.tool_panels[tool_sid]["chunks"].append(args_content)
337
+ self.tool_order.append(tool_sid)
338
+
339
+ return tool_sid
340
+
341
+ def _start_tool_step(
290
342
  self,
291
- event: dict[str, Any],
292
- tool_name: str | None,
343
+ task_id: str,
344
+ context_id: str,
345
+ tool_name: str,
293
346
  tool_args: Any,
294
- tool_out: Any,
295
- tool_calls_info: list,
347
+ _tool_sid: str,
296
348
  ):
297
- """Handle agent step event."""
298
- metadata = event.get("metadata", {})
299
- task_id = event.get("task_id")
300
- context_id = event.get("context_id")
301
- content = event.get("content", "")
349
+ """Start or get a step for a tool."""
350
+ if is_delegation_tool(tool_name):
351
+ st = self.steps.start_or_get(
352
+ task_id=task_id,
353
+ context_id=context_id,
354
+ kind="delegate",
355
+ name=tool_name,
356
+ args=tool_args,
357
+ )
358
+ else:
359
+ st = self.steps.start_or_get(
360
+ task_id=task_id,
361
+ context_id=context_id,
362
+ kind="tool",
363
+ name=tool_name,
364
+ args=tool_args,
365
+ )
302
366
 
303
- def ensure_tool_panel(name: str, args: Any) -> str:
304
- formatted_title = format_tool_title(name)
305
- is_delegation = is_delegation_tool(name)
306
- tool_sid = f"tool_{name}_{task_id}_{context_id}"
307
- if tool_sid not in self.tool_panels:
308
- self.tool_panels[tool_sid] = {
309
- "title": formatted_title,
310
- "status": "running",
311
- "started_at": monotonic(),
312
- "server_started_at": self.stream_processor.server_elapsed_time,
313
- "chunks": [],
314
- "args": args or {},
315
- "output": None,
316
- "is_delegation": is_delegation,
317
- }
318
- # Add Args section once
319
- if args:
320
- try:
321
- args_content = (
322
- "**Args:**\n```json\n"
323
- + json.dumps(args, indent=2)
324
- + "\n```\n\n"
325
- )
326
- except Exception:
327
- args_content = f"**Args:**\n{args}\n\n"
328
- self.tool_panels[tool_sid]["chunks"].append(args_content)
329
- self.tool_order.append(tool_sid)
330
- return tool_sid
367
+ # Record server start time for this step if available
368
+ if st and self.stream_processor.server_elapsed_time is not None:
369
+ self._step_server_start_times[st.step_id] = (
370
+ self.stream_processor.server_elapsed_time
371
+ )
331
372
 
332
- # Create steps and panels for the primary tool
333
- if tool_name:
334
- tool_sid = ensure_tool_panel(tool_name, tool_args)
335
- # Start or get a step for this tool
336
- if is_delegation_tool(tool_name):
337
- st = self.steps.start_or_get(
338
- task_id=task_id,
339
- context_id=context_id,
340
- kind="delegate",
341
- name=tool_name,
342
- args=tool_args,
343
- )
344
- else:
345
- st = self.steps.start_or_get(
346
- task_id=task_id,
347
- context_id=context_id,
348
- kind="tool",
349
- name=tool_name,
350
- args=tool_args,
351
- )
352
- # Record server start time for this step if available
353
- if st and self.stream_processor.server_elapsed_time is not None:
354
- self._step_server_start_times[st.step_id] = (
355
- self.stream_processor.server_elapsed_time
356
- )
373
+ return st
357
374
 
358
- # Handle additional tool calls (avoid duplicates)
375
+ def _process_additional_tool_calls(
376
+ self, tool_calls_info: list, tool_name: str, task_id: str, context_id: str
377
+ ):
378
+ """Process additional tool calls to avoid duplicates."""
359
379
  for call_name, call_args, _ in tool_calls_info or []:
360
380
  if call_name and call_name != tool_name:
361
- ensure_tool_panel(call_name, call_args)
381
+ self._ensure_tool_panel(call_name, call_args, task_id, context_id)
362
382
  if is_delegation_tool(call_name):
363
383
  st2 = self.steps.start_or_get(
364
384
  task_id=task_id,
@@ -380,141 +400,205 @@ class RichStreamRenderer:
380
400
  self.stream_processor.server_elapsed_time
381
401
  )
382
402
 
383
- # Check completion status hints
403
+ def _detect_tool_completion(
404
+ self, metadata: dict, content: str
405
+ ) -> tuple[bool, str | None, Any]:
406
+ """Detect if a tool has completed and return completion info."""
384
407
  tool_info = metadata.get("tool_info", {}) if isinstance(metadata, dict) else {}
385
- is_tool_finished = False
386
- finished_tool_name: str | None = None
387
- finished_tool_output: Any = None
388
408
 
389
409
  if tool_info.get("status") == "finished" and tool_info.get("name"):
390
- is_tool_finished = True
391
- finished_tool_name = tool_info.get("name")
392
- finished_tool_output = tool_info.get("output")
410
+ return True, tool_info.get("name"), tool_info.get("output")
393
411
  elif content and isinstance(content, str) and content.startswith("Completed "):
394
412
  # content like "Completed google_serper"
395
413
  tname = content.replace("Completed ", "").strip()
396
414
  if tname:
397
- is_tool_finished = True
398
- finished_tool_name = tname
399
- if tool_info.get("name") == tname:
400
- finished_tool_output = tool_info.get("output")
415
+ output = (
416
+ tool_info.get("output") if tool_info.get("name") == tname else None
417
+ )
418
+ return True, tname, output
401
419
  elif metadata.get("status") == "finished" and tool_info.get("name"):
402
- is_tool_finished = True
403
- finished_tool_name = tool_info.get("name")
404
- finished_tool_output = tool_info.get("output")
420
+ return True, tool_info.get("name"), tool_info.get("output")
405
421
 
406
- if is_tool_finished and finished_tool_name:
407
- # Update panel
408
- tool_sid = f"tool_{finished_tool_name}_{task_id}_{context_id}"
409
- if tool_sid in self.tool_panels:
410
- meta = self.tool_panels[tool_sid]
411
- prev_status = meta.get("status")
422
+ return False, None, None
423
+
424
+ def _finish_tool_panel(
425
+ self,
426
+ finished_tool_name: str,
427
+ finished_tool_output: Any,
428
+ task_id: str,
429
+ context_id: str,
430
+ ):
431
+ """Finish a tool panel and update its status."""
432
+ tool_sid = f"tool_{finished_tool_name}_{task_id}_{context_id}"
433
+ if tool_sid in self.tool_panels:
434
+ meta = self.tool_panels[tool_sid]
435
+ prev_status = meta.get("status")
436
+
437
+ if prev_status != "finished":
412
438
  meta["status"] = "finished"
413
- # Compute and store duration for finished panel
414
- if prev_status != "finished":
415
- try:
416
- server_now = self.stream_processor.server_elapsed_time
417
- server_start = meta.get("server_started_at")
418
- dur = None
419
- if isinstance(server_now, int | float) and isinstance(
420
- server_start, int | float
421
- ):
422
- dur = max(0.0, float(server_now) - float(server_start))
423
- elif meta.get("started_at") is not None:
424
- dur = max(0.0, float(monotonic() - meta.get("started_at")))
425
- if dur is not None:
426
- meta["duration_seconds"] = dur
427
- meta["server_finished_at"] = (
428
- server_now
429
- if isinstance(server_now, int | float)
430
- else None
431
- )
432
- meta["finished_at"] = monotonic()
433
- except Exception:
434
- pass
435
439
 
436
- if finished_tool_output is not None:
437
- meta["chunks"].append(
438
- self._format_output_block(
439
- finished_tool_output, finished_tool_name
440
- )
440
+ # Compute and store duration
441
+ try:
442
+ server_now = self.stream_processor.server_elapsed_time
443
+ server_start = meta.get("server_started_at")
444
+ dur = None
445
+
446
+ if isinstance(server_now, int | float) and isinstance(
447
+ server_start, int | float
448
+ ):
449
+ dur = max(0.0, float(server_now) - float(server_start))
450
+ elif meta.get("started_at") is not None:
451
+ dur = max(0.0, float(monotonic() - meta.get("started_at")))
452
+
453
+ if dur is not None:
454
+ meta["duration_seconds"] = dur
455
+ meta["server_finished_at"] = (
456
+ server_now if isinstance(server_now, int | float) else None
441
457
  )
442
- meta["output"] = finished_tool_output
443
- # Ensure this finished panel is visible in this frame
444
- self.stream_processor.current_event_finished_panels.add(tool_sid)
458
+ meta["finished_at"] = monotonic()
459
+ except Exception:
460
+ pass
461
+
462
+ # Add output to panel
463
+ if finished_tool_output is not None:
464
+ meta["chunks"].append(
465
+ self._format_output_block(
466
+ finished_tool_output, finished_tool_name
467
+ )
468
+ )
469
+ meta["output"] = finished_tool_output
470
+
471
+ # Ensure this finished panel is visible in this frame
472
+ self.stream_processor.current_event_finished_panels.add(tool_sid)
473
+
474
+ def _finish_tool_step(
475
+ self,
476
+ finished_tool_name: str,
477
+ finished_tool_output: Any,
478
+ task_id: str,
479
+ context_id: str,
480
+ ):
481
+ """Finish the corresponding step for a completed tool."""
482
+ tool_sid = f"tool_{finished_tool_name}_{task_id}_{context_id}"
483
+ step_duration = None
445
484
 
446
- # Finish corresponding step, pass duration to match panel title
485
+ try:
486
+ step_duration = self.tool_panels.get(tool_sid, {}).get("duration_seconds")
487
+ except Exception:
447
488
  step_duration = None
448
- try:
449
- step_duration = self.tool_panels.get(tool_sid, {}).get(
450
- "duration_seconds"
451
- )
452
- except Exception:
453
- step_duration = None
454
- if is_delegation_tool(finished_tool_name):
455
- self.steps.finish(
456
- task_id=task_id,
457
- context_id=context_id,
458
- kind="delegate",
459
- name=finished_tool_name,
460
- output=finished_tool_output,
461
- duration_raw=step_duration,
462
- )
463
- else:
464
- self.steps.finish(
465
- task_id=task_id,
466
- context_id=context_id,
467
- kind="tool",
468
- name=finished_tool_name,
469
- output=finished_tool_output,
470
- duration_raw=step_duration,
489
+
490
+ if is_delegation_tool(finished_tool_name):
491
+ self.steps.finish(
492
+ task_id=task_id,
493
+ context_id=context_id,
494
+ kind="delegate",
495
+ name=finished_tool_name,
496
+ output=finished_tool_output,
497
+ duration_raw=step_duration,
498
+ )
499
+ else:
500
+ self.steps.finish(
501
+ task_id=task_id,
502
+ context_id=context_id,
503
+ kind="tool",
504
+ name=finished_tool_name,
505
+ output=finished_tool_output,
506
+ duration_raw=step_duration,
507
+ )
508
+
509
+ def _create_tool_snapshot(
510
+ self, finished_tool_name: str, task_id: str, context_id: str
511
+ ):
512
+ """Create and print a snapshot for a finished tool."""
513
+ tool_sid = f"tool_{finished_tool_name}_{task_id}_{context_id}"
514
+
515
+ try:
516
+ if not (
517
+ self.cfg.append_finished_snapshots
518
+ and not self.tool_panels.get(tool_sid, {}).get("snapshot_printed")
519
+ ):
520
+ return
521
+
522
+ meta = self.tool_panels[tool_sid]
523
+ adjusted_title = meta.get("title") or finished_tool_name
524
+
525
+ # Add elapsed time to title
526
+ dur = meta.get("duration_seconds")
527
+ if isinstance(dur, int | float):
528
+ elapsed_str = (
529
+ f"{dur:.2f}s"
530
+ if dur >= 1
531
+ else (f"{int(dur * 1000)}ms" if int(dur * 1000) > 0 else "<1ms")
471
532
  )
533
+ adjusted_title = f"{adjusted_title} · {elapsed_str}"
534
+
535
+ # Compose body from chunks and clamp
536
+ body_text = "".join(meta.get("chunks") or [])
537
+ max_lines = int(self.cfg.snapshot_max_lines or 0) or 60
538
+ lines = body_text.splitlines()
539
+ if len(lines) > max_lines:
540
+ lines = lines[:max_lines] + ["… (truncated)"]
541
+ body_text = "\n".join(lines)
542
+
543
+ max_chars = int(self.cfg.snapshot_max_chars or 0) or 4000
544
+ if len(body_text) > max_chars:
545
+ body_text = body_text[: max_chars - 12] + "\n… (truncated)"
546
+
547
+ snapshot_panel = create_tool_panel(
548
+ title=adjusted_title,
549
+ content=body_text or "(no output)",
550
+ status="finished",
551
+ theme=self.cfg.theme,
552
+ is_delegation=is_delegation_tool(finished_tool_name),
553
+ )
472
554
 
473
- # Append a truncated snapshot to scrollback so user can freely scroll
474
- try:
475
- if self.cfg.append_finished_snapshots and not self.tool_panels.get(
476
- tool_sid, {}
477
- ).get("snapshot_printed"):
478
- # Build title with elapsed if available
479
- adjusted_title = meta.get("title") or finished_tool_name
480
- dur = meta.get("duration_seconds")
481
- if isinstance(dur, int | float):
482
- elapsed_str = (
483
- f"{dur:.2f}s"
484
- if dur >= 1
485
- else (
486
- f"{int(dur * 1000)}ms"
487
- if int(dur * 1000) > 0
488
- else "<1ms"
489
- )
490
- )
491
- adjusted_title = f"{adjusted_title} · {elapsed_str}"
492
-
493
- # Compose body from chunks and clamp
494
- body_text = "".join(meta.get("chunks") or [])
495
- # Clamp by lines then by chars
496
- max_lines = int(self.cfg.snapshot_max_lines or 0) or 60
497
- lines = body_text.splitlines()
498
- if len(lines) > max_lines:
499
- lines = lines[:max_lines] + ["… (truncated)"]
500
- body_text = "\n".join(lines)
501
- max_chars = int(self.cfg.snapshot_max_chars or 0) or 4000
502
- if len(body_text) > max_chars:
503
- body_text = body_text[: max_chars - 12] + "\n… (truncated)"
504
-
505
- snapshot_panel = create_tool_panel(
506
- title=adjusted_title,
507
- content=body_text or "(no output)",
508
- status="finished",
509
- theme=self.cfg.theme,
510
- is_delegation=is_delegation_tool(finished_tool_name),
511
- )
512
- # Print as a snapshot entry; when Live is active this prints above the live area
513
- self.console.print(snapshot_panel)
514
- # Guard so we don't print snapshot twice for repeated finish events
515
- self.tool_panels[tool_sid]["snapshot_printed"] = True
516
- except Exception:
517
- pass
555
+ # Print as a snapshot entry
556
+ self.console.print(snapshot_panel)
557
+ # Guard so we don't print snapshot twice
558
+ self.tool_panels[tool_sid]["snapshot_printed"] = True
559
+
560
+ except Exception:
561
+ pass
562
+
563
+ def _handle_agent_step(
564
+ self,
565
+ event: dict[str, Any],
566
+ tool_name: str | None,
567
+ tool_args: Any,
568
+ _tool_out: Any,
569
+ tool_calls_info: list,
570
+ ):
571
+ """Handle agent step event."""
572
+ metadata = event.get("metadata", {})
573
+ task_id = event.get("task_id")
574
+ context_id = event.get("context_id")
575
+ content = event.get("content", "")
576
+
577
+ # Create steps and panels for the primary tool
578
+ if tool_name:
579
+ tool_sid = self._ensure_tool_panel(
580
+ tool_name, tool_args, task_id, context_id
581
+ )
582
+ self._start_tool_step(task_id, context_id, tool_name, tool_args, tool_sid)
583
+
584
+ # Handle additional tool calls
585
+ self._process_additional_tool_calls(
586
+ tool_calls_info, tool_name, task_id, context_id
587
+ )
588
+
589
+ # Check for tool completion
590
+ is_tool_finished, finished_tool_name, finished_tool_output = (
591
+ self._detect_tool_completion(metadata, content)
592
+ )
593
+
594
+ if is_tool_finished and finished_tool_name:
595
+ self._finish_tool_panel(
596
+ finished_tool_name, finished_tool_output, task_id, context_id
597
+ )
598
+ self._finish_tool_step(
599
+ finished_tool_name, finished_tool_output, task_id, context_id
600
+ )
601
+ self._create_tool_snapshot(finished_tool_name, task_id, context_id)
518
602
 
519
603
  def _spinner(self) -> str:
520
604
  """Return spinner character."""
@@ -600,7 +684,7 @@ class RichStreamRenderer:
600
684
 
601
685
  # Modern interface only — no legacy helper shims below
602
686
 
603
- def _refresh(self, force: bool | None = None) -> None:
687
+ def _refresh(self, _force: bool | None = None) -> None:
604
688
  # In the modular renderer, refreshing simply updates the live group
605
689
  self._ensure_live()
606
690
 
@@ -611,84 +695,93 @@ class RichStreamRenderer:
611
695
  return True
612
696
  return False
613
697
 
614
- def _render_steps_text(self) -> Text:
615
- """Render the steps panel content."""
616
- if not (self.steps.order or self.steps.children):
617
- return Text("No steps yet", style="dim")
698
+ def _get_step_icon(self, step_kind: str) -> str:
699
+ """Get icon for step kind."""
700
+ if step_kind == "tool":
701
+ return "⚙️"
702
+ elif step_kind == "delegate":
703
+ return "🤝"
704
+ elif step_kind == "agent":
705
+ return "🧠"
706
+ return ""
707
+
708
+ def _format_step_status(self, step) -> str:
709
+ """Format step status with elapsed time or duration."""
710
+ if is_step_finished(step):
711
+ if step.duration_ms is None:
712
+ return "[<1ms]"
713
+ elif step.duration_ms >= 1000:
714
+ return f"[{step.duration_ms/1000:.2f}s]"
715
+ elif step.duration_ms > 0:
716
+ return f"[{step.duration_ms}ms]"
717
+ return "[<1ms]"
718
+ else:
719
+ # Calculate elapsed time for running steps
720
+ elapsed = self._calculate_step_elapsed_time(step)
721
+ if elapsed >= 1:
722
+ return f"[{elapsed:.2f}s]"
723
+ ms = int(elapsed * 1000)
724
+ return f"[{ms}ms]" if ms > 0 else "[<1ms]"
725
+
726
+ def _calculate_step_elapsed_time(self, step) -> float:
727
+ """Calculate elapsed time for a running step."""
728
+ server_elapsed = self.stream_processor.server_elapsed_time
729
+ server_start = self._step_server_start_times.get(step.step_id)
730
+
731
+ if isinstance(server_elapsed, int | float) and isinstance(
732
+ server_start, int | float
733
+ ):
734
+ return max(0.0, float(server_elapsed) - float(server_start))
618
735
 
619
- # Track running tools by task/context to annotate parallelism
736
+ try:
737
+ return max(0.0, float(monotonic() - step.started_at))
738
+ except Exception:
739
+ return 0.0
740
+
741
+ def _get_step_display_name(self, step) -> str:
742
+ """Get display name for a step."""
743
+ if step.name and step.name != "step":
744
+ return step.name
745
+ return "thinking..." if step.kind == "agent" else f"{step.kind} step"
746
+
747
+ def _check_parallel_tools(self) -> dict[tuple[str | None, str | None], list]:
748
+ """Check for parallel running tools."""
620
749
  running_by_ctx: dict[tuple[str | None, str | None], list] = {}
621
750
  for sid in self.steps.order:
622
751
  st = self.steps.by_id[sid]
623
752
  if st.kind == "tool" and not is_step_finished(st):
624
753
  key = (st.task_id, st.context_id)
625
754
  running_by_ctx.setdefault(key, []).append(st)
755
+ return running_by_ctx
626
756
 
757
+ def _render_steps_text(self) -> Text:
758
+ """Render the steps panel content."""
759
+ if not (self.steps.order or self.steps.children):
760
+ return Text("No steps yet", style="dim")
761
+
762
+ running_by_ctx = self._check_parallel_tools()
627
763
  lines: list[str] = []
764
+
628
765
  for sid in self.steps.order:
629
766
  st = self.steps.by_id[sid]
630
- # Determine elapsed/status label
631
- if is_step_finished(st):
632
- if st.duration_ms is None:
633
- status_br = "[<1ms]"
634
- elif st.duration_ms >= 1000:
635
- status_br = f"[{st.duration_ms/1000:.2f}s]"
636
- elif st.duration_ms > 0:
637
- status_br = f"[{st.duration_ms}ms]"
638
- else:
639
- status_br = "[<1ms]"
640
- else:
641
- # Prefer server timing when we have a server start timestamp
642
- server_elapsed = self.stream_processor.server_elapsed_time
643
- server_start = self._step_server_start_times.get(st.step_id)
644
- if isinstance(server_elapsed, int | float) and isinstance(
645
- server_start, int | float
646
- ):
647
- elapsed = max(0.0, float(server_elapsed) - float(server_start))
648
- else:
649
- try:
650
- elapsed = max(0.0, float(monotonic() - st.started_at))
651
- except Exception:
652
- elapsed = 0.0
653
- # Standardized elapsed label without "Working..."
654
- if elapsed >= 1:
655
- status_br = f"[{elapsed:.2f}s]"
656
- else:
657
- ms = int(elapsed * 1000)
658
- status_br = f"[{ms}ms]" if ms > 0 else "[<1ms]"
659
-
660
- display_name = (
661
- st.name
662
- if st.name and st.name != "step"
663
- else ("thinking..." if st.kind == "agent" else f"{st.kind} step")
664
- )
767
+ status_br = self._format_step_status(st)
768
+ display_name = self._get_step_display_name(st)
665
769
  tail = " ✓" if is_step_finished(st) else ""
666
770
 
667
- # Parallel indicator for running tools
668
- parallel_indicator = ""
771
+ # Add parallel indicator for running tools
669
772
  if st.kind == "tool" and not is_step_finished(st):
670
773
  key = (st.task_id, st.context_id)
671
774
  if len(running_by_ctx.get(key, [])) > 1:
672
- parallel_indicator = " 🔄"
673
- status_br = status_br.replace("]", f"{parallel_indicator}]")
674
-
675
- # Icon prefix (simple mapping)
676
- if st.kind == "tool":
677
- icon = "⚙️"
678
- elif st.kind == "delegate":
679
- icon = "🤝"
680
- elif st.kind == "agent":
681
- icon = "🧠"
682
- else:
683
- icon = ""
775
+ status_br = status_br.replace("]", " 🔄]")
684
776
 
777
+ icon = self._get_step_icon(st.kind)
685
778
  lines.append(f"{icon} {display_name} {status_br}{tail}")
686
779
 
687
780
  return Text("\n".join(lines), style="dim")
688
781
 
689
- def _render_tool_panels(self) -> list[Panel]:
782
+ def _render_tool_panels(self) -> list[AIPPanel]:
690
783
  """Render tool execution output panels."""
691
- panels: list[Panel] = []
784
+ panels: list[AIPPanel] = []
692
785
  for sid in self.tool_order:
693
786
  meta = self.tool_panels.get(sid) or {}
694
787
  title = meta.get("title") or "Tool"