dtSpark 1.0.4__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 (96) hide show
  1. dtSpark/__init__.py +0 -0
  2. dtSpark/_description.txt +1 -0
  3. dtSpark/_full_name.txt +1 -0
  4. dtSpark/_licence.txt +21 -0
  5. dtSpark/_metadata.yaml +6 -0
  6. dtSpark/_name.txt +1 -0
  7. dtSpark/_version.txt +1 -0
  8. dtSpark/aws/__init__.py +7 -0
  9. dtSpark/aws/authentication.py +296 -0
  10. dtSpark/aws/bedrock.py +578 -0
  11. dtSpark/aws/costs.py +318 -0
  12. dtSpark/aws/pricing.py +580 -0
  13. dtSpark/cli_interface.py +2645 -0
  14. dtSpark/conversation_manager.py +3050 -0
  15. dtSpark/core/__init__.py +12 -0
  16. dtSpark/core/application.py +3355 -0
  17. dtSpark/core/context_compaction.py +735 -0
  18. dtSpark/daemon/__init__.py +104 -0
  19. dtSpark/daemon/__main__.py +10 -0
  20. dtSpark/daemon/action_monitor.py +213 -0
  21. dtSpark/daemon/daemon_app.py +730 -0
  22. dtSpark/daemon/daemon_manager.py +289 -0
  23. dtSpark/daemon/execution_coordinator.py +194 -0
  24. dtSpark/daemon/pid_file.py +169 -0
  25. dtSpark/database/__init__.py +482 -0
  26. dtSpark/database/autonomous_actions.py +1191 -0
  27. dtSpark/database/backends.py +329 -0
  28. dtSpark/database/connection.py +122 -0
  29. dtSpark/database/conversations.py +520 -0
  30. dtSpark/database/credential_prompt.py +218 -0
  31. dtSpark/database/files.py +205 -0
  32. dtSpark/database/mcp_ops.py +355 -0
  33. dtSpark/database/messages.py +161 -0
  34. dtSpark/database/schema.py +673 -0
  35. dtSpark/database/tool_permissions.py +186 -0
  36. dtSpark/database/usage.py +167 -0
  37. dtSpark/files/__init__.py +4 -0
  38. dtSpark/files/manager.py +322 -0
  39. dtSpark/launch.py +39 -0
  40. dtSpark/limits/__init__.py +10 -0
  41. dtSpark/limits/costs.py +296 -0
  42. dtSpark/limits/tokens.py +342 -0
  43. dtSpark/llm/__init__.py +17 -0
  44. dtSpark/llm/anthropic_direct.py +446 -0
  45. dtSpark/llm/base.py +146 -0
  46. dtSpark/llm/context_limits.py +438 -0
  47. dtSpark/llm/manager.py +177 -0
  48. dtSpark/llm/ollama.py +578 -0
  49. dtSpark/mcp_integration/__init__.py +5 -0
  50. dtSpark/mcp_integration/manager.py +653 -0
  51. dtSpark/mcp_integration/tool_selector.py +225 -0
  52. dtSpark/resources/config.yaml.template +631 -0
  53. dtSpark/safety/__init__.py +22 -0
  54. dtSpark/safety/llm_service.py +111 -0
  55. dtSpark/safety/patterns.py +229 -0
  56. dtSpark/safety/prompt_inspector.py +442 -0
  57. dtSpark/safety/violation_logger.py +346 -0
  58. dtSpark/scheduler/__init__.py +20 -0
  59. dtSpark/scheduler/creation_tools.py +599 -0
  60. dtSpark/scheduler/execution_queue.py +159 -0
  61. dtSpark/scheduler/executor.py +1152 -0
  62. dtSpark/scheduler/manager.py +395 -0
  63. dtSpark/tools/__init__.py +4 -0
  64. dtSpark/tools/builtin.py +833 -0
  65. dtSpark/web/__init__.py +20 -0
  66. dtSpark/web/auth.py +152 -0
  67. dtSpark/web/dependencies.py +37 -0
  68. dtSpark/web/endpoints/__init__.py +17 -0
  69. dtSpark/web/endpoints/autonomous_actions.py +1125 -0
  70. dtSpark/web/endpoints/chat.py +621 -0
  71. dtSpark/web/endpoints/conversations.py +353 -0
  72. dtSpark/web/endpoints/main_menu.py +547 -0
  73. dtSpark/web/endpoints/streaming.py +421 -0
  74. dtSpark/web/server.py +578 -0
  75. dtSpark/web/session.py +167 -0
  76. dtSpark/web/ssl_utils.py +195 -0
  77. dtSpark/web/static/css/dark-theme.css +427 -0
  78. dtSpark/web/static/js/actions.js +1101 -0
  79. dtSpark/web/static/js/chat.js +614 -0
  80. dtSpark/web/static/js/main.js +496 -0
  81. dtSpark/web/static/js/sse-client.js +242 -0
  82. dtSpark/web/templates/actions.html +408 -0
  83. dtSpark/web/templates/base.html +93 -0
  84. dtSpark/web/templates/chat.html +814 -0
  85. dtSpark/web/templates/conversations.html +350 -0
  86. dtSpark/web/templates/goodbye.html +81 -0
  87. dtSpark/web/templates/login.html +90 -0
  88. dtSpark/web/templates/main_menu.html +983 -0
  89. dtSpark/web/templates/new_conversation.html +191 -0
  90. dtSpark/web/web_interface.py +137 -0
  91. dtspark-1.0.4.dist-info/METADATA +187 -0
  92. dtspark-1.0.4.dist-info/RECORD +96 -0
  93. dtspark-1.0.4.dist-info/WHEEL +5 -0
  94. dtspark-1.0.4.dist-info/entry_points.txt +3 -0
  95. dtspark-1.0.4.dist-info/licenses/LICENSE +21 -0
  96. dtspark-1.0.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2645 @@
