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
@@ -24,10 +24,8 @@ class RendererConfig:
24
24
  live: bool = True
25
25
  persist_live: bool = True
26
26
 
27
- # Debug visibility toggles
28
- show_delegate_tool_panels: bool = False
29
-
30
27
  # Scrollback/append options
28
+ summary_max_steps: int = 0
31
29
  append_finished_snapshots: bool = False
32
- snapshot_max_chars: int = 12000
33
- snapshot_max_lines: int = 200
30
+ snapshot_max_chars: int = 0
31
+ snapshot_max_lines: int = 0
@@ -5,25 +5,76 @@ Authors:
5
5
  """
6
6
 
7
7
  import json
8
- from datetime import datetime
9
- from time import monotonic
8
+ from datetime import datetime, timezone
10
9
  from typing import Any
11
10
 
12
11
  from rich.console import Console
13
12
  from rich.markdown import Markdown
14
13
 
14
+ from glaip_sdk.branding import PRIMARY, SUCCESS, WARNING
15
15
  from glaip_sdk.rich_components import AIPPanel
16
16
 
17
17
 
18
- def _calculate_relative_time(started_ts: float | None) -> tuple[float, str]:
19
- """Calculate relative time since start."""
20
- now_mono = monotonic()
18
+ def _coerce_datetime(value: Any) -> datetime | None:
19
+ """Attempt to coerce an arbitrary value to an aware datetime."""
20
+ if value is None:
21
+ return None
22
+
23
+ if isinstance(value, datetime):
24
+ return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
25
+
26
+ if isinstance(value, str):
27
+ try:
28
+ normalised = value.replace("Z", "+00:00")
29
+ dt = datetime.fromisoformat(normalised)
30
+ except ValueError:
31
+ return None
32
+ return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
33
+
34
+ return None
35
+
36
+
37
+ def _parse_event_timestamp(
38
+ event: dict[str, Any], received_ts: datetime | None = None
39
+ ) -> datetime | None:
40
+ """Resolve the most accurate timestamp available for the event."""
41
+ if received_ts is not None:
42
+ return (
43
+ received_ts
44
+ if received_ts.tzinfo
45
+ else received_ts.replace(tzinfo=timezone.utc)
46
+ )
47
+
48
+ ts_value = event.get("timestamp") or (event.get("metadata") or {}).get("timestamp")
49
+ return _coerce_datetime(ts_value)
50
+
51
+
52
+ def _format_timestamp_for_display(dt: datetime) -> str:
53
+ """Format timestamp for panel title, including timezone offset."""
54
+ local_dt = dt.astimezone()
55
+ ts_ms = local_dt.strftime("%H:%M:%S.%f")[:-3]
56
+ offset = local_dt.strftime("%z")
57
+ # offset is always non-empty for timezone-aware datetimes
58
+ offset = f"{offset[:3]}:{offset[3:]}"
59
+ return f"{ts_ms} {offset}"
60
+
61
+
62
+ def _calculate_relative_time(
63
+ event_ts: datetime | None,
64
+ baseline_ts: datetime | None,
65
+ ) -> tuple[float, str]:
66
+ """Calculate relative time since start and format event timestamp."""
21
67
  rel = 0.0
22
- if started_ts is not None:
23
- rel = max(0.0, now_mono - started_ts)
24
68
 
25
- ts_full = datetime.now().strftime("%H:%M:%S.%f")
26
- ts_ms = ts_full[:-3] # trim to milliseconds
69
+ # Determine display timestamp - use event timestamp when present, otherwise current time
70
+ display_ts: datetime | None = event_ts
71
+ if display_ts is None:
72
+ display_ts = datetime.now(timezone.utc)
73
+
74
+ if event_ts is not None and baseline_ts is not None:
75
+ rel = max(0.0, (event_ts - baseline_ts).total_seconds())
76
+
77
+ ts_ms = _format_timestamp_for_display(display_ts)
27
78
 
28
79
  return rel, ts_ms
29
80
 
@@ -75,10 +126,10 @@ def _format_event_json(event: dict[str, Any]) -> str:
75
126
  def _get_border_color(sse_kind: str) -> str:
76
127
  """Get border color for event type."""
77
128
  border_map = {
78
- "agent_step": "blue",
79
- "content": "green",
80
- "final_response": "green",
81
- "status": "yellow",
129
+ "agent_step": PRIMARY,
130
+ "content": SUCCESS,
131
+ "final_response": SUCCESS,
132
+ "status": WARNING,
82
133
  "artifact": "grey42",
83
134
  }
84
135
  return border_map.get(sse_kind, "grey42")
@@ -91,18 +142,24 @@ def _create_debug_panel(title: str, event_json: str, border: str) -> AIPPanel:
91
142
 
92
143
 
93
144
  def render_debug_event(
94
- event: dict[str, Any], console: Console, started_ts: float | None = None
145
+ event: dict[str, Any],
146
+ console: Console,
147
+ *,
148
+ received_ts: datetime | None = None,
149
+ baseline_ts: datetime | None = None,
95
150
  ) -> None:
96
151
  """Render a debug panel for an SSE event.
