glaip-sdk 0.7.7__py3-none-any.whl → 0.7.12__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.
@@ -11,6 +11,8 @@ import importlib
11
11
  import os
12
12
  import shlex
13
13
  import sys
14
+ import threading
15
+ import time
14
16
  from collections.abc import Callable, Iterable
15
17
  from dataclasses import dataclass
16
18
  from difflib import get_close_matches
@@ -19,6 +21,7 @@ from typing import Any
19
21
 
20
22
  import click
21
23
  from rich.console import Console, Group
24
+ from rich.live import Live
22
25
  from rich.text import Text
23
26
 
24
27
  from glaip_sdk.branding import (
@@ -33,16 +36,20 @@ from glaip_sdk.branding import (
33
36
  SUCCESS_STYLE,
34
37
  WARNING_STYLE,
35
38
  AIPBranding,
39
+ LogoAnimator,
36
40
  )
37
- from glaip_sdk.cli.auth import resolve_api_url_from_context
38
41
  from glaip_sdk.cli.account_store import get_account_store
42
+ from glaip_sdk.cli.auth import resolve_api_url_from_context
39
43
  from glaip_sdk.cli.commands import transcripts as transcripts_cmd
40
44
  from glaip_sdk.cli.commands.configure import _configure_interactive, load_config
41
45
  from glaip_sdk.cli.commands.update import update_command
42
- from glaip_sdk.cli.hints import format_command_hint
46
+ from glaip_sdk.cli.core.context import get_client, restore_slash_session_context
47
+ from glaip_sdk.cli.core.output import format_size
48
+ from glaip_sdk.cli.core.prompting import _fuzzy_pick_for_resources
49
+ from glaip_sdk.cli.hints import command_hint, format_command_hint
43
50
  from glaip_sdk.cli.slash.accounts_controller import AccountsController
44
- from glaip_sdk.cli.slash.agent_session import AgentRunSession
45
51
  from glaip_sdk.cli.slash.accounts_shared import env_credentials_present
52
+ from glaip_sdk.cli.slash.agent_session import AgentRunSession
46
53
  from glaip_sdk.cli.slash.prompt import (
47
54
  FormattedText,
48
55
  PromptSession,
@@ -59,10 +66,6 @@ from glaip_sdk.cli.transcript import (
59
66
  )
60
67
  from glaip_sdk.cli.transcript.viewer import ViewerContext, run_viewer_session
61
68
  from glaip_sdk.cli.update_notifier import maybe_notify_update
62
- from glaip_sdk.cli.core.context import get_client, restore_slash_session_context
63
- from glaip_sdk.cli.core.output import format_size
64
- from glaip_sdk.cli.core.prompting import _fuzzy_pick_for_resources
65
- from glaip_sdk.cli.hints import command_hint
66
69
  from glaip_sdk.rich_components import AIPGrid, AIPPanel, AIPTable
67
70
 
68
71
  SlashHandler = Callable[["SlashSession", list[str], bool], bool]
@@ -144,6 +147,22 @@ def _quick_action_scope(action: dict[str, Any]) -> str:
144
147
  return "global"
145
148
 
146
149
 
150
+ @dataclass
151
+ class AnimationState:
152
+ """State for logo animation shared between threads.
153
+
154
+ Uses mutable lists for integer values to allow thread-safe updates
155
+ without requiring locks or atomic operations.
156
+ """
157
+
158
+ pulse_step: list[int] # Current animation step position
159
+ pulse_direction: list[int] # Direction of pulse (1 or -1)
160
+ step_size: list[int] # Step size for animation
161
+ current_status: list[str] # Current status message
162
+ animation_running: threading.Event # Event signaling animation is running
163
+ stop_requested: threading.Event # Event signaling stop was requested
164
+
165
+
147
166
  class SlashSession:
148
167
  """Interactive command palette controller."""
149
168
 
@@ -155,7 +174,11 @@ class SlashSession:
155
174
  console: Optional console instance, creates default if None
156
175
  """
157
176
  self.ctx = ctx
158
- self.console = console or Console()
177
+ self._interactive = bool(sys.stdin.isatty() and sys.stdout.isatty())
178
+ if console is None:
179
+ self.console = AIPBranding._make_console(force_terminal=self._interactive, soft_wrap=False)
180
+ else:
181
+ self.console = console
159
182
  self._commands: dict[str, SlashCommand] = {}
160
183
  self._unique_commands: dict[str, SlashCommand] = {}
161
184
  self._contextual_commands: dict[str, str] = {}
@@ -164,7 +187,6 @@ class SlashSession:
164
187
  self.recent_agents: list[dict[str, str]] = []
165
188
  self.last_run_input: str | None = None
166
189
  self._should_exit = False
167
- self._interactive = bool(sys.stdin.isatty() and sys.stdout.isatty())
168
190
  self._config_cache: dict[str, Any] | None = None
169
191
  self._welcome_rendered = False
170
192
  self._active_renderer: Any | None = None
@@ -190,6 +212,15 @@ class SlashSession:
190
212
  self._agent_transcript_ready: dict[str, str] = {}
191
213
  self.tui_ctx: TUIContext | None = None
192
214
 
215
+ # Animation configuration constants
216
+ ANIMATION_FPS = 20
217
+ ANIMATION_FRAME_DURATION = 1.0 / ANIMATION_FPS # 0.05 seconds
218
+ ANIMATION_STARTUP_DELAY = 0.1 # Delay to ensure animation starts
219
+
220
+ # Startup UI constants
221
+ INITIALIZING_STATUS = "Initializing..."
222
+ CLI_HEADING_MARKUP = "[bold]>_ GDP Labs AI Agents Package (AIP CLI)[/bold]"
223
+
193
224
  # ------------------------------------------------------------------
194
225
  # Session orchestration
195
226
  # ------------------------------------------------------------------
@@ -218,22 +249,6 @@ class SlashSession:
218
249
 
219
250
  def run(self, initial_commands: Iterable[str] | None = None) -> None:
220
251
  """Start the command palette session loop."""
221
- # Initialize TUI context asynchronously
222
- try:
223
- self.tui_ctx = asyncio.run(TUIContext.create())
224
- except RuntimeError:
225
- try:
226
- loop = asyncio.get_event_loop()
227
- except RuntimeError:
228
- self.tui_ctx = None
229
- else:
230
- if loop.is_running():
231
- self.tui_ctx = None
232
- else:
233
- self.tui_ctx = loop.run_until_complete(TUIContext.create())
234
- except Exception:
235
- self.tui_ctx = None
236
-
237
252
  ctx_obj = self.ctx.obj if isinstance(self.ctx.obj, dict) else None
238
253
  previous_session = None
239
254
  if ctx_obj is not None:
@@ -245,11 +260,18 @@ class SlashSession:
245
260
  self._run_non_interactive(initial_commands)
246
261
  return
247
262
 
248
- if not self._ensure_configuration():
263
+ # Use animated logo during initialization if supported
264
+ animator = LogoAnimator(console=self.console)
265
+ if animator.should_animate() and self._interactive:
266
+ config_available = self._run_with_animated_logo(animator)
267
+ else:
268
+ # Fallback to static logo for non-TTY or NO_COLOR
269
+ config_available = self._run_with_static_logo(animator)
270
+
271
+ if not config_available:
249
272
  return
250
273
 
251
- self._maybe_show_update_prompt()
252
- self._render_header(initial=not self._welcome_rendered)
274
+ self._render_header(initial=not self._welcome_rendered, show_branding=False)
253
275
  if not self._default_actions_shown:
254
276
  self._show_default_quick_actions()
255
277
  self._run_interactive_loop()
@@ -257,6 +279,282 @@ class SlashSession:
257
279
  if ctx_obj is not None:
258
280
  restore_slash_session_context(ctx_obj, previous_session)
259
281
 
282
+ def _initialize_tui_context(self) -> None:
283
+ """Initialize TUI context with error handling.
284
+
285
+ Sets self.tui_ctx to None if initialization fails.
286
+ """
287
+ try:
288
+ self.tui_ctx = asyncio.run(TUIContext.create(detect_osc11=False))
289
+ except RuntimeError:
290
+ try:
291
+ loop = asyncio.get_event_loop()
292
+ except RuntimeError:
293
+ self.tui_ctx = None
294
+ else:
295
+ if loop.is_running():
296
+ self.tui_ctx = None
297
+ else:
298
+ self.tui_ctx = loop.run_until_complete(TUIContext.create(detect_osc11=False))
299
+ except Exception:
300
+ self.tui_ctx = None
301
+
302
+ def _run_initialization_tasks(
303
+ self,
304
+ current_status: list[str],
305
+ animation_running: threading.Event,
306
+ status_callback: Callable[[str], None] | None = None,
307
+ ) -> bool:
308
+ """Run initialization tasks with status updates.
309
+
310
+ Args:
311
+ current_status: Mutable list with current status message.
312
+ animation_running: Event to signal animation state.
313
+ status_callback: Optional callback to invoke when status changes.
314
+
315
+ Returns:
316
+ True if configuration is available, False otherwise.
317
+ """
318
+ # Task 1: TUI Context.
319
+ current_status[0] = "Detecting terminal..."
320
+ if status_callback:
321
+ status_callback(current_status[0])
322
+ self._initialize_tui_context()
323
+
324
+ # Task 2: Configuration.
325
+ current_status[0] = "Connecting to API..."
326
+ if status_callback:
327
+ status_callback(current_status[0])
328
+ if not self._ensure_configuration():
329
+ animation_running.clear()
330
+ return False
331
+
332
+ # Task 3: Updates.
333
+ current_status[0] = "Checking for updates..."
334
+ if status_callback:
335
+ status_callback(current_status[0])
336
+ self._maybe_show_update_prompt()
337
+ return True
338
+
339
+ def _update_pulse_step(
340
+ self,
341
+ state: AnimationState,
342
+ animator: LogoAnimator,
343
+ ) -> bool:
344
+ """Update pulse step and direction.
345
+
346
+ Args:
347
+ state: Animation state container.
348
+ animator: LogoAnimator instance for animation.
349
+
350
+ Returns:
351
+ True if animation should continue, False if should stop.
352
+ """
353
+ state.pulse_step[0] += state.pulse_direction[0] * state.step_size[0]
354
+ if state.pulse_step[0] >= animator.max_width + 5:
355
+ state.pulse_step[0] = animator.max_width + 5
356
+ state.pulse_direction[0] = -1
357
+ return not state.stop_requested.is_set()
358
+ if state.pulse_step[0] <= -5:
359
+ state.pulse_step[0] = -5
360
+ state.pulse_direction[0] = 1
361
+ return not state.stop_requested.is_set()
362
+ return True
363
+
364
+ def _create_animation_updater(
365
+ self,
366
+ animator: LogoAnimator,
367
+ state: AnimationState,
368
+ heading: Text,
369
+ ) -> Callable[[Live], None]:
370
+ """Create animation update function for background thread.
371
+
372
+ Args:
373
+ animator: LogoAnimator instance for animation.
374
+ state: Animation state container.
375
+ heading: Text heading for frames.
376
+
377
+ Returns:
378
+ Function to update animation in background thread.
379
+ """
380
+
381
+ def build_frame(step: int, status_text: str) -> Group:
382
+ return Group(heading, Text(""), animator.generate_frame(step, status_text))
383
+
384
+ def update_animation(live: Live) -> None:
385
+ """Update animation in background thread."""
386
+ while state.animation_running.is_set():
387
+ # Calculate next step
388
+ if not self._update_pulse_step(state, animator):
389
+ break
390
+
391
+ # Update frame with current status
392
+ try:
393
+ live.update(build_frame(state.pulse_step[0], state.current_status[0]))
394
+ except Exception:
395
+ # Animation may be stopped, ignore errors
396
+ break
397
+ time.sleep(self.ANIMATION_FRAME_DURATION)
398
+ state.animation_running.clear()
399
+
400
+ return update_animation
401
+
402
+ def _stop_animation_thread(
403
+ self,
404
+ animation_thread: threading.Thread,
405
+ state: AnimationState,
406
+ ) -> None:
407
+ """Stop animation thread gracefully.
408
+
409
+ Args:
410
+ animation_thread: Thread running animation.
411
+ state: Animation state container.
412
+ """
413
+ state.stop_requested.set()
414
+ state.step_size[0] = 3
415
+ animation_thread.join(timeout=1.5)
416
+ if animation_thread.is_alive():
417
+ state.animation_running.clear()
418
+ animation_thread.join(timeout=0.2)
419
+
420
+ def _run_animated_initialization(
421
+ self,
422
+ live: Live,
423
+ animator: LogoAnimator,
424
+ state: AnimationState,
425
+ heading: Text,
426
+ banner: Text,
427
+ ) -> bool:
428
+ """Run initialization tasks with animated logo.
429
+
430
+ Args:
431
+ live: Live context for animation updates.
432
+ animator: LogoAnimator instance for animation.
433
+ state: Animation state container.
434
+ heading: Text heading for frames.
435
+ banner: Text banner for final display.
436
+
437
+ Returns:
438
+ True if configuration is available, False otherwise.
439
+ """
440
+
441
+ def build_banner() -> Group:
442
+ return Group(heading, Text(""), banner)
443
+
444
+ update_animation = self._create_animation_updater(
445
+ animator,
446
+ state,
447
+ heading,
448
+ )
449
+
450
+ # Start animation thread.
451
+ animation_thread = threading.Thread(target=update_animation, args=(live,), daemon=True)
452
+ animation_thread.start()
453
+
454
+ # Small delay to ensure animation starts.
455
+ time.sleep(self.ANIMATION_STARTUP_DELAY)
456
+
457
+ # Run initialization tasks.
458
+ if not self._run_initialization_tasks(state.current_status, state.animation_running, status_callback=None):
459
+ return False
460
+
461
+ # Stop animation and show final banner.
462
+ self._stop_animation_thread(animation_thread, state)
463
+ live.update(build_banner())
464
+ return True
465
+
466
+ def _run_with_animated_logo(self, animator: LogoAnimator) -> bool:
467
+ """Run initialization with animated logo.
468
+
469
+ Args:
470
+ animator: LogoAnimator instance for animation.
471
+
472
+ Returns:
473
+ True if configuration is available, False otherwise.
474
+ """
475
+ state = AnimationState(
476
+ pulse_step=[0], # Use list for mutable shared state.
477
+ pulse_direction=[1], # Use list for mutable shared state.
478
+ step_size=[1],
479
+ current_status=[self.INITIALIZING_STATUS],
480
+ animation_running=threading.Event(),
481
+ stop_requested=threading.Event(),
482
+ )
483
+ state.animation_running.set()
484
+ heading = Text.from_markup(self.CLI_HEADING_MARKUP)
485
+ banner = Text.from_markup(self._branding.get_welcome_banner())
486
+
487
+ def build_frame(step: int, status_text: str) -> Group:
488
+ return Group(heading, Text(""), animator.generate_frame(step, status_text))
489
+
490
+ try:
491
+ with Live(
492
+ build_frame(0, state.current_status[0]),
493
+ console=self.console,
494
+ refresh_per_second=self.ANIMATION_FPS,
495
+ transient=False,
496
+ ) as live:
497
+ return self._run_animated_initialization(
498
+ live,
499
+ animator,
500
+ state,
501
+ heading,
502
+ banner,
503
+ )
504
+ except KeyboardInterrupt:
505
+ # Graceful exit on Ctrl+C
506
+ state.animation_running.clear()
507
+ # Align with static path: show heading and cancellation message
508
+ heading = Text.from_markup(self.CLI_HEADING_MARKUP)
509
+ self.console.print(Group(heading, Text(""), animator.static_frame("Initialization cancelled.")))
510
+ return False
511
+
512
+ def _run_with_static_logo(self, animator: LogoAnimator) -> bool:
513
+ """Run initialization with static logo (non-TTY or NO_COLOR).
514
+
515
+ Args:
516
+ animator: LogoAnimator instance for static display.
517
+
518
+ Returns:
519
+ True if configuration is available, False otherwise.
520
+ """
521
+ heading = Text.from_markup(self.CLI_HEADING_MARKUP)
522
+ banner = Text.from_markup(self._branding.get_welcome_banner())
523
+
524
+ def build_frame(status_text: str) -> Group:
525
+ return Group(heading, Text(""), animator.static_frame(status_text))
526
+
527
+ def build_banner() -> Group:
528
+ return Group(heading, Text(""), banner)
529
+
530
+ try:
531
+ with Live(
532
+ build_frame(self.INITIALIZING_STATUS),
533
+ console=self.console,
534
+ refresh_per_second=4,
535
+ transient=False,
536
+ ) as live:
537
+ # Run initialization tasks with status updates, reusing shared logic.
538
+ current_status = [self.INITIALIZING_STATUS]
539
+ animation_running = threading.Event()
540
+ animation_running.set()
541
+
542
+ # Update Live display when status changes via callback.
543
+ def update_display(status: str) -> None:
544
+ """Update Live display with current status."""
545
+ live.update(build_frame(status))
546
+
547
+ if not self._run_initialization_tasks(
548
+ current_status, animation_running, status_callback=update_display
549
+ ):
550
+ return False
551
+
552
+ live.update(build_banner())
553
+ return True
554
+ except KeyboardInterrupt:
555
+ self.console.print(Group(heading, Text(""), animator.static_frame("Initialization cancelled.")))
556
+ return False
557
+
260
558
  def _run_interactive_loop(self) -> None:
261
559
  """Run the main interactive command loop."""
262
560
  while not self._should_exit:
@@ -1289,6 +1587,7 @@ class SlashSession:
1289
1587
  *,
1290
1588
  focus_agent: bool = False,
1291
1589
  initial: bool = False,
1590
+ show_branding: bool = True,
1292
1591
  ) -> None:
1293
1592
  """Render the session header with branding and status.
1294
1593
 
@@ -1296,14 +1595,16 @@ class SlashSession:
1296
1595
  active_agent: Optional active agent to display.
1297
1596
  focus_agent: Whether to focus on agent display.
1298
1597
  initial: Whether this is the initial render.
1598
+ show_branding: Whether to render the branding banner.
1299
1599
  """
1300
1600
  if focus_agent and active_agent is not None:
1301
1601
  self._render_focused_agent_header(active_agent)
1302
1602
  return
1303
1603
 
1304
1604
  full_header = initial or not self._welcome_rendered
1305
- if full_header:
1605
+ if full_header and show_branding:
1306
1606
  self._render_branding_banner()
1607
+ if full_header:
1307
1608
  self.console.rule(style=PRIMARY)
1308
1609
  self._render_main_header(active_agent, full=full_header)
1309
1610
  if full_header:
@@ -1313,7 +1614,7 @@ class SlashSession:
1313
1614
  def _render_branding_banner(self) -> None:
1314
1615
  """Render the GL AIP branding banner."""
1315
1616
  banner = self._branding.get_welcome_banner()
1316
- heading = "[bold]>_ GDP Labs AI Agents Package (AIP CLI)[/bold]"
1617
+ heading = self.CLI_HEADING_MARKUP
1317
1618
  self.console.print(heading)
1318
1619
  self.console.print()
1319
1620
  self.console.print(banner)
@@ -3,6 +3,18 @@
3
3
  * Keep layout compact: filter sits tight above the table; header shows active account.
4
4
  */
5
5
 
6
+ Screen {
7
+ layers: base toasts;
8
+ }
9
+
10
+ #toast-container {
11
+ width: 100%;
12
+ height: auto;
13
+ dock: top;
14
+ align: right top;
15
+ layer: toasts;
16
+ }
17
+
6
18
  #header-info {
7
19
  padding: 0 1 0 1;
8
20
  margin: 0;
@@ -86,3 +98,63 @@
86
98
  #form-actions {
87
99
  margin: 0 1 0 1;
88
100
  }
101
+
102
+ /* Harlequin Layout Styling */
103
+
104
+ #left-pane-title, #right-pane-title {
105
+ padding: 1;
106
+ text-style: bold;
107
+ border-bottom: solid $primary;
108
+ height: 3;
109
+ }
110
+
111
+ #harlequin-filter {
112
+ padding: 0 1;
113
+ margin: 1;
114
+ height: 3;
115
+ }
116
+
117
+ #harlequin-accounts-list {
118
+ padding: 0 1;
119
+ margin: 1;
120
+ height: 1fr;
121
+ }
122
+
123
+ #left-content {
124
+ height: 100%;
125
+ }
126
+
127
+ #right-content {
128
+ padding: 1;
129
+ height: 100%;
130
+ }
131
+
132
+ .detail-label {
133
+ padding: 0 1 0 0;
134
+ text-style: bold;
135
+ width: 12;
136
+ }
137
+
138
+ #detail-fields {
139
+ padding: 1 0;
140
+ height: auto;
141
+ }
142
+
143
+ #harlequin-detail-url,
144
+ #harlequin-detail-key,
145
+ #harlequin-detail-status {
146
+ padding: 0 1;
147
+ margin-bottom: 1;
148
+ }
149
+
150
+ #harlequin-detail-actions {
151
+ padding: 1 0;
152
+ margin-top: 1;
153
+ height: auto;
154
+ }
155
+
156
+ #harlequin-status {
157
+ padding: 1;
158
+ margin-top: 1;
159
+ height: auto;
160
+ }