1
+ """
2
+ CLI interface module for user interaction using Rich terminal UI.
3
+
4
+ This module provides functionality for:
5
+ - Displaying menus and prompts with beautiful formatting
6
+ - Handling user input
7
+ - Progress tracking for initialisation
8
+ - Application splash screens
9
+ """
10
+
11
+ from typing import List, Dict, Optional
12
+ from datetime import datetime
13
+
14
+ from rich.console import Console
15
+ from rich.panel import Panel
16
+ from rich.table import Table
17
+ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TimeElapsedColumn
18
+ from rich.text import Text
19
+ from rich.prompt import Prompt, Confirm
20
+ from rich import box
21
+ from rich.align import Align
22
+ from rich.columns import Columns
23
+ from rich.markdown import Markdown
24
+ from rich.live import Live
25
+ from rich.spinner import Spinner
26
+ import time
27
+ import re
28
+
29
+
30
+ def extract_friendly_model_name(model_id: str) -> str:
31
+ """
32
+ Extract a human-friendly model name from a full model ID or ARN.
33
+
34
+ Examples:
35
+ - 'arn:aws:bedrock:...:inference-profile/au.anthropic.claude-sonnet-4-5-20250929-v1:0'
36
+ → 'Claude Sonnet 4.5'
37
+ - 'anthropic.claude-3-5-sonnet-20241022-v2:0' → 'Claude 3.5 Sonnet'
38
+ - 'meta.llama3-1-70b-instruct-v1:0' → 'Llama 3.1 70B Instruct'
39
+
40
+ Args:
41
+ model_id: Full model ID, ARN, or inference profile
42
+
43
+ Returns:
44
+ Human-readable model name
45
+ """
46
+ if not model_id:
47
+ return "Unknown"
48
+
49
+ # Extract the core model identifier from ARN or full path
50
+ model_lower = model_id.lower()
51
+
52
+ # Handle inference profile ARNs
53
+ if 'inference-profile/' in model_lower:
54
+ # Extract after inference-profile/
55
+ match = re.search(r'inference-profile/([^:]+)', model_id, re.IGNORECASE)
56
+ if match:
57
+ model_lower = match.group(1).lower()
58
+
59
+ # Claude model patterns
60
+ claude_patterns = [
61
+ (r'claude-opus-4\.5|claude-opus-4-5', 'Claude Opus 4.5'),
62
+ (r'claude-sonnet-4\.5|claude-sonnet-4-5', 'Claude Sonnet 4.5'),
63
+ (r'claude-opus-4(?!\.)', 'Claude Opus 4'),
64
+ (r'claude-sonnet-4(?!\.)', 'Claude Sonnet 4'),
65
+ (r'claude-3-5-sonnet', 'Claude 3.5 Sonnet'),
66
+ (r'claude-3-5-haiku', 'Claude 3.5 Haiku'),
67
+ (r'claude-3-opus', 'Claude 3 Opus'),
68
+ (r'claude-3-sonnet', 'Claude 3 Sonnet'),
69
+ (r'claude-3-haiku', 'Claude 3 Haiku'),
70
+ (r'claude-2\.1', 'Claude 2.1'),
71
+ (r'claude-2', 'Claude 2'),
72
+ (r'claude-instant', 'Claude Instant'),
73
+ ]
74
+
75
+ for pattern, name in claude_patterns:
76
+ if re.search(pattern, model_lower):
77
+ return name
78
+
79
+ # Llama patterns
80
+ llama_patterns = [
81
+ (r'llama3-1-(\d+)b', lambda m: f"Llama 3.1 {m.group(1)}B"),
82
+ (r'llama3\.2', 'Llama 3.2'),
83
+ (r'llama3', 'Llama 3'),
84
+ (r'llama2-(\d+)b', lambda m: f"Llama 2 {m.group(1)}B"),
85
+ ]
86
+
87
+ for pattern, name in llama_patterns:
88
+ match = re.search(pattern, model_lower)
89
+ if match:
90
+ if callable(name):
91
+ return name(match)
92
+ return name
93
+
94
+ # Mistral patterns
95
+ if 'mistral-large' in model_lower:
96
+ return 'Mistral Large'
97
+ if 'mistral-small' in model_lower:
98
+ return 'Mistral Small'
99
+ if 'mistral' in model_lower:
100
+ return 'Mistral'
101
+
102
+ # Amazon Titan patterns
103
+ if 'titan-text-express' in model_lower:
104
+ return 'Amazon Titan Text Express'
105
+ if 'titan-text-lite' in model_lower:
106
+ return 'Amazon Titan Text Lite'
107
+ if 'titan' in model_lower:
108
+ return 'Amazon Titan'
109
+
110
+ # Cohere patterns
111
+ if 'cohere.command-r-plus' in model_lower:
112
+ return 'Cohere Command R+'
113
+ if 'cohere.command-r' in model_lower:
114
+ return 'Cohere Command R'
115
+ if 'cohere' in model_lower:
116
+ return 'Cohere'
117
+
118
+ # If no pattern matched, try to clean up the model ID
119
+ # Remove common prefixes and suffixes
120
+ cleaned = model_id
121
+ for prefix in ['arn:aws:bedrock:', 'anthropic.', 'meta.', 'amazon.', 'cohere.', 'mistral.', 'au.', 'us.', 'eu.']:
122
+ if cleaned.lower().startswith(prefix):
123
+ cleaned = cleaned[len(prefix):]
124
+
125
+ # Remove version suffixes like -v1:0, -20241022-v2:0
126
+ cleaned = re.sub(r'-\d{8}-v\d+:\d+$', '', cleaned)
127
+ cleaned = re.sub(r'-v\d+:\d+$', '', cleaned)
128
+ cleaned = re.sub(r':\d+$', '', cleaned)
129
+
130
+ # Title case and limit length
131
+ if len(cleaned) > 50:
132
+ cleaned = cleaned[:47] + '...'
133
+
134
+ return cleaned
135
+
136
+
137
+ class StatusIndicator:
138
+ """Context manager for displaying an animated status indicator with elapsed time."""
139
+
140
+ def __init__(self, console: Console, message: str, cli_interface=None):
141
+ """
142
+ Initialise status indicator.
143
+
144
+ Args:
145
+ console: Rich console instance
146
+ message: Status message to display
147
+ cli_interface: Optional CLIInterface to register with for pause/resume control
148
+ """
149
+ self.console = console
150
+ self.message = message
151
+ self.start_time = None
152
+ self.live = None
153
+ self.cli_interface = cli_interface
154
+ self._is_paused = False
155
+
156
+ def __enter__(self):
157
+ """Start the status indicator."""
158
+ self.start_time = time.time()
159
+ spinner = Spinner("dots", text=f"[cyan]{self.message}[/cyan]", style="cyan")
160
+ self.live = Live(spinner, console=self.console, refresh_per_second=10)
161
+ self.live.start()
162
+ # Register with CLI interface for pause/resume control
163
+ if self.cli_interface:
164
+ self.cli_interface._active_status_indicator = self
165
+ return self
166
+
167
+ def __exit__(self, exc_type, exc_val, exc_tb):
168
+ """Stop the status indicator and show elapsed time."""
169
+ if self.live:
170
+ self.live.stop()
171
+
172
+ # Unregister from CLI interface
173
+ if self.cli_interface:
174
+ self.cli_interface._active_status_indicator = None
175
+
176
+ if self.start_time and not self._is_paused:
177
+ elapsed = time.time() - self.start_time
178
+ self.console.print(f"[dim]✓ Completed in {elapsed:.1f}s[/dim]")
179
+
180
+ return False # Don't suppress exceptions
181
+
182
+ def pause(self):
183
+ """
184
+ Temporarily pause the status indicator to allow user interaction.
185
+ Call resume() to continue the indicator.
186
+ """
187
+ if self.live and not self._is_paused:
188
+ self.live.stop()
189
+ self._is_paused = True
190
+
191
+ def resume(self):
192
+ """
193
+ Resume the status indicator after a pause.
194
+ """
195
+ if self._is_paused and self.start_time:
196
+ elapsed = time.time() - self.start_time
197
+ spinner = Spinner("dots", text=f"[cyan]{self.message} ({elapsed:.0f}s)[/cyan]", style="cyan")
198
+ self.live = Live(spinner, console=self.console, refresh_per_second=10)
199
+ self.live.start()
200
+ self._is_paused = False
201
+
202
+ def update(self, message: str):
203
+ """
204
+ Update the status message.
205
+
206
+ Args:
207
+ message: New status message
208
+ """
209
+ if self.live and self.start_time and not self._is_paused:
210
+ elapsed = time.time() - self.start_time
211
+ spinner = Spinner("dots", text=f"[cyan]{message} ({elapsed:.0f}s)[/cyan]", style="cyan")
212
+ self.live.update(spinner)
213
+
214
+
215
+ class CLIInterface:
216
+ """Provides command-line interface functionality using Rich terminal UI."""
217
+
218
+ def __init__(self):
219
+ """Initialise the CLI interface."""
220
+ self.console = Console()
221
+ self.running = True
222
+ self.model_changing_enabled = True # Can be disabled if model is locked via config
223
+ self.cost_tracking_enabled = False # Can be enabled via config
224
+ self._active_status_indicator = None # Track active status indicator for pause/resume
225
+
226
+ def print_splash_screen(self, full_name: str, description: str, version: str):
227
+ """
228
+ Print application splash screen with SPARK branding.
229
+
230
+ Args:
231
+ full_name: Application full name
232
+ description: Application description
233
+ version: Application version
234
+ """
235
+ import os
236
+ from dtPyAppFramework.process import ProcessManager
237
+
238
+ # Get log path
239
+ log_path = ProcessManager().log_path
240
+
241
+ # Build splash content line by line
242
+ splash_content = Text()
243
+ splash_content.append("\n")
244
+
245
+ # Line 1: * . * and DIGITAL-THOUGHT
246
+ splash_content.append(" * . * ", style="bright_yellow")
247
+ splash_content.append(" DIGITAL-THOUGHT\n", style="bold bright_magenta")
248
+
249
+ # Line 2: . \|/ . and Secure Personal AI Research Kit
250
+ splash_content.append(" . \\|/ . ", style="yellow")
251
+ splash_content.append(" Secure Personal AI Research Kit\n", style="cyan")
252
+
253
+ # Line 3: S P A R K and Version
254
+ splash_content.append(" *-- ", style="bright_yellow")
255
+ splash_content.append("S P A R K", style="bold bright_cyan")
256
+ splash_content.append(" --* ", style="bright_yellow")
257
+ splash_content.append(f" Version {version}\n", style="green")
258
+
259
+ # Line 4: . /|\ .
260
+ splash_content.append(" . /|\\ . \n", style="yellow")
261
+
262
+ # Line 5: * . * and Process ID
263
+ splash_content.append(" * . * ", style="bright_yellow")
264
+ splash_content.append(f" Process ID: {os.getpid()}\n", style="cyan")
265
+
266
+ # Line 6: blank space and Log Path
267
+ splash_content.append(" ", style="")
268
+ splash_content.append(f"Log Path: {log_path}\n", style="dim")
269
+
270
+ splash_content.append("")
271
+
272
+ # Create panel
273
+ splash_panel = Panel(
274
+ splash_content,
275
+ border_style="bright_cyan",
276
+ box=box.HEAVY,
277
+ padding=(0, 2)
278
+ )
279
+
280
+ self.console.print()
281
+ self.console.print(splash_panel)
282
+ self.console.print()
283
+
284
+ def print_banner(self):
285
+ """Print the application banner."""
286
+ # This is now replaced by print_splash_screen
287
+ pass
288
+
289
+ def create_progress(self, description: str = "Initialising...") -> Progress:
290
+ """
291
+ Create a progress bar for tracking operations.
292
+
293
+ Args:
294
+ description: Description of the operation
295
+
296
+ Returns:
297
+ Progress instance
298
+ """
299
+ return Progress(
300
+ SpinnerColumn(),
301
+ TextColumn("[bold blue]{task.description}"),
302
+ BarColumn(),
303
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
304
+ TimeElapsedColumn(),
305
+ console=self.console
306
+ )
307
+
308
+ def status_indicator(self, message: str):
309
+ """
310
+ Create a status indicator with spinner and elapsed time.
311
+ Use as a context manager.
312
+
313
+ The status indicator registers with the CLI interface so it can be
314
+ paused/resumed when user input is needed (e.g., tool permission prompts).
315
+
316
+ Args:
317
+ message: Status message to display
318
+
319
+ Returns:
320
+ StatusIndicator context manager
321
+
322
+ Example:
323
+ with cli.status_indicator("Processing request..."):
324
+ # Do work here
325
+ pass
326
+ """
327
+ return StatusIndicator(self.console, message, cli_interface=self)
328
+
329
+ def pause_status_indicator(self):
330
+ """
331
+ Pause the active status indicator to allow user interaction.
332
+ Call resume_status_indicator() after the interaction is complete.
333
+ """
334
+ if self._active_status_indicator:
335
+ self._active_status_indicator.pause()
336
+
337
+ def resume_status_indicator(self):
338
+ """
339
+ Resume the active status indicator after user interaction.
340
+ """
341
+ if self._active_status_indicator:
342
+ self._active_status_indicator.resume()
343
+
344
+ def print_separator(self, char: str = "─", length: int = 70):
345
+ """
346
+ Print a separator line.
347
+
348
+ Args:
349
+ char: Character to use for the separator
350
+ length: Length of the separator line
351
+ """
352
+ self.console.print(char * length, style="dim")
353
+
354
+ def display_main_menu(self) -> str:
355
+ """
356
+ Display the main menu and get user's choice.
357
+
358
+ Returns:
359
+ User's menu choice: 'costs', 'new', 'list', or 'quit'
360
+ """
361
+ # Create menu content
362
+ menu_content = Text()
363
+ option_num = 1
364
+ choice_map = {}
365
+
366
+ # Conditionally show cost tracking option
367
+ if self.cost_tracking_enabled:
368
+ menu_content.append(" ", style="")
369
+ menu_content.append(str(option_num), style="cyan")
370
+ menu_content.append(". Re-gather AWS Bedrock Costs\n", style="")
371
+ choice_map[str(option_num)] = 'costs'
372
+ option_num += 1
373
+
374
+ # Start New Conversation
375
+ menu_content.append(" ", style="")
376
+ menu_content.append(str(option_num), style="cyan")
377
+ menu_content.append(". Start New Conversation\n", style="")
378
+ choice_map[str(option_num)] = 'new'
379
+ option_num += 1
380
+
381
+ # List and Select Conversation
382
+ menu_content.append(" ", style="")
383
+ menu_content.append(str(option_num), style="cyan")
384
+ menu_content.append(". List and Select Conversation\n", style="")
385
+ choice_map[str(option_num)] = 'list'
386
+ option_num += 1
387
+
388
+ # Manage Autonomous Actions
389
+ menu_content.append(" ", style="")
390
+ menu_content.append(str(option_num), style="cyan")
391
+ menu_content.append(". Manage Autonomous Actions\n", style="")
392
+ choice_map[str(option_num)] = 'autonomous'
393
+ option_num += 1
394
+
395
+ # Quit
396
+ menu_content.append(" ", style="")
397
+ menu_content.append(str(option_num), style="cyan")
398
+ menu_content.append(". Quit", style="")
399
+ choice_map[str(option_num)] = 'quit'
400
+
401
+ # Create panel with HEAVY borders
402
+ menu_panel = Panel(
403
+ menu_content,
404
+ title="[bold bright_magenta]MAIN MENU[/bold bright_magenta]",
405
+ border_style="bold cyan",
406
+ box=box.HEAVY,
407
+ padding=(0, 1)
408
+ )
409
+
410
+ self.console.print()
411
+ self.console.print(menu_panel)
412
+ self.console.print()
413
+
414
+ # Get user input
415
+ choice = self.get_input("Select an option")
416
+
417
+ return choice_map.get(choice, 'invalid')
418
+
419
+ def print_budget_warning(self, message: str, level: str = "75"):
420
+ """
421
+ Print a budget warning with appropriate colour based on level.
422
+
423
+ Args:
424
+ message: Warning message to display
425
+ level: Warning level ('75', '85', '95')
426
+ """
427
+ if level == "75":
428
+ # Yellow/amber warning
429
+ self.console.print(f"⚠️ [yellow]{message}[/yellow]")
430
+ elif level == "85":
431
+ # Orange warning
432
+ self.console.print(f"⚠️ [bold yellow]{message}[/bold yellow]")
433
+ elif level == "95":
434
+ # Red warning
435
+ self.console.print(f"🚨 [bold red]{message}[/bold red]")
436
+ else:
437
+ self.console.print(f"⚠️ {message}")
438
+
439
+ def prompt_budget_override(self) -> tuple[bool, float]:
440
+ """
441
+ Prompt user for budget override when limit is reached.
442
+
443
+ Returns:
444
+ Tuple of (override_accepted, additional_percentage)
445
+ """
446
+ self.console.print("\n[bold red]❌ Budget Limit Reached[/bold red]")
447
+ self.console.print("[yellow]Would you like to override the budget limit?[/yellow]")
448
+
449
+ override = self.confirm("Allow budget override?")
450
+ if not override:
451
+ return False, 0.0
452
+
453
+ # Get additional percentage
454
+ while True:
455
+ try:
456
+ percentage_input = input("Enter additional percentage to allow (e.g., 10 for 10% more): ").strip()
457
+ percentage = float(percentage_input)
458
+
459
+ if percentage <= 0:
460
+ self.console.print("[red]Please enter a positive number[/red]")
461
+ continue
462
+
463
+ if percentage > 500: # Sanity check
464
+ self.console.print("[red]Maximum override is 500%[/red]")
465
+ continue
466
+
467
+ return True, percentage
468
+
469
+ except ValueError:
470
+ self.console.print("[red]Please enter a valid number[/red]")
471
+ except KeyboardInterrupt:
472
+ self.console.print("\n[yellow]Override cancelled[/yellow]")
473
+ return False, 0.0
474
+
475
+ def print_error(self, message: str):
476
+ """
477
+ Print an error message.
478
+
479
+ Args:
480
+ message: Error message to display
481
+ """
482
+ self.console.print(f"\n[bold red]✗[/bold red] [red]{message}[/red]\n")
483
+
484
+ def print_success(self, message: str):
485
+ """
486
+ Print a success message.
487
+
488
+ Args:
489
+ message: Success message to display
490
+ """
491
+ self.console.print(f"\n[bold green]✓[/bold green] [green]{message}[/green]\n")
492
+
493
+ def print_info(self, message: str):
494
+ """
495
+ Print an informational message.
496
+
497
+ Args:
498
+ message: Info message to display
499
+ """
500
+ self.console.print(f"\n[bold cyan]ℹ[/bold cyan] [cyan]{message}[/cyan]\n")
501
+
502
+ def print_warning(self, message: str):
503
+ """
504
+ Print a warning message.
505
+
506
+ Args:
507
+ message: Warning message to display
508
+ """
509
+ self.console.print(f"\n[bold yellow]⚠[/bold yellow] [yellow]{message}[/yellow]\n")
510
+
511
+ def get_input(self, prompt: str) -> str:
512
+ """
513
+ Get user input with a prompt.
514
+
515
+ Args:
516
+ prompt: Prompt to display
517
+
518
+ Returns:
519
+ User input string
520
+ """
521
+ return Prompt.ask(f"[bold cyan]{prompt}[/bold cyan]").strip()
522
+
523
+ def get_multiline_input(self, prompt: str) -> str:
524
+ """
525
+ Get multiline user input (ends with double Enter).
526
+
527
+ Args:
528
+ prompt: Prompt to display
529
+
530
+ Returns:
531
+ Multiline user input string
532
+ """
533
+ self.console.print(f"\n[bold cyan]{prompt}[/bold cyan]")
534
+ self.console.print("[dim](Press Enter twice to finish)[/dim]\n")
535
+
536
+ lines = []
537
+ empty_line_count = 0
538
+
539
+ while True:
540
+ line = input()
541
+ if line == "":
542
+ empty_line_count += 1
543
+ if empty_line_count >= 2:
544
+ break
545
+ lines.append(line)
546
+ else:
547
+ empty_line_count = 0
548
+ lines.append(line)
549
+
550
+ return '\n'.join(lines).strip()
551
+
552
+ def display_menu(self, title: str, options: List[str]) -> int:
553
+ """
554
+ Display a menu and get user selection.
555
+
556
+ Args:
557
+ title: Menu title
558
+ options: List of menu options
559
+
560
+ Returns:
561
+ Selected option index (0-based) or -1 for invalid selection
562
+ """
563
+ # Create table
564
+ table = Table(show_header=False, box=box.ROUNDED, border_style="cyan")
565
+ table.add_column("No.", style="bold yellow", width=4)
566
+ table.add_column("Option", style="white")
567
+
568
+ for i, option in enumerate(options, 1):
569
+ table.add_row(str(i), option)
570
+
571
+ table.add_row(str(len(options) + 1), "[red]Exit[/red]")
572
+
573
+ # Display in panel
574
+ panel = Panel(
575
+ table,
576
+ title=f"[bold cyan]{title}[/bold cyan]",
577
+ border_style="cyan"
578
+ )
579
+
580
+ self.console.print()
581
+ self.console.print(panel)
582
+
583
+ try:
584
+ choice = int(Prompt.ask("[bold]Select an option[/bold]"))
585
+ if 1 <= choice <= len(options):
586
+ return choice - 1
587
+ elif choice == len(options) + 1:
588
+ return -1
589
+ else:
590
+ self.print_error("Invalid selection")
591
+ return -1
592
+ except ValueError:
593
+ self.print_error("Please enter a valid number")
594
+ return -1
595
+
596
+ def prompt_tool_permission(self, tool_name: str, tool_description: str = None) -> Optional[str]:
597
+ """
598
+ Prompt user for permission to use a tool.
599
+
600
+ Args:
601
+ tool_name: Name of the tool
602
+ tool_description: Optional description of the tool
603
+
604
+ Returns:
605
+ 'allowed' if user grants permission for all future uses
606
+ 'denied' if user denies this and all future uses
607
+ 'once' if user grants permission for this time only
608
+ None if user cancelled
609
+ """
610
+ # Pause any active status indicator to prevent visual interference
611
+ self.pause_status_indicator()
612
+
613
+ self.console.print()
614
+ self.print_separator("─")
615
+ self.console.print(f"\n[bold yellow]🔐 Tool Permission Request[/bold yellow]")
616
+ self.console.print(f"\nThe assistant wants to use the tool: [bold cyan]{tool_name}[/bold cyan]")
617
+
618
+ if tool_description:
619
+ self.console.print(f"\n[dim]{tool_description}[/dim]")
620
+
621
+ self.console.print("\n[bold]Please choose an option:[/bold]")
622
+ self.console.print(" [bold green]1.[/bold green] Allow once - Run this time only")
623
+ self.console.print(" [bold green]2.[/bold green] Allow always - Run this time and all future times")
624
+ self.console.print(" [bold red]3.[/bold red] Deny - Don't run this time or in the future")
625
+ self.console.print(" [bold yellow]4.[/bold yellow] Cancel")
626
+
627
+ try:
628
+ choice = Prompt.ask("\n[bold]Your choice[/bold]", choices=["1", "2", "3", "4"], default="1")
629
+
630
+ if choice == "1":
631
+ self.console.print("\n[bold green]✓[/bold green] Tool will run this time only")
632
+ return 'once'
633
+ elif choice == "2":
634
+ self.console.print("\n[bold green]✓[/bold green] Tool permission granted for all future uses")
635
+ return 'allowed'
636
+ elif choice == "3":
637
+ self.console.print("\n[bold red]✗[/bold red] Tool denied")
638
+ return 'denied'
639
+ else: # "4" or invalid
640
+ self.console.print("\n[yellow]Cancelled[/yellow]")
641
+ return None
642
+
643
+ except (KeyboardInterrupt, EOFError):
644
+ self.console.print("\n[yellow]Cancelled[/yellow]")
645
+ return None
646
+ finally:
647
+ self.print_separator("─")
648
+ # Resume status indicator after user interaction
649
+ self.resume_status_indicator()
650
+
651
+ def display_models(self, models: List[Dict]) -> Optional[str]:
652
+ """
653
+ Display available models and get user selection.
654
+
655
+ Args:
656
+ models: List of model dictionaries
657
+
658
+ Returns:
659
+ Selected model ID or None
660
+ """
661
+ if not models:
662
+ self.print_error("No models available")
663
+ return None
664
+
665
+ # Create table
666
+ table = Table(
667
+ show_header=True,
668
+ header_style="bold magenta",
669
+ box=box.ROUNDED,
670
+ border_style="cyan"
671
+ )
672
+ table.add_column("No.", style="bold yellow", width=4)
673
+ table.add_column("Model Name", style="cyan")
674
+ table.add_column("Provider", style="green")
675
+ table.add_column("Access Method", style="magenta")
676
+ table.add_column("Streaming", style="blue", justify="center")
677
+
678
+ for i, model in enumerate(models, 1):
679
+ streaming = "✓" if model.get('response_streaming') else "✗"
680
+ access_info = model.get('access_info', 'Unknown')
681
+ table.add_row(
682
+ str(i),
683
+ model['name'],
684
+ model['provider'],
685
+ access_info,
686
+ streaming
687
+ )
688
+
689
+ # Add quit option
690
+ table.add_row(
691
+ "Q",
692
+ "[red]Quit[/red]",
693
+ "",
694
+ "",
695
+ ""
696
+ )
697
+
698
+ # Display in panel
699
+ panel = Panel(
700
+ table,
701
+ title="[bold magenta]📋 Available LLM Models[/bold magenta]",
702
+ border_style="magenta"
703
+ )
704
+
705
+ self.console.print()
706
+ self.console.print(panel)
707
+
708
+ try:
709
+ choice_str = Prompt.ask("\n[bold]Select a model (or Q to quit)[/bold]")
710
+
711
+ # Check for quit
712
+ if choice_str.upper() == 'Q':
713
+ return 'QUIT'
714
+
715
+ choice = int(choice_str)
716
+ if 1 <= choice <= len(models):
717
+ return models[choice - 1]['id']
718
+ else:
719
+ self.print_error("Invalid selection")
720
+ return None
721
+ except ValueError:
722
+ self.print_error("Please enter a valid number or Q to quit")
723
+ return None
724
+
725
+ def display_conversations(self, conversations: List[Dict]) -> Optional[int]:
726
+ """
727
+ Display existing conversations and get user selection.
728
+
729
+ Args:
730
+ conversations: List of conversation dictionaries
731
+
732
+ Returns:
733
+ Selected conversation ID or None
734
+ """
735
+ if not conversations:
736
+ self.print_info("No existing conversations found")
737
+ return None
738
+
739
+ # Create table
740
+ table = Table(
741
+ show_header=True,
742
+ header_style="bold magenta",
743
+ box=box.ROUNDED,
744
+ border_style="cyan"
745
+ )
746
+ table.add_column("No.", style="bold yellow", width=4)
747
+ table.add_column("Name", style="cyan")
748
+ table.add_column("Model", style="green")
749
+ table.add_column("Created", style="blue")
750
+ table.add_column("Tokens", style="magenta", justify="right")
751
+
752
+ for i, conv in enumerate(conversations, 1):
753
+ created = datetime.fromisoformat(conv['created_at'])
754
+ table.add_row(
755
+ str(i),
756
+ conv['name'],
757
+ conv['model_id'][:40] + "..." if len(conv['model_id']) > 40 else conv['model_id'],
758
+ created.strftime('%Y-%m-%d %H:%M'),
759
+ str(conv['total_tokens'])
760
+ )
761
+
762
+ table.add_row(
763
+ "[bold green]N[/bold green]",
764
+ "[bold green]Start New Conversation[/bold green]",
765
+ "-",
766
+ "-",
767
+ "-"
768
+ )
769
+
770
+ # Display in panel
771
+ panel = Panel(
772
+ table,
773
+ title="[bold magenta]💬 Conversations[/bold magenta]",
774
+ border_style="magenta"
775
+ )
776
+
777
+ self.console.print()
778
+ self.console.print(panel)
779
+
780
+ choice_str = Prompt.ask("\n[bold]Select an option[/bold]")
781
+
782
+ # Check for "N" or "n" for new conversation
783
+ if choice_str.lower() == 'n':
784
+ return None
785
+
786
+ # Try to parse as number
787
+ try:
788
+ choice = int(choice_str)
789
+ if 1 <= choice <= len(conversations):
790
+ return conversations[choice - 1]['id']
791
+ else:
792
+ self.print_error("Invalid selection")
793
+ return None
794
+ except ValueError:
795
+ self.print_error("Please enter a valid number or 'N' for new conversation")
796
+ return None
797
+
798
+ def display_message(self, role: str, content: str, timestamp: Optional[datetime] = None):
799
+ """
800
+ Display a chat message with markdown rendering for assistant responses.
801
+
802
+ Args:
803
+ role: Message role (user, assistant, system)
804
+ content: Message content
805
+ timestamp: Optional timestamp
806
+ """
807
+ role_config = {
808
+ 'user': {'emoji': '👤', 'style': 'bold cyan', 'border': 'cyan'},
809
+ 'assistant': {'emoji': '🤖', 'style': 'bold green', 'border': 'green'},
810
+ 'system': {'emoji': 'ℹ️', 'style': 'bold yellow', 'border': 'yellow'}
811
+ }
812
+
813
+ config = role_config.get(role.lower(), {'emoji': '💬', 'style': 'white', 'border': 'white'})
814
+
815
+ # Format title
816
+ title = f"[{config['style']}]{config['emoji']} {role.capitalize()}[/{config['style']}]"
817
+ if timestamp:
818
+ time_str = timestamp.strftime('%H:%M:%S')
819
+ title += f" [dim]{time_str}[/dim]"
820
+
821
+ # Render assistant messages as markdown for better formatting
822
+ if role.lower() == 'assistant':
823
+ rendered_content = Markdown(content)
824
+ else:
825
+ rendered_content = content
826
+
827
+ # Create panel
828
+ panel = Panel(
829
+ rendered_content,
830
+ title=title,
831
+ title_align="left",
832
+ border_style=config['border'],
833
+ box=box.ROUNDED,
834
+ padding=(1, 2)
835
+ )
836
+
837
+ self.console.print()
838
+ self.console.print(panel)
839
+
840
+ def display_conversation_history(self, messages: List[Dict]):
841
+ """
842
+ Display conversation history.
843
+
844
+ Args:
845
+ messages: List of message dictionaries
846
+ """
847
+ self.console.print()
848
+ self.console.print(Panel(
849
+ "[bold]Conversation History[/bold]",
850
+ style="bold magenta",
851
+ box=box.DOUBLE
852
+ ))
853
+
854
+ for msg in messages:
855
+ timestamp = datetime.fromisoformat(msg['timestamp'])
856
+ self.display_message(msg['role'], msg['content'], timestamp)
857
+
858
+ self.console.print()
859
+ self.print_separator("═")
860
+
861
+ def display_conversation_info(self, conversation: Dict, token_count: int, max_tokens: int,
862
+ attached_files: Optional[List[Dict]] = None,
863
+ model_usage: Optional[List[Dict]] = None,
864
+ detailed: bool = False,
865
+ access_method: Optional[str] = None):
866
+ """
867
+ Display current conversation information.
868
+
869
+ Args:
870
+ conversation: Conversation dictionary
871
+ token_count: Current token count
872
+ max_tokens: Maximum token limit
873
+ attached_files: Optional list of attached files
874
+ model_usage: Optional list of per-model usage breakdowns
875
+ detailed: If True, show full details including full instructions
876
+ access_method: Optional access method description (e.g., 'AWS Bedrock', 'Ollama (http://...)')
877
+ """
878
+ token_percentage = (token_count / max_tokens) * 100
879
+
880
+ # Create info table
881
+ table = Table(show_header=False, box=None, padding=(0, 2))
882
+ table.add_column("Label", style="bold cyan")
883
+ table.add_column("Value", style="white")
884
+
885
+ table.add_row("Conversation", conversation['name'])
886
+
887
+ # Display friendly model name with full ID in detailed view
888
+ model_id = conversation['model_id']
889
+ friendly_name = extract_friendly_model_name(model_id)
890
+ if detailed:
891
+ # Show both friendly name and full model ID
892
+ table.add_row("Current Model", f"{friendly_name}")
893
+ table.add_row("Model ID", f"[dim]{model_id}[/dim]")
894
+ else:
895
+ table.add_row("Current Model", friendly_name)
896
+
897
+ # Add access method if provided
898
+ if access_method:
899
+ table.add_row("Access Method", access_method)
900
+
901
+ # Add instructions indicator
902
+ if conversation.get('instructions'):
903
+ if detailed:
904
+ # In detailed view, show YES and we'll display full instructions below
905
+ table.add_row("Instructions", "[green]YES[/green]")
906
+ else:
907
+ # In regular view, just show YES
908
+ table.add_row("Instructions", "[green]YES[/green]")
909
+ else:
910
+ table.add_row("Instructions", "[dim]NO[/dim]")
911
+
912
+ table.add_row("Tokens", f"{token_count:,} / {max_tokens:,} ({token_percentage:.1f}%)")
913
+
914
+ # Add API token usage
915
+ tokens_sent = conversation.get('tokens_sent', 0)
916
+ tokens_received = conversation.get('tokens_received', 0)
917
+ total_api_tokens = tokens_sent + tokens_received
918
+ table.add_row("Total API Usage", f"↑ {tokens_sent:,} sent | ↓ {tokens_received:,} received | Σ {total_api_tokens:,}")
919
+
920
+ # Add attached files count
921
+ if attached_files:
922
+ total_file_tokens = sum(f.get('token_count', 0) for f in attached_files)
923
+ table.add_row("Files", f"{len(attached_files)} attached ({total_file_tokens:,} tokens)")
924
+
925
+ # Colour coding based on usage
926
+ if token_percentage < 60:
927
+ status = "[green]🟢 Good[/green]"
928
+ bar_style = "green"
929
+ elif token_percentage < 80:
930
+ status = "[yellow]🟡 Moderate[/yellow]"
931
+ bar_style = "yellow"
932
+ else:
933
+ status = "[red]🔴 High[/red]"
934
+ bar_style = "red"
935
+
936
+ # Visual token usage bar
937
+ bar_length = 40
938
+ filled_length = int(bar_length * token_count // max_tokens)
939
+ bar = "█" * filled_length + "░" * (bar_length - filled_length)
940
+
941
+ table.add_row("Usage", f"[{bar_style}]{bar}[/{bar_style}] {status}")
942
+
943
+ panel = Panel(
944
+ table,
945
+ title="[bold cyan]📊 Conversation Status[/bold cyan]",
946
+ border_style="cyan",
947
+ box=box.ROUNDED
948
+ )
949
+
950
+ self.console.print()
951
+ self.console.print(panel)
952
+
953
+ # Display full instructions if in detailed mode and instructions exist
954
+ if detailed and conversation.get('instructions'):
955
+ self.console.print()
956
+ instructions_panel = Panel(
957
+ conversation['instructions'],
958
+ title="[bold cyan]📝 Conversation Instructions[/bold cyan]",
959
+ border_style="cyan",
960
+ box=box.ROUNDED
961
+ )
962
+ self.console.print(instructions_panel)
963
+
964
+ # Display per-model usage breakdown if provided
965
+ if model_usage and len(model_usage) > 0:
966
+ self.console.print()
967
+ self.display_model_usage_breakdown(model_usage)
968
+
969
+ def display_model_usage_breakdown(self, model_usage: List[Dict]):
970
+ """
971
+ Display per-model token usage breakdown.
972
+
973
+ Args:
974
+ model_usage: List of model usage dictionaries
975
+ """
976
+ # Create model usage table
977
+ usage_table = Table(title="Model Usage Breakdown", box=box.ROUNDED, show_header=True)
978
+ usage_table.add_column("Model", style="cyan", no_wrap=True)
979
+ usage_table.add_column("Input Tokens", justify="right", style="green")
980
+ usage_table.add_column("Output Tokens", justify="right", style="yellow")
981
+ usage_table.add_column("Total Tokens", justify="right", style="bold")
982
+
983
+ for usage in model_usage:
984
+ usage_table.add_row(
985
+ usage['model_id'],
986
+ f"{usage['input_tokens']:,}",
987
+ f"{usage['output_tokens']:,}",
988
+ f"{usage['total_tokens']:,}"
989
+ )
990
+
991
+ self.console.print(usage_table)
992
+
993
+ def chat_prompt(self) -> Optional[str]:
994
+ """
995
+ Get user input for chat message.
996
+ Supports multi-line input - press Enter twice to send.
997
+
998
+ Returns:
999
+ User message or None to exit
1000
+ """
1001
+ self.console.print()
1002
+ self.print_separator("─")
1003
+
1004
+ # Display help text - conditionally include changemodel if enabled
1005
+ commands = "[bold]quit[/bold] | [bold]end[/bold] | [bold]history[/bold] | [bold]info[/bold] | " \
1006
+ "[bold]export[/bold] | [bold]delete[/bold] | [bold]attach[/bold] | [bold]copy[/bold] | " \
1007
+ "[bold]instructions[/bold] | [bold]deletefiles[/bold] | [bold]mcpaudit[/bold] | [bold]mcpservers[/bold]"
1008
+
1009
+ if self.model_changing_enabled:
1010
+ commands += " | [bold]changemodel[/bold]"
1011
+
1012
+ help_panel = Panel(
1013
+ f"[dim]Commands: {commands}\n"
1014
+ "Press [bold]Enter twice[/bold] to send your message[/dim]",
1015
+ border_style="dim",
1016
+ box=box.ROUNDED
1017
+ )
1018
+ self.console.print(help_panel)
1019
+
1020
+ self.console.print("\n[bold cyan]💬 Your message:[/bold cyan]")
1021
+
1022
+ lines = []
1023
+ empty_line_count = 0
1024
+
1025
+ while True:
1026
+ try:
1027
+ line = input()
1028
+ except EOFError:
1029
+ # Handle Ctrl+D or EOF
1030
+ return None
1031
+
1032
+ if line == "":
1033
+ empty_line_count += 1
1034
+ if empty_line_count >= 2:
1035
+ # Double Enter pressed - send message
1036
+ break
1037
+ lines.append(line)
1038
+ else:
1039
+ empty_line_count = 0
1040
+ lines.append(line)
1041
+
1042
+ message = '\n'.join(lines).strip()
1043
+
1044
+ # Check for commands
1045
+ if message.lower() in ['quit', 'exit', 'q']:
1046
+ return None
1047
+ elif message.lower() in ['end', 'endchat']:
1048
+ return 'END_CHAT'
1049
+ elif message.lower() in ['history', 'h']:
1050
+ return 'SHOW_HISTORY'
1051
+ elif message.lower() in ['info', 'i']:
1052
+ return 'SHOW_INFO'
1053
+ elif message.lower() in ['export', 'e']:
1054
+ return 'EXPORT_CONVERSATION'
1055
+ elif message.lower() in ['delete', 'd']:
1056
+ return 'DELETE_CONVERSATION'
1057
+ elif message.lower() in ['attach', 'a']:
1058
+ return 'ATTACH_FILES'
1059
+ elif message.lower() in ['mcpaudit', 'audit']:
1060
+ return 'MCP_AUDIT'
1061
+ elif message.lower() in ['mcpservers', 'servers', 's']:
1062
+ return 'MCP_SERVERS'
1063
+ elif message.lower() in ['changemodel', 'model', 'm']:
1064
+ return 'CHANGE_MODEL'
1065
+ elif message.lower() in ['instructions', 'inst']:
1066
+ return 'CHANGE_INSTRUCTIONS'
1067
+ elif message.lower() in ['deletefiles', 'df']:
1068
+ return 'DELETE_FILES'
1069
+ elif message.lower() in ['copy', 'c']:
1070
+ return 'COPY_LAST'
1071
+
1072
+ return message
1073
+
1074
+ def confirm(self, message: str) -> bool:
1075
+ """
1076
+ Ask for user confirmation.
1077
+
1078
+ Args:
1079
+ message: Confirmation message
1080
+
1081
+ Returns:
1082
+ True if confirmed, False otherwise
1083
+ """
1084
+ return Confirm.ask(f"[bold yellow]{message}[/bold yellow]")
1085
+
1086
+ def wait_for_enter(self, message: str = "Press Enter to continue"):
1087
+ """
1088
+ Wait for user to press Enter.
1089
+
1090
+ Args:
1091
+ message: Message to display
1092
+ """
1093
+ Prompt.ask(f"\n[dim]{message}[/dim]", default="")
1094
+
1095
+ def print_farewell(self, version: str = None):
1096
+ """
1097
+ Print farewell message when exiting with SPARK branding.
1098
+
1099
+ Args:
1100
+ version: Application version to display (optional)
1101
+ """
1102
+ # Get version from launch module if not provided
1103
+ if version is None:
1104
+ try:
1105
+ from dtSpark import launch
1106
+ version = launch.version()
1107
+ except:
1108
+ version = "X.X"
1109
+
1110
+ # Get log path
1111
+ from dtPyAppFramework.process import ProcessManager
1112
+ log_path = ProcessManager().log_path
1113
+
1114
+ # Build farewell content line by line
1115
+ farewell_content = Text()
1116
+ farewell_content.append("\n")
1117
+
1118
+ # Line 1: * . * and DIGITAL-THOUGHT
1119
+ farewell_content.append(" * . * ", style="bright_yellow")
1120
+ farewell_content.append(" DIGITAL-THOUGHT\n", style="bold bright_magenta")
1121
+
1122
+ # Line 2: . \|/ . and Secure Personal AI Research Kit
1123
+ farewell_content.append(" . \\|/ . ", style="yellow")
1124
+ farewell_content.append(" Secure Personal AI Research Kit\n", style="cyan")
1125
+
1126
+ # Line 3: S P A R K and Version
1127
+ farewell_content.append(" *-- ", style="bright_yellow")
1128
+ farewell_content.append("S P A R K", style="bold bright_cyan")
1129
+ farewell_content.append(" --* ", style="bright_yellow")
1130
+ farewell_content.append(f" Version {version}\n", style="green")
1131
+
1132
+ # Line 4: . /|\ .
1133
+ farewell_content.append(" . /|\\ . \n", style="yellow")
1134
+
1135
+ # Line 5: * . * and Thankyou and Goodbye!
1136
+ farewell_content.append(" * . * ", style="bright_yellow")
1137
+ farewell_content.append(" Thankyou and Goodbye!\n", style="bright_green")
1138
+
1139
+ # Line 6: blank space and Log Path
1140
+ farewell_content.append(" ", style="")
1141
+ farewell_content.append(f"Log Path: {log_path}\n", style="dim")
1142
+
1143
+ farewell_content.append("")
1144
+
1145
+ # Create panel
1146
+ farewell_panel = Panel(
1147
+ farewell_content,
1148
+ border_style="bright_cyan",
1149
+ box=box.HEAVY,
1150
+ padding=(0, 2)
1151
+ )
1152
+
1153
+ self.console.print()
1154
+ self.console.print(farewell_panel)
1155
+ self.console.print()
1156
+
1157
+ def display_mcp_status(self, mcp_manager):
1158
+ """
1159
+ Display MCP server connection status and tools.
1160
+
1161
+ Args:
1162
+ mcp_manager: MCPManager instance
1163
+ """
1164
+ # Count connected servers
1165
+ connected_count = sum(1 for client in mcp_manager.clients.values() if client.connected)
1166
+
1167
+ if connected_count == 0:
1168
+ self.print_warning("No MCP servers connected")
1169
+ return
1170
+
1171
+ # Create table for server details
1172
+ table = Table(
1173
+ show_header=True,
1174
+ header_style="bold magenta",
1175
+ box=box.ROUNDED,
1176
+ border_style="green"
1177
+ )
1178
+ table.add_column("Server", style="cyan")
1179
+ table.add_column("Status", style="green", justify="center")
1180
+ table.add_column("Transport", style="blue")
1181
+ table.add_column("Tools", style="yellow", justify="right")
1182
+
1183
+ # Get tools by server
1184
+ tools_by_server = {}
1185
+ if hasattr(mcp_manager, '_tools_cache') and mcp_manager._tools_cache:
1186
+ for tool in mcp_manager._tools_cache:
1187
+ server_name = tool.get('server', 'unknown')
1188
+ if server_name not in tools_by_server:
1189
+ tools_by_server[server_name] = []
1190
+ tools_by_server[server_name].append(tool['name'])
1191
+
1192
+ # Add rows for each server
1193
+ for name, client in mcp_manager.clients.items():
1194
+ status = "✓ Connected" if client.connected else "✗ Disconnected"
1195
+ transport = client.config.transport.upper()
1196
+ tool_count = len(tools_by_server.get(name, []))
1197
+
1198
+ table.add_row(
1199
+ name,
1200
+ status if client.connected else f"[red]{status}[/red]",
1201
+ transport,
1202
+ str(tool_count)
1203
+ )
1204
+
1205
+ # Display in panel
1206
+ total_tools = sum(len(tools) for tools in tools_by_server.values())
1207
+ panel = Panel(
1208
+ table,
1209
+ title=f"[bold green]🔧 MCP Servers ({connected_count} connected, {total_tools} tools)[/bold green]",
1210
+ border_style="green"
1211
+ )
1212
+
1213
+ self.console.print()
1214
+ self.console.print(panel)
1215
+
1216
+ # List tools for each server
1217
+ if tools_by_server:
1218
+ self.console.print()
1219
+ for server_name, tool_names in tools_by_server.items():
1220
+ tools_text = ", ".join([f"[cyan]{name}[/cyan]" for name in tool_names])
1221
+ self.console.print(f" [bold]{server_name}[/bold]: {tools_text}")
1222
+ self.console.print()
1223
+
1224
+ def display_bedrock_costs(self, costs_data: Dict):
1225
+ """
1226
+ Display AWS Bedrock usage costs.
1227
+
1228
+ Args:
1229
+ costs_data: Dictionary containing cost information from CostTracker
1230
+ """
1231
+ if not costs_data:
1232
+ self.print_warning("No AWS Bedrock cost information available")
1233
+ return
1234
+
1235
+ currency = costs_data.get('currency', 'USD')
1236
+
1237
+ # Create content for the panel
1238
+ content_parts = []
1239
+
1240
+ # Current Month section
1241
+ if 'current_month' in costs_data:
1242
+ current_month = costs_data['current_month']
1243
+ total = current_month.get('total', 0.0)
1244
+ breakdown = current_month.get('breakdown', {})
1245
+
1246
+ content_parts.append(f"[bold cyan]Current Month:[/bold cyan] [yellow]${total:.2f} {currency}[/yellow]")
1247
+
1248
+ if breakdown:
1249
+ # Calculate percentages and sort by cost
1250
+ breakdown_with_pct = []
1251
+ for model, cost in breakdown.items():
1252
+ percentage = (cost / total * 100) if total > 0 else 0
1253
+ breakdown_with_pct.append((model, cost, percentage))
1254
+
1255
+ breakdown_with_pct.sort(key=lambda x: x[1], reverse=True)
1256
+
1257
+ for model, cost, percentage in breakdown_with_pct:
1258
+ content_parts.append(f" • [cyan]{model}[/cyan]: [yellow]${cost:.2f}[/yellow] [dim]({percentage:.1f}%)[/dim]")
1259
+
1260
+ # Last Month section
1261
+ if 'last_month' in costs_data:
1262
+ last_month = costs_data['last_month']
1263
+ total = last_month.get('total', 0.0)
1264
+ breakdown = last_month.get('breakdown', {})
1265
+
1266
+ if content_parts:
1267
+ content_parts.append("") # Add spacing
1268
+
1269
+ content_parts.append(f"[bold cyan]Last Month:[/bold cyan] [yellow]${total:.2f} {currency}[/yellow]")
1270
+
1271
+ if breakdown:
1272
+ # Calculate percentages and sort by cost
1273
+ breakdown_with_pct = []
1274
+ for model, cost in breakdown.items():
1275
+ percentage = (cost / total * 100) if total > 0 else 0
1276
+ breakdown_with_pct.append((model, cost, percentage))
1277
+
1278
+ breakdown_with_pct.sort(key=lambda x: x[1], reverse=True)
1279
+
1280
+ for model, cost, percentage in breakdown_with_pct:
1281
+ content_parts.append(f" • [cyan]{model}[/cyan]: [yellow]${cost:.2f}[/yellow] [dim]({percentage:.1f}%)[/dim]")
1282
+
1283
+ # Last 24 Hours section
1284
+ if 'last_24h' in costs_data:
1285
+ last_24h = costs_data['last_24h']
1286
+ total = last_24h.get('total', 0.0)
1287
+ breakdown = last_24h.get('breakdown', {})
1288
+
1289
+ if content_parts:
1290
+ content_parts.append("") # Add spacing
1291
+
1292
+ content_parts.append(f"[bold cyan]Last 24 Hours:[/bold cyan] [yellow]${total:.4f} {currency}[/yellow]")
1293
+
1294
+ if breakdown:
1295
+ # Calculate percentages and sort by cost
1296
+ breakdown_with_pct = []
1297
+ for model, cost in breakdown.items():
1298
+ percentage = (cost / total * 100) if total > 0 else 0
1299
+ breakdown_with_pct.append((model, cost, percentage))
1300
+
1301
+ breakdown_with_pct.sort(key=lambda x: x[1], reverse=True)
1302
+
1303
+ for model, cost, percentage in breakdown_with_pct:
1304
+ content_parts.append(f" • [cyan]{model}[/cyan]: [yellow]${cost:.4f}[/yellow] [dim]({percentage:.1f}%)[/dim]")
1305
+
1306
+ # Create panel
1307
+ content_text = "\n".join(content_parts)
1308
+ panel = Panel(
1309
+ content_text,
1310
+ title="[bold green]💰 AWS Bedrock Usage Costs[/bold green]",
1311
+ border_style="green"
1312
+ )
1313
+
1314
+ self.console.print()
1315
+ self.console.print(panel)
1316
+
1317
+ def display_anthropic_costs(self, costs_data: Dict):
1318
+ """
1319
+ Display Anthropic Direct API usage costs and budget status.
1320
+
1321
+ Args:
1322
+ costs_data: Dictionary containing cost information from AnthropicService
1323
+ """
1324
+ if not costs_data:
1325
+ self.print_warning("No Anthropic cost information available")
1326
+ return
1327
+
1328
+ # Create content for the panel
1329
+ content_parts = []
1330
+
1331
+ # Current month spending
1332
+ current_month_spent = costs_data.get('current_month_spent', 0.0)
1333
+ total_spent = costs_data.get('total_spent', 0.0)
1334
+ budget_limit = costs_data.get('budget_limit', 0.0)
1335
+ budget_remaining = costs_data.get('budget_remaining', 0.0)
1336
+ budget_percentage = costs_data.get('budget_percentage', 0.0)
1337
+ budget_exceeded = costs_data.get('budget_exceeded', False)
1338
+ approaching_limit = costs_data.get('approaching_limit', False)
1339
+ current_month = costs_data.get('current_month', '')
1340
+
1341
+ # Current month section
1342
+ content_parts.append(f"[bold cyan]Current Month ({current_month}):[/bold cyan]")
1343
+ content_parts.append(f" • Spent: [yellow]${current_month_spent:.4f} USD[/yellow]")
1344
+
1345
+ # Budget section
1346
+ if budget_limit > 0:
1347
+ content_parts.append("")
1348
+ content_parts.append(f"[bold cyan]Budget Status:[/bold cyan]")
1349
+ content_parts.append(f" • Budget Limit: [yellow]${budget_limit:.2f} USD[/yellow]")
1350
+
1351
+ if budget_exceeded:
1352
+ content_parts.append(f" • Remaining: [red]${budget_remaining:.2f} USD (EXCEEDED)[/red]")
1353
+ content_parts.append(f" • Usage: [red]{budget_percentage:.1f}%[/red] [red]⚠️ OVER BUDGET[/red]")
1354
+ elif approaching_limit:
1355
+ content_parts.append(f" • Remaining: [yellow]${budget_remaining:.2f} USD[/yellow]")
1356
+ content_parts.append(f" • Usage: [yellow]{budget_percentage:.1f}%[/yellow] [yellow]⚠️ APPROACHING LIMIT[/yellow]")
1357
+ else:
1358
+ content_parts.append(f" • Remaining: [green]${budget_remaining:.2f} USD[/green]")
1359
+ content_parts.append(f" • Usage: [green]{budget_percentage:.1f}%[/green]")
1360
+
1361
+ # Total lifetime spending
1362
+ content_parts.append("")
1363
+ content_parts.append(f"[bold cyan]Total Lifetime Spending:[/bold cyan] [yellow]${total_spent:.4f} USD[/yellow]")
1364
+
1365
+ # Usage count
1366
+ usage_count = costs_data.get('usage_count', 0)
1367
+ if usage_count > 0:
1368
+ content_parts.append(f"[bold cyan]API Calls:[/bold cyan] {usage_count} requests")
1369
+
1370
+ # Create panel with appropriate colour based on budget status
1371
+ if budget_exceeded:
1372
+ border_colour = "red"
1373
+ title = "[bold red]💰 Anthropic API Costs - BUDGET EXCEEDED[/bold red]"
1374
+ elif approaching_limit:
1375
+ border_colour = "yellow"
1376
+ title = "[bold yellow]💰 Anthropic API Costs - APPROACHING LIMIT[/bold yellow]"
1377
+ else:
1378
+ border_colour = "green"
1379
+ title = "[bold green]💰 Anthropic API Costs[/bold green]"
1380
+
1381
+ content_text = "\n".join(content_parts)
1382
+ panel = Panel(
1383
+ content_text,
1384
+ title=title,
1385
+ border_style=border_colour
1386
+ )
1387
+
1388
+ self.console.print()
1389
+ self.console.print(panel)
1390
+
1391
+ def display_aws_account_info(self, account_info: Dict):
1392
+ """
1393
+ Display AWS account and authentication information.
1394
+
1395
+ Args:
1396
+ account_info: Dictionary containing AWS account information
1397
+ """
1398
+ if not account_info:
1399
+ self.print_warning("No AWS account information available")
1400
+ return
1401
+
1402
+ # Create content for the panel
1403
+ content_parts = []
1404
+
1405
+ # Identity/ARN
1406
+ if 'user_arn' in account_info:
1407
+ content_parts.append(f"[bold cyan]Authenticated as:[/bold cyan] [green]{account_info['user_arn']}[/green]")
1408
+
1409
+ # Account ID
1410
+ if 'account_id' in account_info:
1411
+ content_parts.append(f"[bold cyan]Account:[/bold cyan] [yellow]{account_info['account_id']}[/yellow]")
1412
+
1413
+ # Region
1414
+ if 'region' in account_info:
1415
+ content_parts.append(f"[bold cyan]Region:[/bold cyan] [yellow]{account_info['region']}[/yellow]")
1416
+
1417
+ # Authentication Method
1418
+ if 'auth_method' in account_info and account_info['auth_method']:
1419
+ auth_method_display = "API Keys" if account_info['auth_method'] == 'api_keys' else "SSO Profile"
1420
+ content_parts.append(f"[bold cyan]Authentication:[/bold cyan] [magenta]{auth_method_display}[/magenta]")
1421
+
1422
+ # Create panel
1423
+ content_text = "\n".join(content_parts)
1424
+ panel = Panel(
1425
+ content_text,
1426
+ title="[bold green]☁️ AWS Account Information[/bold green]",
1427
+ border_style="green"
1428
+ )
1429
+
1430
+ self.console.print()
1431
+ self.console.print(panel)
1432
+
1433
+ def display_application_info(self, user_guid: str):
1434
+ """
1435
+ Display application and user information.
1436
+
1437
+ Args:
1438
+ user_guid: User's unique identifier
1439
+ """
1440
+ # Create content for the panel
1441
+ content_parts = []
1442
+
1443
+ # User GUID
1444
+ content_parts.append(f"[bold cyan]User GUID:[/bold cyan] [blue]{user_guid}[/blue]")
1445
+ content_parts.append("")
1446
+ content_parts.append("[dim]This unique identifier is used for database isolation[/dim]")
1447
+ content_parts.append("[dim]and multi-user support when using shared databases.[/dim]")
1448
+
1449
+ # Create panel
1450
+ content_text = "\n".join(content_parts)
1451
+ panel = Panel(
1452
+ content_text,
1453
+ title="[bold green]📋 Application Information[/bold green]",
1454
+ border_style="green"
1455
+ )
1456
+
1457
+ self.console.print()
1458
+ self.console.print(panel)
1459
+
1460
+ def display_tool_call(self, tool_name: str, tool_input: Dict):
1461
+ """
1462
+ Display a tool call during chat.
1463
+
1464
+ Args:
1465
+ tool_name: Name of the tool being called
1466
+ tool_input: Input parameters for the tool
1467
+ """
1468
+ # Format input nicely
1469
+ input_str = ", ".join([f"{k}={v}" for k, v in tool_input.items()])
1470
+
1471
+ self.console.print(
1472
+ f"\n[dim]🔧 Calling tool:[/dim] [bold cyan]{tool_name}[/bold cyan]"
1473
+ f"[dim]({input_str})[/dim]"
1474
+ )
1475
+
1476
+ def display_tool_result(self, tool_name: str, result: str, is_error: bool = False):
1477
+ """
1478
+ Display a tool result during chat.
1479
+
1480
+ Args:
1481
+ tool_name: Name of the tool that was called
1482
+ result: Result from the tool
1483
+ is_error: Whether the result is an error
1484
+ """
1485
+ if is_error:
1486
+ self.console.print(
1487
+ f"[dim] ✗ Tool failed:[/dim] [red]{result}[/red]"
1488
+ )
1489
+ else:
1490
+ # Truncate long results
1491
+ display_result = result if len(result) <= 100 else result[:100] + "..."
1492
+ self.console.print(
1493
+ f"[dim] ✓ Result:[/dim] [green]{display_result}[/green]"
1494
+ )
1495
+
1496
+ def get_file_attachments(self, supported_extensions: str) -> List[Dict]:
1497
+ """
1498
+ Prompt user to attach files or directories to the conversation.
1499
+
1500
+ Args:
1501
+ supported_extensions: Comma-separated list of supported file extensions
1502
+
1503
+ Returns:
1504
+ List of dictionaries with 'path' and 'tags' keys
1505
+ """
1506
+ file_attachments = []
1507
+
1508
+ # Ask if user wants to attach files
1509
+ attach_files = self.confirm("Would you like to attach files to this conversation?")
1510
+
1511
+ if not attach_files:
1512
+ return file_attachments
1513
+
1514
+ # Display supported file types
1515
+ self.console.print()
1516
+ info_panel = Panel(
1517
+ f"[cyan]Supported file types:[/cyan]\n{supported_extensions}\n\n"
1518
+ f"[yellow]You can provide file paths or directory paths.[/yellow]",
1519
+ title="[bold cyan]📎 File Attachments[/bold cyan]",
1520
+ border_style="cyan",
1521
+ box=box.ROUNDED
1522
+ )
1523
+ self.console.print(info_panel)
1524
+ self.console.print()
1525
+
1526
+ # Get file/directory paths from user
1527
+ self.print_info("Enter file or directory paths one at a time (press Enter with empty path to finish)")
1528
+
1529
+ while True:
1530
+ input_path = self.get_input("File/Directory path (or press Enter to finish)").strip()
1531
+
1532
+ if not input_path:
1533
+ # User pressed Enter with empty input
1534
+ break
1535
+
1536
+ # Check if path exists
1537
+ from pathlib import Path
1538
+ from dtSpark.files.manager import FileManager
1539
+
1540
+ path = Path(input_path)
1541
+
1542
+ if not path.exists():
1543
+ self.print_error(f"Path not found: {input_path}")
1544
+ continue
1545
+
1546
+ # Handle directories
1547
+ if path.is_dir():
1548
+ self.print_info(f"Directory detected: {path.name}")
1549
+
1550
+ # Ask if recursive
1551
+ recursive = self.confirm(" Include files from subdirectories?")
1552
+
1553
+ # Scan directory
1554
+ try:
1555
+ found_files = FileManager.scan_directory(str(path.absolute()), recursive=recursive)
1556
+
1557
+ if not found_files:
1558
+ self.print_warning(f" No supported files found in directory")
1559
+ continue
1560
+
1561
+ self.print_success(f" Found {len(found_files)} supported file(s)")
1562
+
1563
+ # Ask for tags for this batch
1564
+ assign_tags = self.confirm(" Would you like to assign tags to these files?")
1565
+ tags = None
1566
+ if assign_tags:
1567
+ tags_input = self.get_input(" Enter tags (comma-separated)").strip()
1568
+ if tags_input:
1569
+ tags = tags_input
1570
+ self.print_success(f" Tagged {len(found_files)} file(s) with: {tags}")
1571
+
1572
+ # Add all found files with the same tags
1573
+ for file_path in found_files:
1574
+ file_attachments.append({
1575
+ 'path': file_path,
1576
+ 'tags': tags
1577
+ })
1578
+
1579
+ except Exception as e:
1580
+ self.print_error(f" Error scanning directory: {e}")
1581
+ continue
1582
+
1583
+ # Handle individual files
1584
+ elif path.is_file():
1585
+ # Check if file is supported
1586
+ if not FileManager.is_supported(str(path)):
1587
+ self.print_error(f"Unsupported file type: {path.suffix}")
1588
+ continue
1589
+
1590
+ # Ask for tags for this file
1591
+ assign_tags = self.confirm(f" Assign tags to '{path.name}'?")
1592
+ tags = None
1593
+ if assign_tags:
1594
+ tags_input = self.get_input(" Enter tags (comma-separated)").strip()
1595
+ if tags_input:
1596
+ tags = tags_input
1597
+
1598
+ file_attachments.append({
1599
+ 'path': str(path.absolute()),
1600
+ 'tags': tags
1601
+ })
1602
+
1603
+ tags_str = f" with tags: {tags}" if tags else ""
1604
+ self.print_success(f"Added: {path.name}{tags_str}")
1605
+
1606
+ else:
1607
+ self.print_error(f"Invalid path type: {input_path}")
1608
+ continue
1609
+
1610
+ if file_attachments:
1611
+ self.console.print()
1612
+
1613
+ # Count files by tags
1614
+ tagged_count = sum(1 for f in file_attachments if f['tags'])
1615
+ untagged_count = len(file_attachments) - tagged_count
1616
+
1617
+ self.print_success(f"Total files to attach: {len(file_attachments)}")
1618
+ if tagged_count > 0:
1619
+ self.print_info(f" - {tagged_count} file(s) with tags")
1620
+ if untagged_count > 0:
1621
+ self.print_info(f" - {untagged_count} file(s) without tags")
1622
+ self.console.print()
1623
+
1624
+ return file_attachments
1625
+
1626
+ def display_attached_files(self, files: List[Dict]):
1627
+ """
1628
+ Display attached files for a conversation.
1629
+
1630
+ Args:
1631
+ files: List of file dictionaries from database
1632
+ """
1633
+ if not files:
1634
+ return
1635
+
1636
+ # Create table for attached files
1637
+ table = Table(
1638
+ show_header=True,
1639
+ header_style="bold magenta",
1640
+ box=box.ROUNDED,
1641
+ border_style="blue"
1642
+ )
1643
+ table.add_column("ID", style="dim", justify="right")
1644
+ table.add_column("Filename", style="cyan")
1645
+ table.add_column("Type", style="green", justify="center")
1646
+ table.add_column("Size", style="yellow", justify="right")
1647
+ table.add_column("Tokens", style="magenta", justify="right")
1648
+ table.add_column("Tags", style="bright_blue")
1649
+
1650
+ for file_info in files:
1651
+ # Format file size
1652
+ size_bytes = file_info.get('file_size', 0)
1653
+ if size_bytes < 1024:
1654
+ size_str = f"{size_bytes} B"
1655
+ elif size_bytes < 1024 * 1024:
1656
+ size_str = f"{size_bytes / 1024:.1f} KB"
1657
+ else:
1658
+ size_str = f"{size_bytes / (1024 * 1024):.1f} MB"
1659
+
1660
+ # Format tags
1661
+ tags = file_info.get('tags', None)
1662
+ if tags:
1663
+ # Split tags and format them nicely
1664
+ tag_list = [t.strip() for t in tags.split(',') if t.strip()]
1665
+ tags_str = ', '.join([f"[bold]{tag}[/bold]" for tag in tag_list])
1666
+ else:
1667
+ tags_str = "[dim]-[/dim]"
1668
+
1669
+ table.add_row(
1670
+ str(file_info['id']),
1671
+ file_info['filename'],
1672
+ file_info['file_type'],
1673
+ size_str,
1674
+ f"{file_info.get('token_count', 0):,}",
1675
+ tags_str
1676
+ )
1677
+
1678
+ # Display in panel
1679
+ panel = Panel(
1680
+ table,
1681
+ title=f"[bold blue]📎 Attached Files ({len(files)})[/bold blue]",
1682
+ border_style="blue"
1683
+ )
1684
+
1685
+ self.console.print()
1686
+ self.console.print(panel)
1687
+
1688
+ def display_mcp_transactions(self, transactions: List[Dict], title: str = "MCP Tool Transactions"):
1689
+ """
1690
+ Display MCP transaction history for security monitoring.
1691
+
1692
+ Args:
1693
+ transactions: List of transaction dictionaries
1694
+ title: Title for the display panel
1695
+ """
1696
+ if not transactions:
1697
+ self.print_info("No MCP transactions found")
1698
+ return
1699
+
1700
+ # Create table for transactions
1701
+ table = Table(
1702
+ show_header=True,
1703
+ header_style="bold magenta",
1704
+ box=box.ROUNDED,
1705
+ border_style="yellow"
1706
+ )
1707
+ table.add_column("ID", style="dim", width=6)
1708
+ table.add_column("Timestamp", style="cyan", width=19)
1709
+ table.add_column("Tool", style="green")
1710
+ table.add_column("Server", style="blue")
1711
+ table.add_column("Status", style="white", justify="center", width=8)
1712
+ table.add_column("Time(ms)", style="magenta", justify="right", width=10)
1713
+
1714
+ for txn in transactions:
1715
+ timestamp = datetime.fromisoformat(txn['transaction_timestamp'])
1716
+ status = "[red]ERROR[/red]" if txn['is_error'] else "[green]OK[/green]"
1717
+ exec_time = str(txn['execution_time_ms']) if txn['execution_time_ms'] else "-"
1718
+
1719
+ table.add_row(
1720
+ str(txn['id']),
1721
+ timestamp.strftime('%Y-%m-%d %H:%M:%S'),
1722
+ txn['tool_name'],
1723
+ txn['tool_server'],
1724
+ status,
1725
+ exec_time
1726
+ )
1727
+
1728
+ # Display in panel
1729
+ panel = Panel(
1730
+ table,
1731
+ title=f"[bold yellow]🔐 {title} ({len(transactions)})[/bold yellow]",
1732
+ border_style="yellow"
1733
+ )
1734
+
1735
+ self.console.print()
1736
+ self.console.print(panel)
1737
+
1738
+ def display_mcp_transaction_details(self, transaction: Dict):
1739
+ """
1740
+ Display detailed information about a specific MCP transaction.
1741
+
1742
+ Args:
1743
+ transaction: Transaction dictionary
1744
+ """
1745
+ import json
1746
+
1747
+ # Create details table
1748
+ details_table = Table(show_header=False, box=None, padding=(0, 2))
1749
+ details_table.add_column("Label", style="bold yellow")
1750
+ details_table.add_column("Value", style="white")
1751
+
1752
+ timestamp = datetime.fromisoformat(transaction['transaction_timestamp'])
1753
+ status = "ERROR" if transaction['is_error'] else "SUCCESS"
1754
+ status_style = "red" if transaction['is_error'] else "green"
1755
+
1756
+ details_table.add_row("Transaction ID", str(transaction['id']))
1757
+ details_table.add_row("Timestamp", timestamp.strftime('%Y-%m-%d %H:%M:%S'))
1758
+ details_table.add_row("Conversation ID", str(transaction['conversation_id']))
1759
+ details_table.add_row("Tool Name", transaction['tool_name'])
1760
+ details_table.add_row("Tool Server", transaction['tool_server'])
1761
+ details_table.add_row("Status", f"[{status_style}]{status}[/{status_style}]")
1762
+ if transaction['execution_time_ms']:
1763
+ details_table.add_row("Execution Time", f"{transaction['execution_time_ms']} ms")
1764
+
1765
+ self.console.print()
1766
+ self.console.print(Panel(
1767
+ details_table,
1768
+ title="[bold yellow]🔐 Transaction Details[/bold yellow]",
1769
+ border_style="yellow"
1770
+ ))
1771
+
1772
+ # User prompt
1773
+ self.console.print()
1774
+ self.console.print(Panel(
1775
+ transaction['user_prompt'],
1776
+ title="[bold cyan]User Prompt[/bold cyan]",
1777
+ border_style="cyan"
1778
+ ))
1779
+
1780
+ # Tool input
1781
+ self.console.print()
1782
+ try:
1783
+ input_formatted = json.dumps(json.loads(transaction['tool_input']), indent=2)
1784
+ except:
1785
+ input_formatted = transaction['tool_input']
1786
+
1787
+ self.console.print(Panel(
1788
+ input_formatted,
1789
+ title="[bold blue]Tool Input[/bold blue]",
1790
+ border_style="blue"
1791
+ ))
1792
+
1793
+ # Tool response
1794
+ self.console.print()
1795
+ response_style = "red" if transaction['is_error'] else "green"
1796
+ response_text = transaction['tool_response']
1797
+ if len(response_text) > 500:
1798
+ response_text = response_text[:500] + "\n\n[... truncated for display ...]"
1799
+
1800
+ self.console.print(Panel(
1801
+ response_text,
1802
+ title=f"[bold {response_style}]Tool Response[/bold {response_style}]",
1803
+ border_style=response_style
1804
+ ))
1805
+
1806
+ def display_mcp_stats(self, stats: Dict):
1807
+ """
1808
+ Display MCP transaction statistics for security monitoring.
1809
+
1810
+ Args:
1811
+ stats: Statistics dictionary
1812
+ """
1813
+ # Create stats table
1814
+ stats_table = Table(show_header=False, box=None, padding=(0, 2))
1815
+ stats_table.add_column("Metric", style="bold yellow")
1816
+ stats_table.add_column("Value", style="white")
1817
+
1818
+ stats_table.add_row("Total Transactions", f"{stats['total_transactions']:,}")
1819
+ stats_table.add_row("Errors", f"{stats['error_count']:,}")
1820
+ stats_table.add_row("Error Rate", f"{stats['error_rate']:.2f}%")
1821
+
1822
+ self.console.print()
1823
+ self.console.print(Panel(
1824
+ stats_table,
1825
+ title="[bold yellow]📊 MCP Transaction Statistics[/bold yellow]",
1826
+ border_style="yellow"
1827
+ ))
1828
+
1829
+ # Top tools
1830
+ if stats['top_tools']:
1831
+ self.console.print()
1832
+ tools_table = Table(
1833
+ show_header=True,
1834
+ header_style="bold magenta",
1835
+ box=box.ROUNDED,
1836
+ border_style="green"
1837
+ )
1838
+ tools_table.add_column("Tool", style="cyan")
1839
+ tools_table.add_column("Usage Count", style="green", justify="right")
1840
+
1841
+ for tool in stats['top_tools']:
1842
+ tools_table.add_row(tool['tool'], str(tool['count']))
1843
+
1844
+ self.console.print(Panel(
1845
+ tools_table,
1846
+ title="[bold green]🔧 Most Used Tools[/bold green]",
1847
+ border_style="green"
1848
+ ))
1849
+
1850
+ # Top conversations
1851
+ if stats['top_conversations']:
1852
+ self.console.print()
1853
+ conv_table = Table(
1854
+ show_header=True,
1855
+ header_style="bold magenta",
1856
+ box=box.ROUNDED,
1857
+ border_style="cyan"
1858
+ )
1859
+ conv_table.add_column("Conversation", style="cyan")
1860
+ conv_table.add_column("Tool Calls", style="green", justify="right")
1861
+
1862
+ for conv in stats['top_conversations']:
1863
+ conv_table.add_row(conv['conversation'], str(conv['count']))
1864
+
1865
+ self.console.print(Panel(
1866
+ conv_table,
1867
+ title="[bold cyan]💬 Conversations with Most Tool Usage[/bold cyan]",
1868
+ border_style="cyan"
1869
+ ))
1870
+
1871
+ def display_mcp_server_states(self, server_states: List[Dict]) -> None:
1872
+ """
1873
+ Display MCP server enabled/disabled states.
1874
+
1875
+ Args:
1876
+ server_states: List of dicts with 'server_name' and 'enabled' keys
1877
+ """
1878
+ if not server_states:
1879
+ self.console.print("[yellow]No MCP servers available[/yellow]")
1880
+ return
1881
+
1882
+ self.console.print()
1883
+ self.console.print("[bold cyan]═══ MCP Server States ═══[/bold cyan]")
1884
+ self.console.print()
1885
+
1886
+ table = Table(
1887
+ show_header=True,
1888
+ header_style="bold magenta",
1889
+ box=box.ROUNDED,
1890
+ border_style="cyan"
1891
+ )
1892
+ table.add_column("Server Name", style="cyan")
1893
+ table.add_column("Status", justify="center")
1894
+
1895
+ for state in server_states:
1896
+ status = "[green]✓ Enabled[/green]" if state['enabled'] else "[red]✗ Disabled[/red]"
1897
+ table.add_row(state['server_name'], status)
1898
+
1899
+ self.console.print(table)
1900
+
1901
+ def display_prompt_violation(self, inspection_result) -> None:
1902
+ """
1903
+ Display prompt security violation with details.
1904
+
1905
+ Args:
1906
+ inspection_result: InspectionResult from prompt inspector
1907
+ """
1908
+ from rich.panel import Panel
1909
+
1910
+ self.console.print()
1911
+
1912
+ # Build violation message
1913
+ title = "[bold red]🛡️ Security Violation Detected[/bold red]"
1914
+
1915
+ content_parts = []
1916
+
1917
+ # Severity
1918
+ severity_colors = {
1919
+ 'low': 'yellow',
1920
+ 'medium': 'orange1',
1921
+ 'high': 'red',
1922
+ 'critical': 'bold red'
1923
+ }
1924
+ severity_color = severity_colors.get(inspection_result.severity, 'red')
1925
+ content_parts.append(f"[bold]Severity:[/bold] [{severity_color}]{inspection_result.severity.upper()}[/{severity_color}]")
1926
+
1927
+ # Violation types
1928
+ if inspection_result.violation_types:
1929
+ violations_text = ', '.join(inspection_result.violation_types)
1930
+ content_parts.append(f"[bold]Violations:[/bold] {violations_text}")
1931
+
1932
+ # Explanation
1933
+ content_parts.append(f"\n[bold]Details:[/bold]\n{inspection_result.explanation}")
1934
+
1935
+ # Detected patterns (sample)
1936
+ if inspection_result.detected_patterns:
1937
+ sample = inspection_result.detected_patterns[:2]
1938
+ patterns_text = ', '.join(f'"{p}"' for p in sample)
1939
+ if len(inspection_result.detected_patterns) > 2:
1940
+ patterns_text += f" (+{len(inspection_result.detected_patterns) - 2} more)"
1941
+ content_parts.append(f"\n[bold]Detected Patterns:[/bold] {patterns_text}")
1942
+
1943
+ # Detection method
1944
+ content_parts.append(f"\n[dim]Detection method: {inspection_result.inspection_method}[/dim]")
1945
+
1946
+ # Create panel
1947
+ panel = Panel(
1948
+ "\n".join(content_parts),
1949
+ title=title,
1950
+ border_style="red",
1951
+ padding=(1, 2)
1952
+ )
1953
+
1954
+ self.console.print(panel)
1955
+ self.console.print()
1956
+ self.console.print("[bold red]❌ This prompt has been blocked for security reasons.[/bold red]")
1957
+ self.console.print()
1958
+
1959
+ def confirm_risky_prompt(self, inspection_result) -> bool:
1960
+ """
1961
+ Ask user to confirm they want to send a risky prompt.
1962
+
1963
+ Args:
1964
+ inspection_result: InspectionResult from prompt inspector
1965
+
1966
+ Returns:
1967
+ True if user confirms, False otherwise
1968
+ """
1969
+ from rich.panel import Panel
1970
+
1971
+ self.console.print()
1972
+
1973
+ # Build warning message
1974
+ title = "[bold yellow]⚠️ Security Warning[/bold yellow]"
1975
+
1976
+ content_parts = []
1977
+
1978
+ # Severity
1979
+ severity_colors = {
1980
+ 'low': 'yellow',
1981
+ 'medium': 'orange1',
1982
+ 'high': 'red',
1983
+ 'critical': 'bold red'
1984
+ }
1985
+ severity_color = severity_colors.get(inspection_result.severity, 'yellow')
1986
+ content_parts.append(f"[bold]Severity:[/bold] [{severity_color}]{inspection_result.severity.upper()}[/{severity_color}]")
1987
+
1988
+ # Violation types
1989
+ if inspection_result.violation_types:
1990
+ violations_text = ', '.join(inspection_result.violation_types)
1991
+ content_parts.append(f"[bold]Potential Issues:[/bold] {violations_text}")
1992
+
1993
+ # Explanation
1994
+ content_parts.append(f"\n[bold]Details:[/bold]\n{inspection_result.explanation}")
1995
+
1996
+ # Detected patterns (sample)
1997
+ if inspection_result.detected_patterns:
1998
+ sample = inspection_result.detected_patterns[:2]
1999
+ patterns_text = ', '.join(f'"{p}"' for p in sample)
2000
+ if len(inspection_result.detected_patterns) > 2:
2001
+ patterns_text += f" (+{len(inspection_result.detected_patterns) - 2} more)"
2002
+ content_parts.append(f"\n[bold]Detected Patterns:[/bold] {patterns_text}")
2003
+
2004
+ # Sanitised version available?
2005
+ if inspection_result.sanitised_prompt:
2006
+ content_parts.append("\n[green]ℹ A sanitised version of your prompt is available.[/green]")
2007
+
2008
+ # Detection method
2009
+ content_parts.append(f"\n[dim]Detection method: {inspection_result.inspection_method}[/dim]")
2010
+
2011
+ # Create panel
2012
+ panel = Panel(
2013
+ "\n".join(content_parts),
2014
+ title=title,
2015
+ border_style="yellow",
2016
+ padding=(1, 2)
2017
+ )
2018
+
2019
+ self.console.print(panel)
2020
+ self.console.print()
2021
+
2022
+ # Prompt for confirmation
2023
+ response = Prompt.ask(
2024
+ "[bold yellow]Do you want to proceed with this prompt?[/bold yellow]",
2025
+ choices=["y", "n"],
2026
+ default="n"
2027
+ )
2028
+
2029
+ return response.lower() == 'y'
2030
+
2031
+ def select_mcp_server(self, server_states: List[Dict], action: str = "toggle") -> Optional[str]:
2032
+ """
2033
+ Let user select an MCP server from a list.
2034
+
2035
+ Args:
2036
+ server_states: List of dicts with 'server_name' and 'enabled' keys
2037
+ action: Action description (e.g., "toggle", "enable", "disable")
2038
+
2039
+ Returns:
2040
+ Selected server name or None if cancelled
2041
+ """
2042
+ if not server_states:
2043
+ return None
2044
+
2045
+ self.console.print(f"\n[bold cyan]Select a server to {action}:[/bold cyan]")
2046
+ for i, state in enumerate(server_states, 1):
2047
+ status = "[green]enabled[/green]" if state['enabled'] else "[red]disabled[/red]"
2048
+ self.console.print(f" [{i}] {state['server_name']} ({status})")
2049
+ self.console.print(" [0] Cancel")
2050
+
2051
+ choice = self.get_input("Enter choice")
2052
+ try:
2053
+ idx = int(choice)
2054
+ if idx == 0:
2055
+ return None
2056
+ if 1 <= idx <= len(server_states):
2057
+ return server_states[idx - 1]['server_name']
2058
+ else:
2059
+ self.print_error("Invalid choice")
2060
+ return None
2061
+ except ValueError:
2062
+ self.print_error("Invalid input")
2063
+ return None
2064
+
2065
+ # =========================================================================
2066
+ # Autonomous Actions Interface
2067
+ # =========================================================================
2068
+
2069
+ def display_autonomous_actions_menu(self, failed_action_count: int = 0) -> str:
2070
+ """
2071
+ Display the autonomous actions submenu.
2072
+
2073
+ Args:
2074
+ failed_action_count: Number of failed/disabled actions to show as indicator
2075
+
2076
+ Returns:
2077
+ User's menu choice
2078
+ """
2079
+ menu_content = Text()
2080
+
2081
+ # Show warning if there are failed actions
2082
+ if failed_action_count > 0:
2083
+ menu_content.append(f" ⚠️ {failed_action_count} action(s) disabled due to failures\n\n", style="yellow")
2084
+
2085
+ options = [
2086
+ ('1', 'List Actions', 'list'),
2087
+ ('2', 'Create New Action', 'create'),
2088
+ ('3', 'View Action Runs', 'runs'),
2089
+ ('4', 'Run Now (Manual)', 'run_now'),
2090
+ ('5', 'Enable/Disable Action', 'toggle'),
2091
+ ('6', 'Delete Action', 'delete'),
2092
+ ('7', 'Export Run Results', 'export'),
2093
+ ('8', 'Back to Main Menu', 'back')
2094
+ ]
2095
+
2096
+ choice_map = {}
2097
+ for num, label, action in options:
2098
+ menu_content.append(" ", style="")
2099
+ menu_content.append(num, style="cyan")
2100
+ menu_content.append(f". {label}\n", style="")
2101
+ choice_map[num] = action
2102
+
2103
+ menu_panel = Panel(
2104
+ menu_content,
2105
+ title="[bold bright_magenta]AUTONOMOUS ACTIONS[/bold bright_magenta]",
2106
+ border_style="bold cyan",
2107
+ box=box.HEAVY,
2108
+ padding=(0, 1)
2109
+ )
2110
+
2111
+ self.console.print()
2112
+ self.console.print(menu_panel)
2113
+ self.console.print()
2114
+
2115
+ choice = self.get_input("Select an option")
2116
+ return choice_map.get(choice, 'invalid')
2117
+
2118
+ def select_action_creation_method(self) -> Optional[str]:
2119
+ """
2120
+ Prompt user to select action creation method.
2121
+
2122
+ Returns:
2123
+ 'manual', 'prompt_driven', or None if cancelled
2124
+ """
2125
+ self.console.print("\n[bold cyan]Create Autonomous Action[/bold cyan]")
2126
+ self.console.print("─" * 40)
2127
+ self.console.print("Choose creation method:")
2128
+ self.console.print(" [cyan]1.[/cyan] Manual Wizard (step-by-step)")
2129
+ self.console.print(" [cyan]2.[/cyan] Prompt-Driven (conversational with AI)")
2130
+ self.console.print(" [cyan]3.[/cyan] Cancel")
2131
+
2132
+ choice = self.get_input("Select")
2133
+ if choice == "1":
2134
+ return "manual"
2135
+ elif choice == "2":
2136
+ return "prompt_driven"
2137
+ return None
2138
+
2139
+ def display_actions_list(self, actions: List[Dict]):
2140
+ """
2141
+ Display a table of autonomous actions.
2142
+
2143
+ Args:
2144
+ actions: List of action dictionaries
2145
+ """
2146
+ if not actions:
2147
+ self.print_info("No autonomous actions defined")
2148
+ return
2149
+
2150
+ table = Table(
2151
+ show_header=True,
2152
+ header_style="bold magenta",
2153
+ box=box.ROUNDED,
2154
+ border_style="cyan"
2155
+ )
2156
+ table.add_column("ID", style="dim", justify="right")
2157
+ table.add_column("Name", style="cyan")
2158
+ table.add_column("Schedule", style="green")
2159
+ table.add_column("Context", style="yellow")
2160
+ table.add_column("Status", justify="center")
2161
+ table.add_column("Last Run", style="dim")
2162
+ table.add_column("Failures", justify="right")
2163
+
2164
+ for action in actions:
2165
+ # Format schedule
2166
+ if action['schedule_type'] == 'one_off':
2167
+ config = action.get('schedule_config', {})
2168
+ run_date = config.get('run_date', 'N/A')
2169
+ if isinstance(run_date, str) and len(run_date) > 16:
2170
+ run_date = run_date[:16]
2171
+ schedule = f"One-off: {run_date}"
2172
+ else:
2173
+ config = action.get('schedule_config', {})
2174
+ cron = config.get('cron_expression', 'N/A')
2175
+ schedule = f"Cron: {cron}"
2176
+
2177
+ # Format status
2178
+ if action['is_enabled']:
2179
+ status = "[green]Enabled[/green]"
2180
+ else:
2181
+ status = "[red]Disabled[/red]"
2182
+
2183
+ # Format last run
2184
+ last_run = action.get('last_run_at', 'Never')
2185
+ if last_run and last_run != 'Never':
2186
+ if isinstance(last_run, str) and len(last_run) > 16:
2187
+ last_run = last_run[:16]
2188
+
2189
+ # Failure count with warning colour
2190
+ failures = action.get('failure_count', 0)
2191
+ max_failures = action.get('max_failures', 3)
2192
+ if failures >= max_failures:
2193
+ failures_str = f"[red]{failures}/{max_failures}[/red]"
2194
+ elif failures > 0:
2195
+ failures_str = f"[yellow]{failures}/{max_failures}[/yellow]"
2196
+ else:
2197
+ failures_str = f"{failures}/{max_failures}"
2198
+
2199
+ table.add_row(
2200
+ str(action['id']),
2201
+ action['name'][:30],
2202
+ schedule[:30],
2203
+ action.get('context_mode', 'fresh'),
2204
+ status,
2205
+ str(last_run) if last_run else 'Never',
2206
+ failures_str
2207
+ )
2208
+
2209
+ panel = Panel(
2210
+ table,
2211
+ title="[bold cyan]Autonomous Actions[/bold cyan]",
2212
+ border_style="cyan"
2213
+ )
2214
+ self.console.print()
2215
+ self.console.print(panel)
2216
+ self.console.print()
2217
+
2218
+ def display_action_runs(self, runs: List[Dict], action_name: str = None):
2219
+ """
2220
+ Display a table of action runs.
2221
+
2222
+ Args:
2223
+ runs: List of run dictionaries
2224
+ action_name: Optional action name for title
2225
+ """
2226
+ if not runs:
2227
+ self.print_info("No action runs found")
2228
+ return
2229
+
2230
+ table = Table(
2231
+ show_header=True,
2232
+ header_style="bold magenta",
2233
+ box=box.ROUNDED,
2234
+ border_style="cyan"
2235
+ )
2236
+ table.add_column("Run ID", style="dim", justify="right")
2237
+ if not action_name:
2238
+ table.add_column("Action", style="cyan")
2239
+ table.add_column("Started", style="green")
2240
+ table.add_column("Status", justify="center")
2241
+ table.add_column("Duration", style="dim", justify="right")
2242
+ table.add_column("Tokens", style="yellow", justify="right")
2243
+
2244
+ for run in runs:
2245
+ # Format status
2246
+ status = run.get('status', 'unknown')
2247
+ if status == 'completed':
2248
+ status_str = "[green]✓ Completed[/green]"
2249
+ elif status == 'failed':
2250
+ status_str = "[red]✗ Failed[/red]"
2251
+ elif status == 'running':
2252
+ status_str = "[yellow]⟳ Running[/yellow]"
2253
+ else:
2254
+ status_str = status
2255
+
2256
+ # Calculate duration
2257
+ started = run.get('started_at')
2258
+ completed = run.get('completed_at')
2259
+ if started and completed:
2260
+ try:
2261
+ if isinstance(started, str):
2262
+ started = datetime.fromisoformat(started.replace('Z', '+00:00'))
2263
+ if isinstance(completed, str):
2264
+ completed = datetime.fromisoformat(completed.replace('Z', '+00:00'))
2265
+ duration = (completed - started).total_seconds()
2266
+ duration_str = f"{duration:.1f}s"
2267
+ except:
2268
+ duration_str = "N/A"
2269
+ else:
2270
+ duration_str = "N/A"
2271
+
2272
+ # Format tokens
2273
+ input_tokens = run.get('input_tokens', 0)
2274
+ output_tokens = run.get('output_tokens', 0)
2275
+ tokens_str = f"{input_tokens:,}/{output_tokens:,}"
2276
+
2277
+ # Format started time
2278
+ started_str = str(started)[:19] if started else 'N/A'
2279
+
2280
+ row = [str(run['id'])]
2281
+ if not action_name:
2282
+ row.append(run.get('action_name', 'Unknown')[:20])
2283
+ row.extend([started_str, status_str, duration_str, tokens_str])
2284
+
2285
+ table.add_row(*row)
2286
+
2287
+ title = f"[bold cyan]Runs for '{action_name}'[/bold cyan]" if action_name else "[bold cyan]Recent Action Runs[/bold cyan]"
2288
+ panel = Panel(table, title=title, border_style="cyan")
2289
+ self.console.print()
2290
+ self.console.print(panel)
2291
+ self.console.print()
2292
+
2293
+ def display_run_details(self, run: Dict):
2294
+ """
2295
+ Display detailed information about a single run.
2296
+
2297
+ Args:
2298
+ run: Run dictionary
2299
+ """
2300
+ content_parts = []
2301
+
2302
+ content_parts.append(f"[bold]Run ID:[/bold] {run['id']}")
2303
+ content_parts.append(f"[bold]Action:[/bold] {run.get('action_name', 'Unknown')}")
2304
+ content_parts.append(f"[bold]Status:[/bold] {run['status']}")
2305
+ content_parts.append(f"[bold]Started:[/bold] {run.get('started_at', 'N/A')}")
2306
+ content_parts.append(f"[bold]Completed:[/bold] {run.get('completed_at', 'N/A')}")
2307
+ content_parts.append(f"[bold]Input Tokens:[/bold] {run.get('input_tokens', 0):,}")
2308
+ content_parts.append(f"[bold]Output Tokens:[/bold] {run.get('output_tokens', 0):,}")
2309
+
2310
+ if run.get('error_message'):
2311
+ content_parts.append(f"\n[bold red]Error:[/bold red] {run['error_message']}")
2312
+
2313
+ if run.get('result_text'):
2314
+ content_parts.append("\n[bold]Result:[/bold]")
2315
+ # Truncate long results
2316
+ result = run['result_text']
2317
+ if len(result) > 1000:
2318
+ result = result[:1000] + "\n... (truncated)"
2319
+ content_parts.append(result)
2320
+
2321
+ panel = Panel(
2322
+ "\n".join(content_parts),
2323
+ title="[bold cyan]Run Details[/bold cyan]",
2324
+ border_style="cyan",
2325
+ padding=(1, 2)
2326
+ )
2327
+ self.console.print()
2328
+ self.console.print(panel)
2329
+ self.console.print()
2330
+
2331
+ def select_action(self, actions: List[Dict], prompt: str = "Select an action") -> Optional[int]:
2332
+ """
2333
+ Let user select an action from a list.
2334
+
2335
+ Args:
2336
+ actions: List of action dictionaries
2337
+ prompt: Prompt to display
2338
+
2339
+ Returns:
2340
+ Selected action ID or None if cancelled
2341
+ """
2342
+ if not actions:
2343
+ self.print_warning("No actions available")
2344
+ return None
2345
+
2346
+ self.console.print(f"\n[bold cyan]{prompt}:[/bold cyan]")
2347
+ for i, action in enumerate(actions, 1):
2348
+ status = "[green]enabled[/green]" if action['is_enabled'] else "[red]disabled[/red]"
2349
+ self.console.print(f" [{i}] {action['name']} ({status})")
2350
+ self.console.print(" [0] Cancel")
2351
+
2352
+ choice = self.get_input("Enter choice")
2353
+ try:
2354
+ idx = int(choice)
2355
+ if idx == 0:
2356
+ return None
2357
+ if 1 <= idx <= len(actions):
2358
+ return actions[idx - 1]['id']
2359
+ else:
2360
+ self.print_error("Invalid choice")
2361
+ return None
2362
+ except ValueError:
2363
+ self.print_error("Invalid input")
2364
+ return None
2365
+
2366
+ def select_run(self, runs: List[Dict], prompt: str = "Select a run") -> Optional[int]:
2367
+ """
2368
+ Let user select a run from a list.
2369
+
2370
+ Args:
2371
+ runs: List of run dictionaries
2372
+ prompt: Prompt to display
2373
+
2374
+ Returns:
2375
+ Selected run ID or None if cancelled
2376
+ """
2377
+ if not runs:
2378
+ self.print_warning("No runs available")
2379
+ return None
2380
+
2381
+ self.console.print(f"\n[bold cyan]{prompt}:[/bold cyan]")
2382
+ for i, run in enumerate(runs, 1):
2383
+ status = run.get('status', 'unknown')
2384
+ started = str(run.get('started_at', ''))[:16]
2385
+ self.console.print(f" [{i}] Run {run['id']} - {status} ({started})")
2386
+ self.console.print(" [0] Cancel")
2387
+
2388
+ choice = self.get_input("Enter choice")
2389
+ try:
2390
+ idx = int(choice)
2391
+ if idx == 0:
2392
+ return None
2393
+ if 1 <= idx <= len(runs):
2394
+ return runs[idx - 1]['id']
2395
+ else:
2396
+ self.print_error("Invalid choice")
2397
+ return None
2398
+ except ValueError:
2399
+ self.print_error("Invalid input")
2400
+ return None
2401
+
2402
+ def select_export_format(self) -> Optional[str]:
2403
+ """
2404
+ Let user select an export format.
2405
+
2406
+ Returns:
2407
+ 'text', 'html', 'markdown', or None if cancelled
2408
+ """
2409
+ self.console.print("\n[bold cyan]Select export format:[/bold cyan]")
2410
+ self.console.print(" [1] Plain Text")
2411
+ self.console.print(" [2] HTML")
2412
+ self.console.print(" [3] Markdown")
2413
+ self.console.print(" [0] Cancel")
2414
+
2415
+ choice = self.get_input("Enter choice")
2416
+ format_map = {'1': 'text', '2': 'html', '3': 'markdown'}
2417
+ return format_map.get(choice)
2418
+
2419
+ def create_action_wizard(self, available_models: List[Dict],
2420
+ available_tools: List[Dict]) -> Optional[Dict]:
2421
+ """
2422
+ Interactive wizard for creating a new autonomous action.
2423
+
2424
+ Args:
2425
+ available_models: List of available model dictionaries
2426
+ available_tools: List of available tool dictionaries
2427
+
2428
+ Returns:
2429
+ Action configuration dictionary or None if cancelled
2430
+ """
2431
+ self.console.print("\n[bold cyan]═══ Create New Autonomous Action ═══[/bold cyan]\n")
2432
+
2433
+ # Step 1: Name
2434
+ name = self.get_input("Action name (unique identifier)")
2435
+ if not name:
2436
+ self.print_error("Name is required")
2437
+ return None
2438
+
2439
+ # Step 2: Description
2440
+ description = self.get_input("Description (what this action does)")
2441
+ if not description:
2442
+ description = name
2443
+
2444
+ # Step 3: Action prompt
2445
+ self.console.print("\n[bold]Enter the action prompt:[/bold]")
2446
+ self.console.print("[dim]This is what the AI will execute each time the action runs.[/dim]")
2447
+ action_prompt = self.get_multiline_input("Action prompt")
2448
+ if not action_prompt:
2449
+ self.print_error("Action prompt is required")
2450
+ return None
2451
+
2452
+ # Step 4: Model selection
2453
+ self.console.print("\n[bold cyan]Select a model:[/bold cyan]")
2454
+ if not available_models:
2455
+ self.print_error("No models available")
2456
+ return None
2457
+
2458
+ for i, model in enumerate(available_models, 1):
2459
+ friendly_name = extract_friendly_model_name(model.get('id', ''))
2460
+ self.console.print(f" [{i}] {friendly_name}")
2461
+
2462
+ model_choice = self.get_input("Enter choice")
2463
+ try:
2464
+ model_idx = int(model_choice) - 1
2465
+ if model_idx < 0 or model_idx >= len(available_models):
2466
+ self.print_error("Invalid model selection")
2467
+ return None
2468
+ model_id = available_models[model_idx]['id']
2469
+ except ValueError:
2470
+ self.print_error("Invalid input")
2471
+ return None
2472
+
2473
+ # Step 5: Schedule type
2474
+ self.console.print("\n[bold cyan]Schedule type:[/bold cyan]")
2475
+ self.console.print(" [1] One-off (run once at specific time)")
2476
+ self.console.print(" [2] Recurring (run on schedule)")
2477
+
2478
+ schedule_choice = self.get_input("Enter choice")
2479
+ if schedule_choice == '1':
2480
+ schedule_type = 'one_off'
2481
+ self.console.print("\n[dim]Enter date/time in format: YYYY-MM-DD HH:MM[/dim]")
2482
+ run_date_str = self.get_input("Run date/time")
2483
+ try:
2484
+ run_date = datetime.strptime(run_date_str, "%Y-%m-%d %H:%M")
2485
+ schedule_config = {'run_date': run_date.isoformat()}
2486
+ except ValueError:
2487
+ self.print_error("Invalid date format")
2488
+ return None
2489
+ elif schedule_choice == '2':
2490
+ schedule_type = 'recurring'
2491
+ self.console.print("\n[dim]Enter cron expression (minute hour day month day_of_week)[/dim]")
2492
+ self.console.print("[dim]Examples: '0 9 * * *' (daily at 9am), '0 0 * * 0' (weekly on Sunday)[/dim]")
2493
+ cron_expr = self.get_input("Cron expression")
2494
+ if not cron_expr:
2495
+ self.print_error("Cron expression is required")
2496
+ return None
2497
+ schedule_config = {'cron_expression': cron_expr}
2498
+ else:
2499
+ self.print_error("Invalid schedule type")
2500
+ return None
2501
+
2502
+ # Step 6: Context mode
2503
+ self.console.print("\n[bold cyan]Context mode:[/bold cyan]")
2504
+ self.console.print(" [1] Fresh - Start with clean context each run")
2505
+ self.console.print(" [2] Cumulative - Carry context from previous runs")
2506
+
2507
+ context_choice = self.get_input("Enter choice")
2508
+ context_mode = 'cumulative' if context_choice == '2' else 'fresh'
2509
+
2510
+ # Step 7: Max failures
2511
+ max_failures_str = self.get_input("Max failures before auto-disable (default: 3)")
2512
+ try:
2513
+ max_failures = int(max_failures_str) if max_failures_str else 3
2514
+ except ValueError:
2515
+ max_failures = 3
2516
+
2517
+ # Step 8: Max tokens
2518
+ self.console.print("\n[bold cyan]Max tokens for LLM response:[/bold cyan]")
2519
+ self.console.print(" Use higher values for tasks that generate large content (e.g., reports)")
2520
+ self.console.print(" Recommended: 4096 (simple tasks), 8192 (default), 16384 (large reports)")
2521
+ max_tokens_str = self.get_input("Max tokens (default: 8192)")
2522
+ try:
2523
+ max_tokens = int(max_tokens_str) if max_tokens_str else 8192
2524
+ # Enforce reasonable limits
2525
+ max_tokens = max(1024, min(max_tokens, 32000))
2526
+ except ValueError:
2527
+ max_tokens = 8192
2528
+
2529
+ # Step 9: Tool selection
2530
+ selected_tools = []
2531
+ if available_tools:
2532
+ self.console.print("\n[bold cyan]Select tools to allow (enter numbers separated by commas, or 'none'):[/bold cyan]")
2533
+ for i, tool in enumerate(available_tools, 1):
2534
+ server = tool.get('server', 'unknown')
2535
+ self.console.print(f" [{i}] {tool['name']} ({server})")
2536
+
2537
+ tools_input = self.get_input("Tool numbers (e.g., 1,3,5) or 'none'")
2538
+ if tools_input.lower() != 'none' and tools_input:
2539
+ try:
2540
+ indices = [int(x.strip()) - 1 for x in tools_input.split(',')]
2541
+ for idx in indices:
2542
+ if 0 <= idx < len(available_tools):
2543
+ tool = available_tools[idx]
2544
+ selected_tools.append({
2545
+ 'tool_name': tool['name'],
2546
+ 'server_name': tool.get('server'),
2547
+ 'permission_state': 'allowed'
2548
+ })
2549
+ except ValueError:
2550
+ self.print_warning("Invalid tool selection, proceeding without tools")
2551
+
2552
+ # Confirm
2553
+ self.console.print("\n[bold cyan]═══ Action Summary ═══[/bold cyan]")
2554
+ self.console.print(f" Name: {name}")
2555
+ self.console.print(f" Description: {description}")
2556
+ self.console.print(f" Model: {extract_friendly_model_name(model_id)}")
2557
+ self.console.print(f" Schedule: {schedule_type} - {schedule_config}")
2558
+ self.console.print(f" Context Mode: {context_mode}")
2559
+ self.console.print(f" Max Failures: {max_failures}")
2560
+ self.console.print(f" Max Tokens: {max_tokens}")
2561
+ self.console.print(f" Tools: {len(selected_tools)}")
2562
+ self.console.print()
2563
+
2564
+ if not self.confirm("Create this action?"):
2565
+ return None
2566
+
2567
+ return {
2568
+ 'name': name,
2569
+ 'description': description,
2570
+ 'action_prompt': action_prompt,
2571
+ 'model_id': model_id,
2572
+ 'schedule_type': schedule_type,
2573
+ 'schedule_config': schedule_config,
2574
+ 'context_mode': context_mode,
2575
+ 'max_failures': max_failures,
2576
+ 'max_tokens': max_tokens,
2577
+ 'tool_permissions': selected_tools
2578
+ }
2579
+
2580
+ def display_creation_conversation_message(
2581
+ self,
2582
+ role: str,
2583
+ content: str,
2584
+ is_final: bool = False
2585
+ ) -> None:
2586
+ """
2587
+ Display a message in the action creation conversation.
2588
+
2589
+ Args:
2590
+ role: Message role ('user' or 'assistant')
2591
+ content: Message content text
2592
+ is_final: Whether this is the final message in the conversation
2593
+ """
2594
+ if role == "user":
2595
+ self.console.print(f"\n[bold purple]You:[/bold purple] {content}")
2596
+ else:
2597
+ self.console.print(f"\n[bold green]Assistant:[/bold green]")
2598
+ self.console.print(Markdown(content))
2599
+
2600
+ if is_final:
2601
+ self.console.print("─" * 40)
2602
+
2603
+ def display_creation_tool_call(self, tool_name: str, result: dict) -> None:
2604
+ """
2605
+ Display a tool call result during action creation.
2606
+
2607
+ Args:
2608
+ tool_name: Name of the tool that was called
2609
+ result: Result dictionary from the tool
2610
+ """
2611
+ self.console.print(f"\n[dim]↳ Called {tool_name}[/dim]")
2612
+
2613
+ if tool_name == 'list_available_tools':
2614
+ count = result.get('count', 0)
2615
+ self.console.print(f"[dim] Found {count} available tools[/dim]")
2616
+
2617
+ elif tool_name == 'validate_schedule':
2618
+ if result.get('valid'):
2619
+ human_readable = result.get('human_readable', 'Valid')
2620
+ self.console.print(f"[dim] Schedule: {human_readable}[/dim]")
2621
+ else:
2622
+ error = result.get('error', 'Invalid')
2623
+ self.console.print(f"[yellow] Validation failed: {error}[/yellow]")
2624
+
2625
+ elif tool_name == 'create_autonomous_action':
2626
+ if result.get('success'):
2627
+ name = result.get('name', 'Action')
2628
+ self.console.print(f"[green] ✓ Created action: {name}[/green]")
2629
+ else:
2630
+ error = result.get('error', 'Creation failed')
2631
+ self.console.print(f"[red] ✗ Error: {error}[/red]")
2632
+
2633
+ def display_creation_prompt_header(self) -> None:
2634
+ """Display the header for prompt-driven action creation."""
2635
+ header = Panel(
2636
+ "[bold]Describe the task you want to schedule.[/bold]\n"
2637
+ "The AI will help you configure the action by asking clarifying questions.\n"
2638
+ "[dim]Type 'cancel' at any time to abort.[/dim]",
2639
+ title="[bold cyan]Prompt-Driven Action Creation[/bold cyan]",
2640
+ border_style="cyan",
2641
+ box=box.ROUNDED
2642
+ )
2643
+ self.console.print()
2644
+ self.console.print(header)
2645
+ self.console.print()