97
152
 
98
153
  Args:
99
154
  event: The SSE event data
100
155
  console: Rich console to print to
101
- started_ts: Monotonic timestamp when streaming started
156
+ received_ts: Client-side receipt timestamp, if available
157
+ baseline_ts: Baseline event timestamp for elapsed timing
102
158
  """
103
159
  try:
104
160
  # Calculate timing information
105
- rel, ts_ms = _calculate_relative_time(started_ts)
161
+ event_ts = _parse_event_timestamp(event, received_ts)
162
+ rel, ts_ms = _calculate_relative_time(event_ts, baseline_ts)
106
163
 
107
164
  # Extract event metadata
108
165
  sse_kind, status_str = _get_event_metadata(event)
@@ -11,6 +11,7 @@ from rich.markdown import Markdown
11
11
  from rich.spinner import Spinner
12
12
  from rich.text import Text
13
13
 
14
+ from glaip_sdk.branding import INFO, PRIMARY, SUCCESS, WARNING
14
15
  from glaip_sdk.rich_components import AIPPanel
15
16
 
16
17
 
@@ -19,7 +20,7 @@ def _spinner_renderable(message: str = "Processing...") -> Align:
19
20
  spinner = Spinner(
20
21
  "dots",
21
22
  text=Text(f" {message}", style="dim"),
22
- style="cyan",
23
+ style=INFO,
23
24
  )
24
25
  return Align.left(spinner)
25
26
 
@@ -39,13 +40,13 @@ def create_main_panel(content: str, title: str, theme: str = "dark") -> AIPPanel
39
40
  return AIPPanel(
40
41
  Markdown(content, code_theme=("monokai" if theme == "dark" else "github")),
41
42
  title=title,
42
- border_style="green",
43
+ border_style=SUCCESS,
43
44
  )
44
45
  else:
45
46
  return AIPPanel(
46
47
  _spinner_renderable(),
47
48
  title=title,
48
- border_style="green",
49
+ border_style=SUCCESS,
49
50
  )
50
51
 
51
52
 
@@ -55,6 +56,8 @@ def create_tool_panel(
55
56
  status: str = "running",
56
57
  theme: str = "dark",
57
58
  is_delegation: bool = False,
59
+ *,
60
+ spinner_message: str | None = None,
58
61
  ) -> AIPPanel:
59
62
  """Create a tool execution panel.
60
63
 
