glaip-sdk 0.0.19__py3-none-any.whl → 0.1.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 (56) 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 +127 -21
  6. glaip_sdk/cli/commands/configure.py +141 -90
  7. glaip_sdk/cli/commands/mcps.py +82 -31
  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 +437 -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 +794 -0
  27. glaip_sdk/cli/update_notifier.py +29 -5
  28. glaip_sdk/cli/utils.py +255 -74
  29. glaip_sdk/client/agents.py +3 -1
  30. glaip_sdk/client/run_rendering.py +126 -21
  31. glaip_sdk/icons.py +25 -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 +55 -11
  38. glaip_sdk/utils/rendering/models.py +15 -2
  39. glaip_sdk/utils/rendering/renderer/__init__.py +0 -2
  40. glaip_sdk/utils/rendering/renderer/base.py +1287 -227
  41. glaip_sdk/utils/rendering/renderer/config.py +3 -5
  42. glaip_sdk/utils/rendering/renderer/debug.py +73 -16
  43. glaip_sdk/utils/rendering/renderer/panels.py +27 -15
  44. glaip_sdk/utils/rendering/renderer/progress.py +61 -38
  45. glaip_sdk/utils/rendering/renderer/stream.py +3 -3
  46. glaip_sdk/utils/rendering/renderer/toggle.py +184 -0
  47. glaip_sdk/utils/rendering/step_tree_state.py +102 -0
  48. glaip_sdk/utils/rendering/steps.py +944 -16
  49. glaip_sdk/utils/serialization.py +5 -2
  50. glaip_sdk/utils/validation.py +1 -2
  51. {glaip_sdk-0.0.19.dist-info → glaip_sdk-0.1.0.dist-info}/METADATA +12 -1
  52. glaip_sdk-0.1.0.dist-info/RECORD +82 -0
  53. glaip_sdk/utils/rich_utils.py +0 -29
  54. glaip_sdk-0.0.19.dist-info/RECORD +0 -73
  55. {glaip_sdk-0.0.19.dist-info → glaip_sdk-0.1.0.dist-info}/WHEEL +0 -0
  56. {glaip_sdk-0.0.19.dist-info → glaip_sdk-0.1.0.dist-info}/entry_points.txt +0 -0
@@ -23,6 +23,58 @@ from glaip_sdk.utils.rendering.renderer import RichStreamRenderer
23
23
  from glaip_sdk.utils.rendering.renderer.config import RendererConfig
24
24
 
25
25
 
26
+ def _coerce_to_string(value: Any) -> str:
27
+ """Return a best-effort string representation for transcripts."""
28
+ try:
29
+ return str(value)
30
+ except Exception:
31
+ return f"{value}"
32
+
33
+
34
+ def _has_visible_text(value: Any) -> bool:
35
+ """Return True when the value is a non-empty string."""
36
+ return isinstance(value, str) and bool(value.strip())
37
+
38
+
39
+ def _update_state_transcript(state: Any, text_value: str) -> bool:
40
+ """Inject transcript text into renderer state if possible."""
41
+ if state is None:
42
+ return False
43
+
44
+ updated = False
45
+
46
+ if hasattr(state, "final_text") and not _has_visible_text(
47
+ getattr(state, "final_text", "")
48
+ ):
49
+ try:
50
+ state.final_text = text_value
51
+ updated = True
52
+ except Exception:
53
+ pass
54
+
55
+ buffer = getattr(state, "buffer", None)
56
+ if isinstance(buffer, list) and not any(_has_visible_text(item) for item in buffer):
57
+ buffer.append(text_value)
58
+ updated = True
59
+
60
+ return updated
61
+
62
+
63
+ def _update_renderer_transcript(renderer: Any, text_value: str) -> None:
64
+ """Populate the renderer (or its state) with the supplied text."""
65
+ state = getattr(renderer, "state", None)
66
+ if _update_state_transcript(state, text_value):
67
+ return
68
+
69
+ if hasattr(renderer, "final_text") and not _has_visible_text(
70
+ getattr(renderer, "final_text", "")
71
+ ):
72
+ try:
73
+ setattr(renderer, "final_text", text_value)
74
+ except Exception:
75
+ pass
76
+
77
+
26
78
  class AgentRunRenderingManager:
