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.
- glaip_sdk/_version.py +2 -2
- glaip_sdk/branding.py +27 -2
- glaip_sdk/cli/auth.py +93 -28
- glaip_sdk/cli/commands/__init__.py +2 -2
- glaip_sdk/cli/commands/agents.py +127 -21
- glaip_sdk/cli/commands/configure.py +141 -90
- glaip_sdk/cli/commands/mcps.py +82 -31
- glaip_sdk/cli/commands/models.py +4 -3
- glaip_sdk/cli/commands/tools.py +27 -14
- glaip_sdk/cli/commands/update.py +66 -0
- glaip_sdk/cli/config.py +13 -2
- glaip_sdk/cli/display.py +35 -26
- glaip_sdk/cli/io.py +14 -5
- glaip_sdk/cli/main.py +185 -73
- glaip_sdk/cli/pager.py +2 -1
- glaip_sdk/cli/resolution.py +4 -1
- glaip_sdk/cli/slash/__init__.py +3 -4
- glaip_sdk/cli/slash/agent_session.py +88 -36
- glaip_sdk/cli/slash/prompt.py +20 -48
- glaip_sdk/cli/slash/session.py +437 -189
- glaip_sdk/cli/transcript/__init__.py +71 -0
- glaip_sdk/cli/transcript/cache.py +338 -0
- glaip_sdk/cli/transcript/capture.py +278 -0
- glaip_sdk/cli/transcript/export.py +38 -0
- glaip_sdk/cli/transcript/launcher.py +79 -0
- glaip_sdk/cli/transcript/viewer.py +794 -0
- glaip_sdk/cli/update_notifier.py +29 -5
- glaip_sdk/cli/utils.py +255 -74
- glaip_sdk/client/agents.py +3 -1
- glaip_sdk/client/run_rendering.py +126 -21
- glaip_sdk/icons.py +25 -0
- glaip_sdk/models.py +6 -0
- glaip_sdk/rich_components.py +29 -1
- glaip_sdk/utils/__init__.py +1 -1
- glaip_sdk/utils/client_utils.py +6 -4
- glaip_sdk/utils/display.py +61 -32
- glaip_sdk/utils/rendering/formatting.py +55 -11
- glaip_sdk/utils/rendering/models.py +15 -2
- glaip_sdk/utils/rendering/renderer/__init__.py +0 -2
- glaip_sdk/utils/rendering/renderer/base.py +1287 -227
- glaip_sdk/utils/rendering/renderer/config.py +3 -5
- glaip_sdk/utils/rendering/renderer/debug.py +73 -16
- glaip_sdk/utils/rendering/renderer/panels.py +27 -15
- glaip_sdk/utils/rendering/renderer/progress.py +61 -38
- glaip_sdk/utils/rendering/renderer/stream.py +3 -3
- glaip_sdk/utils/rendering/renderer/toggle.py +184 -0
- glaip_sdk/utils/rendering/step_tree_state.py +102 -0
- glaip_sdk/utils/rendering/steps.py +944 -16
- glaip_sdk/utils/serialization.py +5 -2
- glaip_sdk/utils/validation.py +1 -2
- {glaip_sdk-0.0.19.dist-info → glaip_sdk-0.1.0.dist-info}/METADATA +12 -1
- glaip_sdk-0.1.0.dist-info/RECORD +82 -0
- glaip_sdk/utils/rich_utils.py +0 -29
- glaip_sdk-0.0.19.dist-info/RECORD +0 -73
- {glaip_sdk-0.0.19.dist-info → glaip_sdk-0.1.0.dist-info}/WHEEL +0 -0
- {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 =
|
|
33
|
-
snapshot_max_lines: int =
|
|
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
|
|
19
|
-
"""
|
|
20
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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":
|
|
79
|
-
"content":
|
|
80
|
-
"final_response":
|
|
81
|
-
"status":
|
|
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],
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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=
|
|
43
|
+
border_style=SUCCESS,
|
|
43
44
|
)
|
|
44
45
|
else:
|
|
45
46
|
return AIPPanel(
|
|
46
47
|
_spinner_renderable(),
|
|
47
48
|
title=title,
|
|
48
|
-
border_style=
|
|
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 =
|
|
75
|
+
mark = "✓" if status == "finished" else ""
|
|
76
|
+
border_style = WARNING if is_delegation else PRIMARY
|
|
73
77
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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=
|
|
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 =
|
|
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=
|
|
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=
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
103
|
-
if
|
|
104
|
-
|
|
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
|
-
|
|
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
|
|
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)
|