@@ -64,22 +67,29 @@ def create_tool_panel(
64
67
  status: Tool execution status
65
68
  theme: Color theme
66
69
  is_delegation: Whether this is a delegation tool
70
+ spinner_message: Optional custom message to show alongside the spinner
67
71
 
68
72
  Returns:
69
73
  Rich Panel instance
70
74
  """
71
- mark = "✓" if status == "finished" else ""
72
- border_style = "magenta" if is_delegation else "blue"
75
+ mark = "✓" if status == "finished" else ""
76
+ border_style = WARNING if is_delegation else PRIMARY
73
77
 
74
- body_renderable = (
75
- Markdown(content, code_theme=("monokai" if theme == "dark" else "github"))
76
- if content
77
- else _spinner_renderable()
78
- )
78
+ if content:
79
+ body_renderable = Markdown(
80
+ content,
81
+ code_theme=("monokai" if theme == "dark" else "github"),
82
+ )
83
+ elif status == "running":
84
+ body_renderable = _spinner_renderable(spinner_message or f"{title} running...")
85
+ else:
86
+ body_renderable = Text("No output yet.", style="dim")
87
+
88
+ title_text = f"{title} {mark}".rstrip()
79
89
 
80
90
  return AIPPanel(
81
91
  body_renderable,
82
- title=f"{title} {mark}",
92
+ title=title_text,
83
93
  border_style=border_style,
84
94
  )
85
95
 
@@ -103,15 +113,17 @@ def create_context_panel(
103
113
  Returns:
104
114
  Rich Panel instance
105
115
  """
106
- mark = "✓" if status == "finished" else ""
107
- border_style = "magenta" if is_delegation else "cyan"
116
+ mark = "✓" if status == "finished" else ""
117
+ border_style = WARNING if is_delegation else INFO
118
+
119
+ title_text = f"{title} {mark}".rstrip()
108
120
 
109
121
  return AIPPanel(
110
122
  Markdown(
111
123
  content,
112
124
  code_theme=("monokai" if theme == "dark" else "github"),
113
125
  ),
114
- title=f"{title} {mark}",
126
+ title=title_text,
115
127
  border_style=border_style,
116
128
  )
117
129
 
@@ -132,6 +144,6 @@ def create_final_panel(
132
144
  return AIPPanel(
133
145
  Markdown(content, code_theme=("monokai" if theme == "dark" else "github")),
134
146
  title=title,
135
- border_style="green",
147
+ border_style=SUCCESS,
136
148
  padding=(0, 1),
137
149
  )
@@ -16,40 +16,52 @@ def get_spinner() -> str:
16
16
  return get_spinner_char()
17
17
 
18
18
 
19
+ def _resolve_elapsed_time(
20
+ started_at: float | None,
21
+ server_elapsed_time: float | None,
22
+ streaming_started_at: float | None,
23
+ ) -> float | None:
24
+ """Return the elapsed seconds using server data when available."""
25
+ if server_elapsed_time is not None and streaming_started_at is not None:
26
+ return server_elapsed_time
27
+ if started_at is None:
28
+ return None
29
+ try:
30
+ return monotonic() - started_at
31
+ except Exception:
32
+ return None
33
+
34
+
35
+ def _format_elapsed_suffix(elapsed: float) -> str:
36
+ """Return formatting suffix for elapsed timing."""
37
+ if elapsed >= 1:
38
+ return f"{elapsed:.2f}s"
39
+ elapsed_ms = int(elapsed * 1000)
40
+ return f"{elapsed_ms}ms" if elapsed_ms > 0 else "<1ms"
41
+
42
+
19
43
  def format_working_indicator(
20
44
  started_at: float | None,
21
45
  server_elapsed_time: float | None = None,
22
46
  streaming_started_at: float | None = None,
23
47
  ) -> str:
24
- """Format a working indicator with elapsed time.
48
+ """Format a working indicator with elapsed time."""
49
+ base_message = "Working..."
25
50
 
26
- Args:
27
- started_at: Timestamp when work started, or None
28
- server_elapsed_time: Server-reported elapsed time if available
29
- streaming_started_at: When streaming started
30
-
31
- Returns:
32
- Formatted working indicator string with elapsed time
33
- """
34
- chip = "Working..."
51
+ if started_at is None and (
52
+ server_elapsed_time is None or streaming_started_at is None
53
+ ):
54
+ return base_message
35
55
 
36
- # Use server timing if available (more accurate)
37
- if server_elapsed_time is not None and streaming_started_at is not None:
38
- elapsed = server_elapsed_time
39
- elif started_at:
40
- try:
41
- elapsed = monotonic() - started_at
42
- except Exception:
43
- return chip
44
- else:
45
- return chip
56
+ spinner_chip = f"{get_spinner_char()} {base_message}"
57
+ elapsed = _resolve_elapsed_time(
58
+ started_at, server_elapsed_time, streaming_started_at
59
+ )
60
+ if elapsed is None:
61
+ return spinner_chip
46
62
 
47
- if elapsed >= 1:
48
- chip = f"Working... ({elapsed:.2f}s)"
49
- else:
50
- elapsed_ms = int(elapsed * 1000)
51
- chip = f"Working... ({elapsed_ms}ms)" if elapsed_ms > 0 else "Working... (<1ms)"
52
- return chip
63
+ suffix = _format_elapsed_suffix(elapsed)
64
+ return f"{spinner_chip} ({suffix})"
53
65
 
54
66
 
55
67
  def format_elapsed_time(elapsed_seconds: float) -> str:
@@ -88,6 +100,24 @@ def is_delegation_tool(tool_name: str) -> bool:
88
100
  )
89
101
 
90
102
 
103
+ def _delegation_tool_title(tool_name: str) -> str | None:
104
+ """Return delegation-aware title or ``None`` when not applicable."""
105
+ if tool_name.startswith("delegate_to_"):
106
+ sub_agent_name = tool_name.replace("delegate_to_", "", 1)
107
+ return f"Sub-Agent: {sub_agent_name}"
108
+ if tool_name.startswith("delegate_"):
109
+ sub_agent_name = tool_name.replace("delegate_", "", 1)
110
+ return f"Sub-Agent: {sub_agent_name}"
111
+ return None
112
+
113
+
114
+ def _strip_path_and_extension(tool_name: str) -> str:
115
+ """Return tool name without path segments or extensions."""
116
+ filename = tool_name.rsplit("/", 1)[-1]
117
+ base_name = filename.split(".", 1)[0]
118
+ return base_name
119
+
120
+
91
121
  def format_tool_title(tool_name: str) -> str:
92
122
  """Format tool name for panel title display.
93
123
 
@@ -99,20 +129,13 @@ def format_tool_title(tool_name: str) -> str:
99
129
  """
100
130
  # Check if this is a delegation tool
101
131
  if is_delegation_tool(tool_name):
102
- # Extract the sub-agent name from delegation tool names
103
- if tool_name.startswith("delegate_to_"):
104
- sub_agent_name = tool_name.replace("delegate_to_", "")
105
- return f"Sub-Agent: {sub_agent_name}"
106
- elif tool_name.startswith("delegate_"):
107
- sub_agent_name = tool_name.replace("delegate_", "")
108
- return f"Sub-Agent: {sub_agent_name}"
132
+ delegation_title = _delegation_tool_title(tool_name)
133
+ if delegation_title:
134
+ return delegation_title
109
135
 
110
136
  # For regular tools, clean up the name
111
137
  # Remove file path prefixes if present
112
- if "/" in tool_name:
113
- tool_name = tool_name.split("/")[-1]
114
- if "." in tool_name:
115
- tool_name = tool_name.split(".")[0]
138
+ clean_name = _strip_path_and_extension(tool_name)
116
139
 
117
140
  # Convert snake_case to Title Case
118
- return tool_name.replace("_", " ").title()
141
+ return clean_name.replace("_", " ").title()
@@ -38,7 +38,7 @@ class StreamProcessor:
38
38
  Returns:
39
39
  Dictionary with extracted metadata
40
40
  """
41
- metadata = event.get("metadata", {})
41
+ metadata = event.get("metadata") or {}
42
42
  # Update server elapsed timing if backend provides it
43
43
  try:
44
44
  t = metadata.get("time")
@@ -49,8 +49,8 @@ class StreamProcessor:
49
49
 
50
50
  return {
51
51
  "kind": metadata.get("kind") if metadata else event.get("kind"),
52
- "task_id": event.get("task_id"),
53
- "context_id": event.get("context_id"),
52
+ "task_id": metadata.get("task_id") or event.get("task_id"),
53
+ "context_id": metadata.get("context_id") or event.get("context_id"),
54
54
  "content": event.get("content", ""),
55
55
  "status": metadata.get("status") if metadata else event.get("status"),
56
56
  "metadata": metadata,
@@ -0,0 +1,184 @@
1
+ """Keyboard-driven transcript toggling support for the live renderer.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import sys
11
+ import threading
12
+ import time
13
+ from typing import Any
14
+
15
+ try: # pragma: no cover - Windows-specific dependencies
16
+ import msvcrt # type: ignore[import]
17
+ except ImportError: # pragma: no cover - POSIX fallback
18
+ msvcrt = None # type: ignore[assignment]
19
+
20
+ if os.name != "nt": # pragma: no cover - POSIX-only imports
21
+ import select
22
+ import termios
23
+ import tty
24
+
25
+
26
+ CTRL_T = "\x14"
27
+
28
+
29
+ class TranscriptToggleController:
30
+ """Manage mid-run transcript toggling for RichStreamRenderer instances."""
31
+
32
+ def __init__(self, *, enabled: bool) -> None:
33
+ """Initialise controller.
34
+
35
+ Args:
36
+ enabled: Whether toggling should be active (usually gated by TTY checks).
37
+ """
38
+ self._enabled = enabled and bool(sys.stdin) and sys.stdin.isatty()
39
+ self._lock = threading.Lock()
40
+ self._posix_fd: int | None = None
41
+ self._posix_attrs: list[int] | None = None
42
+ self._active = False
43
+ self._stop_event = threading.Event()
44
+ self._poll_thread: threading.Thread | None = None
45
+
46
+ @property
47
+ def enabled(self) -> bool:
48
+ """Return True when controller is able to process keypresses."""
49
+ return self._enabled
50
+
51
+ def on_stream_start(self, renderer: Any) -> None:
52
+ """Prepare terminal state before streaming begins."""
53
+ if not self._enabled:
54
+ return
55
+
56
+ if os.name == "nt": # pragma: no cover - Windows behaviour not in CI
57
+ self._active = True
58
+ self._start_polling_thread(renderer)
59
+ return
60
+
61
+ fd = sys.stdin.fileno()
62
+ try:
63
+ attrs = termios.tcgetattr(fd)
64
+ except Exception:
65
+ self._enabled = False
66
+ return
67
+
68
+ try:
69
+ tty.setcbreak(fd)
70
+ except Exception:
71
+ try:
72
+ termios.tcsetattr(fd, termios.TCSADRAIN, attrs)
73
+ except Exception:
74
+ pass
75
+ self._enabled = False
76
+ return
77
+
78
+ with self._lock:
79
+ self._posix_fd = fd
80
+ self._posix_attrs = attrs
81
+ self._active = True
82
+
83
+ self._start_polling_thread(renderer)
84
+
85
+ def on_stream_complete(self) -> None:
86
+ """Restore terminal state when streaming ends."""
87
+ if not self._active:
88
+ return
89
+
90
+ self._stop_polling_thread()
91
+
92
+ if os.name == "nt": # pragma: no cover - Windows behaviour not in CI
93
+ self._active = False
94
+ return
95
+
96
+ with self._lock:
97
+ fd = self._posix_fd
98
+ attrs = self._posix_attrs
99
+ self._posix_fd = None
100
+ self._posix_attrs = None
101
+ self._active = False
102
+
103
+ if fd is None or attrs is None:
104
+ return
105
+
106
+ try:
107
+ termios.tcsetattr(fd, termios.TCSADRAIN, attrs)
108
+ except Exception:
109
+ pass
110
+
111
+ def poll(self, renderer: Any) -> None:
112
+ """Poll for toggle keypresses and update renderer if needed."""
113
+ if not self._active:
114
+ return
115
+
116
+ if os.name == "nt": # pragma: no cover - Windows behaviour not in CI
117
+ self._poll_windows(renderer)
118
+ else:
119
+ self._poll_posix(renderer)
120
+
121
+ # ------------------------------------------------------------------
122
+ # Platform-specific polling
123
+ # ------------------------------------------------------------------
124
+ def _poll_windows(self, renderer: Any) -> None:
125
+ if not msvcrt: # pragma: no cover - safety guard
126
+ return
127
+
128
+ while msvcrt.kbhit():
129
+ ch = msvcrt.getwch()
130
+ if ch == CTRL_T:
131
+ renderer.toggle_transcript_mode()
132
+
133
+ def _poll_posix(self, renderer: Any) -> None: # pragma: no cover - requires TTY
134
+ fd = self._posix_fd
135
+ if fd is None:
136
+ return
137
+
138
+ while True:
139
+ readable, _, _ = select.select([fd], [], [], 0)
140
+ if not readable:
141
+ return
142
+
143
+ try:
144
+ data = os.read(fd, 1)
145
+ except Exception:
146
+ return
147
+
148
+ if not data:
149
+ return
150
+
151
+ ch = data.decode(errors="ignore")
152
+ if ch == CTRL_T:
153
+ renderer.toggle_transcript_mode()
154
+
155
+ def _start_polling_thread(self, renderer: Any) -> None:
156
+ if self._poll_thread and self._poll_thread.is_alive():
157
+ return
158
+ if not self._active:
159
+ return
160
+
161
+ self._stop_event.clear()
162
+ self._poll_thread = threading.Thread(
163
+ target=self._poll_loop, args=(renderer,), daemon=True
164
+ )
165
+ self._poll_thread.start()
166
+
167
+ def _stop_polling_thread(self) -> None:
168
+ self._stop_event.set()
169
+ thread = self._poll_thread
170
+ if thread and thread.is_alive():
171
+ thread.join(timeout=0.2)
172
+ self._poll_thread = None
173
+
174
+ def _poll_loop(self, renderer: Any) -> None:
175
+ while self._active and not self._stop_event.is_set():
176
+ try:
177
+ if os.name == "nt":
178
+ self._poll_windows(renderer)
179
+ else:
180
+ self._poll_posix(renderer)
181
+ except Exception:
182
+ # Never let background polling disrupt the main stream
183
+ pass
184
+ time.sleep(0.05)
@@ -0,0 +1,102 @@
1
+ """State container for hierarchical renderer steps.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Iterator
10
+ from dataclasses import dataclass, field
11
+
12
+ from glaip_sdk.utils.rendering.models import Step
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class StepTreeState:
17
+ """Track hierarchical ordering, buffers, and pruning metadata."""
18
+
19
+ max_steps: int = 200
20
+ root_order: list[str] = field(default_factory=list)
21
+ child_map: dict[str, list[str]] = field(default_factory=dict)
22
+ buffered_children: dict[str, list[str]] = field(default_factory=dict)
23
+ running_by_context: dict[tuple[str | None, str | None], set[str]] = field(
24
+ default_factory=dict
25
+ )
26
+ retained_ids: set[str] = field(default_factory=set)
27
+ step_index: dict[str, Step] = field(default_factory=dict)
28
+ pending_branch_failures: set[str] = field(default_factory=set)
29
+
30
+ def link_root(self, step_id: str) -> None:
31
+ """Ensure a step id is present in the root ordering."""
32
+ if step_id not in self.root_order:
33
+ self.root_order.append(step_id)
34
+
35
+ def unlink_root(self, step_id: str) -> None:
36
+ """Remove a step id from the root ordering if present."""
37
+ if step_id in self.root_order:
38
+ self.root_order.remove(step_id)
39
+
40
+ def link_child(self, parent_id: str, child_id: str) -> None:
41
+ """Attach a child step to a parent."""
42
+ children = self.child_map.setdefault(parent_id, [])
43
+ if child_id not in children:
44
+ children.append(child_id)
45
+
46
+ def unlink_child(self, parent_id: str, child_id: str) -> None:
47
+ """Detach a child from a parent."""
48
+ children = self.child_map.get(parent_id)
49
+ if not children:
50
+ return
51
+
52
+ if child_id in children:
53
+ children.remove(child_id)
54
+ # Clean up if the list is now empty
55
+ if len(children) == 0:
56
+ self.child_map.pop(parent_id, None)
57
+
58
+ def buffer_child(self, parent_id: str, child_id: str) -> None:
59
+ """Track a child that is waiting for its parent to appear."""
60
+ queue = self.buffered_children.setdefault(parent_id, [])
61
+ if child_id not in queue:
62
+ queue.append(child_id)
63
+
64
+ def pop_buffered_children(self, parent_id: str) -> list[str]:
65
+ """Return any buffered children for a parent."""
66
+ return self.buffered_children.pop(parent_id, [])
67
+
68
+ def discard_running(self, step_id: str) -> None:
69
+ """Remove a step from running context tracking."""
70
+ for key, running in tuple(self.running_by_context.items()):
71
+ if step_id in running:
72
+ running.discard(step_id)
73
+ if not running:
74
+ self.running_by_context.pop(key, None)
75
+
76
+ def iter_visible_tree(self) -> Iterator[tuple[str, tuple[bool, ...]]]:
77
+ """Yield step ids in depth-first order alongside branch metadata.
78
+
79
+ Returns:
80
+ Iterator of (step_id, branch_state) tuples where branch_state
81
+ captures whether each ancestor was the last child. This data
82
+ is later used by rendering helpers to draw connectors such as
83
+ `│`, `├─`, and `└─` consistently.
84
+ """
85
+ roots = tuple(self.root_order)
86
+ total_roots = len(roots)
87
+ for index, root_id in enumerate(roots):
88
+ yield root_id, ()
89
+ ancestor_state = (index == total_roots - 1,)
90
+ yield from self._walk_children(root_id, ancestor_state)
91
+
92
+ def _walk_children(
93
+ self, parent_id: str, ancestor_state: tuple[bool, ...]
94
+ ) -> Iterator[tuple[str, tuple[bool, ...]]]:
95
+ """Depth-first traversal helper yielding children with ancestry info."""
96
+ children = self.child_map.get(parent_id, [])
97
+ total_children = len(children)
98
+ for idx, child_id in enumerate(children):
99
+ is_last = idx == total_children - 1
100
+ branch_state = ancestor_state + (is_last,)
101
+ yield child_id, branch_state
102
+ yield from self._walk_children(child_id, branch_state)