27
79
  """Coordinate renderer creation and streaming event handling."""
28
80
 
@@ -79,7 +131,6 @@ class AgentRunRenderingManager:
79
131
  silent_config = RendererConfig(
80
132
  live=False,
81
133
  persist_live=False,
82
- show_delegate_tool_panels=False,
83
134
  render_thinking=False,
84
135
  )
85
136
  return RichStreamRenderer(
@@ -92,7 +143,6 @@ class AgentRunRenderingManager:
92
143
  minimal_config = RendererConfig(
93
144
  live=False,
94
145
  persist_live=False,
95
- show_delegate_tool_panels=False,
96
146
  render_thinking=False,
97
147
  )
98
148
  return RichStreamRenderer(
@@ -106,7 +156,6 @@ class AgentRunRenderingManager:
106
156
  theme="dark",
107
157
  style="debug",
108
158
  live=False,
109
- show_delegate_tool_panels=True,
110
159
  append_finished_snapshots=False,
111
160
  )
112
161
  return RichStreamRenderer(
@@ -118,7 +167,7 @@ class AgentRunRenderingManager:
118
167
  def _create_default_renderer(self, verbose: bool) -> RichStreamRenderer:
119
168
  if verbose:
120
169
  return self._create_verbose_renderer()
121
- default_config = RendererConfig(show_delegate_tool_panels=True)
170
+ default_config = RendererConfig()
122
171
  return RichStreamRenderer(console=_Console(), cfg=default_config)
123
172
 
124
173
  # --------------------------------------------------------------------- #
@@ -139,17 +188,28 @@ class AgentRunRenderingManager:
139
188
 
140
189
  self._capture_request_id(stream_response, meta, renderer)
141
190
 
142
- for event in iter_sse_events(stream_response, timeout_seconds, agent_name):
143
- if started_monotonic is None:
144
- started_monotonic = self._maybe_start_timer(event)
191
+ controller = getattr(renderer, "transcript_controller", None)
192
+ if controller and getattr(controller, "enabled", False):
193
+ controller.on_stream_start(renderer)
145
194
 
146
- final_text, stats_usage = self._process_single_event(
147
- event,
148
- renderer,
149
- final_text,
150
- stats_usage,
151
- meta,
152
- )
195
+ try:
196
+ for event in iter_sse_events(stream_response, timeout_seconds, agent_name):
197
+ if started_monotonic is None:
198
+ started_monotonic = self._maybe_start_timer(event)
199
+
200
+ final_text, stats_usage = self._process_single_event(
201
+ event,
202
+ renderer,
203
+ final_text,
204
+ stats_usage,
205
+ meta,
206
+ )
207
+
208
+ if controller and getattr(controller, "enabled", False):
209
+ controller.poll(renderer)
210
+ finally:
211
+ if controller and getattr(controller, "enabled", False):
212
+ controller.on_stream_complete()
153
213
 
154
214
  finished_monotonic = monotonic()
155
215
  return final_text, stats_usage, started_monotonic, finished_monotonic
@@ -194,19 +254,50 @@ class AgentRunRenderingManager:
194
254
  kind = (ev.get("metadata") or {}).get("kind")
195
255
  renderer.on_event(ev)
196
256
 
257
+ handled = self._handle_metadata_kind(
258
+ kind,
259
+ ev,
260
+ final_text,
261
+ stats_usage,
262
+ meta,
263
+ renderer,
264
+ )
265
+ if handled is not None:
266
+ return handled
267
+
268
+ if ev.get("content"):
269
+ final_text = self._handle_content_event(ev, final_text)
270
+
271
+ return final_text, stats_usage
272
+
273
+ def _handle_metadata_kind(
274
+ self,
275
+ kind: str | None,
276
+ ev: dict[str, Any],
277
+ final_text: str,
278
+ stats_usage: dict[str, Any],
279
+ meta: dict[str, Any],
280
+ renderer: RichStreamRenderer,
281
+ ) -> tuple[str, dict[str, Any]] | None:
282
+ """Process well-known metadata kinds and return updated state."""
197
283
  if kind == "artifact":
198
284
  return final_text, stats_usage
199
285
 
200
- if kind == "final_response" and ev.get("content"):
201
- final_text = ev.get("content", "")
202
- elif ev.get("content"):
203
- final_text = self._handle_content_event(ev, final_text)
204
- elif kind == "usage":
286
+ if kind == "final_response":
287
+ content = ev.get("content")
288
+ if content:
289
+ return content, stats_usage
290
+ return final_text, stats_usage
291
+
292
+ if kind == "usage":
205
293
  stats_usage.update(ev.get("usage") or {})
206
- elif kind == "run_info":
294
+ return final_text, stats_usage
295
+
296
+ if kind == "run_info":
207
297
  self._handle_run_info_event(ev, meta, renderer)
298
+ return final_text, stats_usage
208
299
 
209
- return final_text, stats_usage
300
+ return None
210
301
 
211
302
  def _handle_content_event(self, ev: dict[str, Any], final_text: str) -> str:
212
303
  content = ev.get("content", "")
@@ -227,6 +318,16 @@ class AgentRunRenderingManager:
227
318
  meta["run_id"] = ev["run_id"]
228
319
  renderer.on_start(meta)
229
320
 
321
+ def _ensure_renderer_final_content(
322
+ self, renderer: RichStreamRenderer, text: str
323
+ ) -> None:
324
+ """Populate renderer state with final output when the stream omits it."""
325
+ if not text:
326
+ return
327
+
328
+ text_value = _coerce_to_string(text)
329
+ _update_renderer_transcript(renderer, text_value)
330
+
230
331
  # --------------------------------------------------------------------- #
231
332
  # Finalisation helpers
232
333
  # --------------------------------------------------------------------- #
@@ -258,6 +359,10 @@ class AgentRunRenderingManager:
258
359
  except TypeError:
259
360
  rendered_text = ""
260
361
 
362
+ fallback_text = final_text or rendered_text
363
+ if fallback_text:
364
+ self._ensure_renderer_final_content(renderer, fallback_text)
365
+
261
366
  renderer.on_complete(st)
262
367
  return final_text or rendered_text or "No response content received."
263
368
 
glaip_sdk/icons.py ADDED
@@ -0,0 +1,25 @@
1
+ """Lightweight icon definitions used across the CLI.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ ICON_AGENT = "🤖"
8
+ ICON_AGENT_STEP = "🤖"
9
+ ICON_TOOL = "🔧"
10
+ ICON_TOOL_STEP = "🔧"
11
+ ICON_DELEGATE = ICON_AGENT_STEP
12
+ ICON_STATUS_SUCCESS = "✓"
13
+ ICON_STATUS_FAILED = "✗"
14
+ ICON_STATUS_WARNING = "⚠"
15
+
16
+ __all__ = [
17
+ "ICON_AGENT",
18
+ "ICON_AGENT_STEP",
19
+ "ICON_TOOL",
20
+ "ICON_TOOL_STEP",
21
+ "ICON_DELEGATE",
22
+ "ICON_STATUS_SUCCESS",
23
+ "ICON_STATUS_FAILED",
24
+ "ICON_STATUS_WARNING",
25
+ ]
glaip_sdk/models.py CHANGED
@@ -58,6 +58,9 @@ class Agent(BaseModel):
58
58
  )
59
59
  # Automatically pass the agent name for better renderer display
60
60
  kwargs.setdefault("agent_name", self.name)
61
+ # Pass the agent's configured timeout if not explicitly overridden
62
+ if "timeout" not in kwargs:
63
+ kwargs["timeout"] = self.timeout
61
64
  # Pass verbose flag through to enable event JSON output
62
65
  return self._client.run_agent(self.id, message, verbose=verbose, **kwargs)
63
66
 
@@ -82,6 +85,9 @@ class Agent(BaseModel):
82
85
  )
83
86
  # Automatically pass the agent name for better context
84
87
  kwargs.setdefault("agent_name", self.name)
88
+ # Pass the agent's configured timeout if not explicitly overridden
89
+ if "timeout" not in kwargs:
90
+ kwargs["timeout"] = self.timeout
85
91
 
86
92
  async for chunk in self._client.arun_agent(self.id, message, **kwargs):
87
93
  yield chunk
@@ -38,4 +38,32 @@ class AIPTable(Table):
38
38
  super().__init__(*args, **kwargs)
39
39
 
40
40
 
41
- __all__ = ["AIPPanel", "AIPTable"]
41
+ class AIPGrid(Table):
42
+ """Table-based grid with GL AIP defaults for layout blocks."""
43
+
44
+ def __init__(
45
+ self,
46
+ *,
47
+ expand: bool = True,
48
+ padding: tuple[int, int] = (0, 1),
49
+ collapse_padding: bool = True,
50
+ ):
51
+ """Initialize AIPGrid with zero-edge borders and optional expansion.
52
+
53
+ Args:
54
+ expand: Whether the grid should expand to fill available width.
55
+ padding: Cell padding for the grid (row, column).
56
+ collapse_padding: Collapse padding between renderables.
57
+ """
58
+ super().__init__(
59
+ show_header=False,
60
+ show_edge=False,
61
+ pad_edge=False,
62
+ box=None,
63
+ expand=expand,
64
+ padding=padding,
65
+ collapse_padding=collapse_padding,
66
+ )
67
+
68
+
69
+ __all__ = ["AIPPanel", "AIPTable", "AIPGrid"]
@@ -5,6 +5,7 @@ Authors:
5
5
  """
6
6
 
7
7
  from glaip_sdk.utils.display import (
8
+ RICH_AVAILABLE,
8
9
  print_agent_created,
9
10
  print_agent_deleted,
10
11
  print_agent_output,
@@ -19,7 +20,6 @@ from glaip_sdk.utils.general import (
19
20
  )
20
21
  from glaip_sdk.utils.rendering.models import RunStats, Step
21
22
  from glaip_sdk.utils.rendering.steps import StepManager
22
- from glaip_sdk.utils.rich_utils import RICH_AVAILABLE
23
23
  from glaip_sdk.utils.run_renderer import RichStreamRenderer
24
24
 
25
25
  __all__ = [
@@ -17,6 +17,12 @@ from typing import Any, BinaryIO, NoReturn
17
17
  import httpx
18
18
 
19
19
  from glaip_sdk.exceptions import AgentTimeoutError
20
+ from glaip_sdk.utils.resource_refs import (
21
+ extract_ids as extract_ids_new,
22
+ )
23
+ from glaip_sdk.utils.resource_refs import (
24
+ find_by_name as find_by_name_new,
25
+ )
20
26
 
21
27
  # Set up module-level logger
22
28
  logger = logging.getLogger("glaip_sdk.client_utils")
@@ -77,8 +83,6 @@ def extract_ids(items: list[str | Any] | None) -> list[str] | None:
77
83
  This function maintains backward compatibility by returning None for empty input.
78
84
  New code should use glaip_sdk.utils.resource_refs.extract_ids which returns [].
79
85
  """
80
- from .resource_refs import extract_ids as extract_ids_new
81
-
82
86
  if not items:
83
87
  return None
84
88
 
@@ -127,8 +131,6 @@ def find_by_name(
127
131
  Note:
128
132
  This function now delegates to glaip_sdk.utils.resource_refs.find_by_name.
129
133
  """
130
- from .resource_refs import find_by_name as find_by_name_new
131
-
132
134
  return find_by_name_new(items, name, case_sensitive)
133
135
 
134
136
 
@@ -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,21 @@ 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 (
16
+ ICON_AGENT_STEP,
17
+ ICON_DELEGATE,
18
+ ICON_STATUS_FAILED,
19
+ ICON_STATUS_SUCCESS,
20
+ ICON_STATUS_WARNING,
21
+ ICON_TOOL_STEP,
22
+ )
23
+
14
24
  # Constants for argument formatting
15
25
  DEFAULT_ARGS_MAX_LEN = 100
16
26
  IMPORTANT_PARAMETER_KEYS = [
@@ -34,9 +44,20 @@ SECRET_VALUE_PATTERNS = [
34
44
  re.compile(r"eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+"), # JWT tokens
35
45
  ]
36
46
  SENSITIVE_PATTERNS = re.compile(
37
- r"password\s*[:=]\s*[^\s,}]+|secret\s*[:=]\s*[^\s,}]+|token\s*[:=]\s*[^\s,}]+|key\s*[:=]\s*[^\s,}]+|api_key\s*[:=]\s*[^\s,}]+|^password$|^secret$|^token$|^key$|^api_key$",
47
+ r"(?:password|secret|token|key|api_key)(?:\s*[:=]\s*[^\s,}]+)?",
38
48
  re.IGNORECASE,
39
49
  )
50
+ CONNECTOR_VERTICAL = "│ "
51
+ CONNECTOR_EMPTY = " "
52
+ CONNECTOR_BRANCH = "├─ "
53
+ CONNECTOR_LAST = "└─ "
54
+ ROOT_MARKER = ""
55
+ SECRET_MASK = "••••••"
56
+ STATUS_GLYPHS = {
57
+ "success": ICON_STATUS_SUCCESS,
58
+ "failed": ICON_STATUS_FAILED,
59
+ "warning": ICON_STATUS_WARNING,
60
+ }
40
61
 
41
62
 
42
63
  def _truncate_string(s: str, max_len: int) -> str:
@@ -50,7 +71,7 @@ def mask_secrets_in_string(text: str) -> str:
50
71
  """Mask sensitive information in a string."""
51
72
  result = text
52
73
  for pattern in SECRET_VALUE_PATTERNS:
53
- result = re.sub(pattern, "••••••", result)
74
+ result = re.sub(pattern, SECRET_MASK, result)
54
75
  return result
55
76
 
56
77
 
@@ -71,7 +92,7 @@ def _redact_dict_values(text: dict) -> dict:
71
92
  result = {}
72
93
  for key, value in text.items():
73
94
  if _is_sensitive_key(key):
74
- result[key] = "••••••"
95
+ result[key] = SECRET_MASK
75
96
  elif _should_recurse_redaction(value):
76
97
  result[key] = redact_sensitive(value)
77
98
  else:
@@ -89,11 +110,11 @@ def _redact_string_content(text: str) -> str:
89
110
  result = text
90
111
  # First mask secrets
91
112
  for pattern in SECRET_VALUE_PATTERNS:
92
- result = re.sub(pattern, "••••••", result)
113
+ result = re.sub(pattern, SECRET_MASK, result)
93
114
  # Then redact sensitive patterns
94
115
  result = re.sub(
95
116
  SENSITIVE_PATTERNS,
96
- lambda m: m.group(0).split("=")[0] + "=••••••",
117
+ lambda m: m.group(0).split("=")[0] + "=" + SECRET_MASK,
97
118
  result,
98
119
  )
99
120
  return result
@@ -113,6 +134,31 @@ def _should_recurse_redaction(value: Any) -> bool:
113
134
  return isinstance(value, dict | list) or isinstance(value, str)
114
135
 
115
136
 
137
+ def glyph_for_status(icon_key: str | None) -> str | None:
138
+ """Return glyph representing a step status icon key."""
139
+ if not icon_key:
140
+ return None
141
+ return STATUS_GLYPHS.get(icon_key)
142
+
143
+
144
+ def normalise_display_label(label: str | None) -> str:
145
+ """Return a user facing label or the Unknown fallback."""
146
+ label = (label or "").strip()
147
+ return label or "Unknown step detail"
148
+
149
+
150
+ def build_connector_prefix(branch_state: tuple[bool, ...]) -> str:
151
+ """Build connector prefix for a tree line based on ancestry state."""
152
+ if not branch_state:
153
+ return ROOT_MARKER
154
+
155
+ parts: list[str] = []
156
+ for ancestor_is_last in branch_state[:-1]:
157
+ parts.append(CONNECTOR_EMPTY if ancestor_is_last else CONNECTOR_VERTICAL)
158
+ parts.append(CONNECTOR_LAST if branch_state[-1] else CONNECTOR_BRANCH)
159
+ return "".join(parts)
160
+
161
+
116
162
  def pretty_args(args: dict | None, max_len: int = DEFAULT_ARGS_MAX_LEN) -> str:
117
163
  """Format arguments in a pretty way."""
118
164
  if not args:
@@ -127,11 +173,9 @@ def pretty_args(args: dict | None, max_len: int = DEFAULT_ARGS_MAX_LEN) -> str:
127
173
 
128
174
  # Convert to JSON string and truncate if needed
129
175
  try:
130
- import json
131
-
132
176
  args_str = json.dumps(masked_args, ensure_ascii=False, separators=(",", ":"))
133
177
  return _truncate_string(args_str, max_len)
134
- except (TypeError, ValueError, Exception):
178
+ except Exception:
135
179
  # Fallback to string representation if JSON serialization fails
136
180
  args_str = str(masked_args)
137
181
  return _truncate_string(args_str, max_len)
@@ -174,11 +218,11 @@ def get_spinner_char() -> str:
174
218
  def get_step_icon(step_kind: str) -> str:
175
219
  """Get the appropriate icon for a step kind."""
176
220
  if step_kind == "tool":
177
- return "⚙️" # Gear emoji for tool
221
+ return ICON_TOOL_STEP
178
222
  if step_kind == "delegate":
179
- return "🤝" # Handshake for delegate
223
+ return ICON_DELEGATE
180
224
  if step_kind == "agent":
181
- return "🧠" # Brain emoji for agent
225
+ return ICON_AGENT_STEP
182
226
  return ""
183
227
 
184
228
 
@@ -30,19 +30,32 @@ class Step:
30
30
  context_id: str | None = None
31
31
  started_at: float = field(default_factory=monotonic)
32
32
  duration_ms: int | None = None
33
-
34
- def finish(self, duration_raw: float | None) -> None:
33
+ duration_source: str | None = None
34
+ display_label: str | None = None
35
+ status_icon: str | None = None
36
+ failure_reason: str | None = None
37
+ branch_failed: bool = False
38
+ is_parallel: bool = False
39
+ server_started_at: float | None = None
40
+ server_finished_at: float | None = None
41
+ duration_unknown: bool = False
42
+
43
+ def finish(self, duration_raw: float | None, *, source: str | None = None) -> None:
35
44
  """Mark the step as finished and calculate duration.
36
45
 
37
46
  Args:
38
47
  duration_raw: Raw duration in seconds, or None to calculate from started_at
48
+ source: Optional duration source tag
39
49
  """
50
+ self.duration_unknown = False
40
51
  if isinstance(duration_raw, int | float) and duration_raw > 0:
41
52
  # Use provided duration if it's a positive number (even if very small)
42
53
  self.duration_ms = round(float(duration_raw) * 1000)
54
+ self.duration_source = source or self.duration_source or "provided"
43
55
  else:
44
56
  # Calculate from started_at if duration_raw is None, negative, or zero
45
57
  self.duration_ms = int((monotonic() - self.started_at) * 1000)
58
+ self.duration_source = source or self.duration_source or "monotonic"
46
59
  self.status = "finished"
47
60
 
48
61
 
@@ -35,7 +35,6 @@ def make_silent_renderer() -> RichStreamRenderer:
35
35
  cfg = RendererConfig(
36
36
  live=False,
37
37
  persist_live=False,
38
- show_delegate_tool_panels=False,
39
38
  render_thinking=False,
40
39
  )
41
40
  return RichStreamRenderer(
@@ -51,7 +50,6 @@ def make_minimal_renderer() -> RichStreamRenderer:
51
50
  cfg = RendererConfig(
52
51
  live=False,
53
52
  persist_live=False,
54
- show_delegate_tool_panels=False,
55
53
  render_thinking=False,
56
54
  )
57
55
  return RichStreamRenderer(console=Console(), cfg=cfg)