cite-agent 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of cite-agent might be problematic. Click here for more details.

@@ -0,0 +1,417 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Automatic setup and configuration for Nocturnal Archive
4
+ """
5
+
6
+ import os
7
+ from getpass import getpass
8
+ from pathlib import Path
9
+ from typing import Optional, Dict, Any, List, Tuple
10
+
11
+ from .account_client import AccountClient, AccountCredentials, AccountProvisioningError
12
+
13
+ KEY_PLACEHOLDER = "__KEYRING__"
14
+ KEYRING_SERVICE = "Nocturnal Archive"
15
+ DEFAULT_QUERY_LIMIT = 25
16
+
17
+ # Production: Users don't need API keys - backend has them
18
+ # Optional research API keys for advanced features (not required)
19
+ MANAGED_SECRETS: Dict[str, Dict[str, Any]] = {
20
+ "OPENALEX_API_KEY": {
21
+ "label": "OpenAlex",
22
+ "prompt": "OpenAlex API key (optional)",
23
+ "optional": True,
24
+ },
25
+ "PUBMED_API_KEY": {
26
+ "label": "PubMed",
27
+ "prompt": "PubMed API key (optional)",
28
+ "optional": True,
29
+ },
30
+ }
31
+
32
+ class NocturnalConfig:
33
+ """Handles automatic configuration and setup"""
34
+
35
+ def __init__(self):
36
+ self.config_dir = Path.home() / ".nocturnal_archive"
37
+ self.config_file = self.config_dir / "config.env"
38
+ self.ensure_config_dir()
39
+ self._keyring = None
40
+ try:
41
+ import keyring # type: ignore
42
+
43
+ self._keyring = keyring
44
+ except Exception:
45
+ self._keyring = None
46
+
47
+ def ensure_config_dir(self):
48
+ """Create config directory if it doesn't exist"""
49
+ self.config_dir.mkdir(exist_ok=True)
50
+
51
+ def interactive_setup(self) -> bool:
52
+ """Interactive setup for account authentication and configuration."""
53
+ print("šŸš€ Nocturnal Archive Beta Setup")
54
+ print("=" * 40)
55
+ print()
56
+
57
+ if self.config_file.exists():
58
+ print("āœ… Configuration already exists!")
59
+ response = input("Do you want to reconfigure? (y/N): ").strip().lower()
60
+ if response not in ["y", "yes"]:
61
+ return True
62
+
63
+ print("You'll use your institution-issued account to sign in. No invite codes or manual API keys required.")
64
+ print()
65
+
66
+ email = self._prompt_academic_email()
67
+ if not email:
68
+ return False
69
+
70
+ password = self._prompt_password()
71
+ if not password:
72
+ return False
73
+
74
+ if not self._confirm_beta_terms():
75
+ print("āŒ Terms must be accepted to continue")
76
+ return False
77
+
78
+ try:
79
+ credentials = self._provision_account(email, password)
80
+ except AccountProvisioningError as exc:
81
+ print(f"āŒ Could not verify your account: {exc}")
82
+ return False
83
+
84
+ print("\nšŸ›”ļø Recap of beta limitations:")
85
+ for item in self._beta_limitations():
86
+ print(f" • {item}")
87
+ print()
88
+
89
+ if not self._confirm("I understand the beta limitations above (Y/n): "):
90
+ print("āŒ Please acknowledge the beta limitations to continue")
91
+ return False
92
+
93
+ config: Dict[str, Any] = {
94
+ "NOCTURNAL_ACCOUNT_EMAIL": credentials.email,
95
+ "NOCTURNAL_ACCOUNT_ID": credentials.account_id,
96
+ "NOCTURNAL_AUTH_TOKEN": credentials.auth_token,
97
+ "NOCTURNAL_REFRESH_TOKEN": credentials.refresh_token,
98
+ "NOCTURNAL_TELEMETRY_TOKEN": credentials.telemetry_token,
99
+ "NOCTURNAL_ACCOUNT_ISSUED_AT": credentials.issued_at or "",
100
+ "NOCTURNAL_TELEMETRY": "1",
101
+ "NOCTURNAL_TERMS_ACCEPTED": "1",
102
+ "NOCTURNAL_LIMITATIONS_ACK": "1",
103
+ "NOCTURNAL_CONFIG_VERSION": "2.0.0",
104
+ }
105
+
106
+ optional_updates = self._configure_optional_secrets(existing_config=config)
107
+ config.update(optional_updates)
108
+
109
+ self.save_config(config)
110
+
111
+ print("\nāœ… Configuration saved successfully!")
112
+ print(f"šŸ“ Config location: {self.config_file}")
113
+ print("\nšŸŽ‰ You're ready to use Nocturnal Archive!")
114
+ print("\nQuick start:")
115
+ print("```python")
116
+ print("from nocturnal_archive import EnhancedNocturnalAgent, ChatRequest")
117
+ print("import asyncio")
118
+ print()
119
+ print("async def main():")
120
+ print(" agent = EnhancedNocturnalAgent()")
121
+ print(" await agent.initialize()")
122
+ print(" # Your code here...")
123
+ print(" await agent.close()")
124
+ print()
125
+ print("asyncio.run(main())")
126
+ print("```")
127
+
128
+ return True
129
+
130
+ def _confirm(self, prompt: str) -> bool:
131
+ response = input(prompt).strip().lower()
132
+ return response in ["", "y", "yes"]
133
+
134
+ def _prompt_academic_email(self) -> Optional[str]:
135
+ for attempt in range(5):
136
+ email = input("Academic email address: ").strip()
137
+ if not email:
138
+ print("āŒ Email cannot be empty")
139
+ continue
140
+ if not self._is_academic_email(email):
141
+ print("āŒ Email address must use an academic domain (e.g. .edu, .ac.uk)")
142
+ continue
143
+ return email.lower()
144
+ print("āŒ Could not capture a valid academic email after multiple attempts")
145
+ return None
146
+
147
+ def _prompt_password(self) -> Optional[str]:
148
+ for attempt in range(5):
149
+ password = getpass("Account password: ")
150
+ if not password:
151
+ print("āŒ Password cannot be empty")
152
+ continue
153
+ if len(password) < 8:
154
+ print("āš ļø Passwords should be at least 8 characters long.")
155
+ confirm = input("Continue with this password? (y/N): ").strip().lower()
156
+ if confirm not in ["y", "yes"]:
157
+ continue
158
+ confirm_password = getpass("Confirm password: ")
159
+ if password != confirm_password:
160
+ print("āŒ Passwords do not match")
161
+ continue
162
+ return password
163
+ print("āŒ Could not confirm password after multiple attempts")
164
+ return None
165
+
166
+ def _provision_account(self, email: str, password: str) -> AccountCredentials:
167
+ client = AccountClient()
168
+ return client.provision(email=email, password=password)
169
+
170
+ def _is_academic_email(self, email: str) -> bool:
171
+ if "@" not in email:
172
+ return False
173
+ local, domain = email.split("@", 1)
174
+ if not local or not domain:
175
+ return False
176
+ domain = domain.lower()
177
+ # Accept domains containing edu/ac anywhere except the top-most TLD (to allow edu.mx, ac.uk, etc.)
178
+ parts = domain.split(".")
179
+ if len(parts) < 2:
180
+ return False
181
+ academic_markers = {"edu", "ac"}
182
+ return any(part in academic_markers for part in parts)
183
+
184
+ def _store_secret(self, name: str, value: str) -> bool:
185
+ if not value or not self._keyring:
186
+ return False
187
+ try:
188
+ self._keyring.set_password(KEYRING_SERVICE, name, value)
189
+ return True
190
+ except Exception:
191
+ return False
192
+
193
+ def _persist_secret(self, name: str, value: Optional[str], persist_config: bool = True) -> bool:
194
+ if not value:
195
+ return False
196
+ stored = self._store_secret(name, value)
197
+ if stored and persist_config:
198
+ config = self.load_config()
199
+ config[name] = KEY_PLACEHOLDER
200
+ config["NOCTURNAL_SECRET_BACKEND"] = "keyring"
201
+ self.save_config(config)
202
+ return stored
203
+
204
+ def _configure_optional_secrets(self, existing_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
205
+ config_updates: Dict[str, Any] = {}
206
+ current_config = existing_config or {}
207
+ optional_keys = [key for key, meta in MANAGED_SECRETS.items() if meta.get("optional") and key != "GROQ_API_KEY"]
208
+ if not optional_keys:
209
+ return config_updates
210
+
211
+ print("šŸ”‘ Optional integrations")
212
+ print("Provide any additional API keys you already have. Press Enter to skip a provider.")
213
+ for secret_name in optional_keys:
214
+ meta = MANAGED_SECRETS[secret_name]
215
+ if current_config.get(secret_name):
216
+ continue
217
+ prompt_text = f"{meta.get('prompt', secret_name)} (optional): "
218
+ try:
219
+ value = getpass(prompt_text)
220
+ except Exception:
221
+ value = input(prompt_text)
222
+ if not value:
223
+ continue
224
+ stored = self._persist_secret(secret_name, value, persist_config=False)
225
+ config_updates[secret_name] = KEY_PLACEHOLDER if stored else value
226
+ if stored:
227
+ print(f" • Stored {meta.get('label', secret_name)} key securely in your keychain.")
228
+ else:
229
+ print(f" • Saved {meta.get('label', secret_name)} key to config.env (plaintext). Consider configuring a keychain backend.")
230
+ return config_updates
231
+
232
+ def import_secrets(self, secrets: Dict[str, str], allow_plaintext: bool = True) -> Dict[str, Tuple[bool, str]]:
233
+ results: Dict[str, Tuple[bool, str]] = {}
234
+ config = self.load_config()
235
+ for name, value in secrets.items():
236
+ if name not in MANAGED_SECRETS:
237
+ continue
238
+ if not value:
239
+ results[name] = (False, "empty value")
240
+ continue
241
+ stored = self._persist_secret(name, value, persist_config=False)
242
+ if stored:
243
+ config[name] = KEY_PLACEHOLDER
244
+ results[name] = (True, "stored in keyring")
245
+ elif allow_plaintext:
246
+ config[name] = value
247
+ results[name] = (True, "stored in config file")
248
+ else:
249
+ results[name] = (False, "keyring unavailable and plaintext disabled")
250
+ if results:
251
+ self.save_config(config)
252
+ return results
253
+
254
+ def import_from_env_file(self, path: str, allow_plaintext: bool = True) -> Dict[str, Tuple[bool, str]]:
255
+ env_path = Path(path).expanduser()
256
+ if not env_path.exists():
257
+ raise FileNotFoundError(f"Secrets file not found: {env_path}")
258
+ secrets: Dict[str, str] = {}
259
+ with open(env_path, "r", encoding="utf-8") as handle:
260
+ for line in handle:
261
+ line = line.strip()
262
+ if not line or line.startswith("#"):
263
+ continue
264
+ if "=" not in line:
265
+ continue
266
+ key, value = line.split("=", 1)
267
+ secrets[key.strip()] = value.strip().strip('\"')
268
+ return self.import_secrets(secrets, allow_plaintext=allow_plaintext)
269
+
270
+ def _retrieve_secret(self, name: str) -> Optional[str]:
271
+ if not self._keyring:
272
+ return None
273
+ try:
274
+ return self._keyring.get_password(KEYRING_SERVICE, name)
275
+ except Exception:
276
+ return None
277
+
278
+ def _notify_keyring_success(self):
279
+ print("šŸ” Stored Groq API key securely in your system keychain.")
280
+
281
+ def _warn_keyring_fallback(self):
282
+ if self._keyring is None:
283
+ print("āš ļø Could not access the system keychain. Storing the key in config.env instead.")
284
+ else:
285
+ print("āš ļø Keychain write failed; falling back to plain-text storage in config.env.")
286
+
287
+ def _ensure_query_limit(self, config: Dict[str, str]) -> bool:
288
+ if config.get("NOCTURNAL_QUERY_LIMIT") != str(DEFAULT_QUERY_LIMIT):
289
+ config["NOCTURNAL_QUERY_LIMIT"] = str(DEFAULT_QUERY_LIMIT)
290
+ config.pop("NOCTURNAL_QUERY_LIMIT_SIG", None)
291
+ return True
292
+ if "NOCTURNAL_QUERY_LIMIT_SIG" in config:
293
+ config.pop("NOCTURNAL_QUERY_LIMIT_SIG", None)
294
+ return True
295
+ return False
296
+
297
+ def _beta_limitations(self) -> List[str]:
298
+ return [
299
+ "Daily usage capped at 25 queries per tester",
300
+ "Complex shell / filesystem commands remain sandboxed",
301
+ "Research API may rate-limit during heavy usage",
302
+ "Telemetry is always on and streamed to the control plane",
303
+ "Beta builds auto-update on launch"
304
+ ]
305
+
306
+ def _confirm_beta_terms(self) -> bool:
307
+ print("šŸ“œ Beta Participation Terms")
308
+ print("You are agreeing to: confidential use, providing feedback, and abiding by the usage limits.")
309
+ print("For full details see the Beta Agreement included with your invite.")
310
+ return self._confirm("Do you accept the beta terms? (Y/n): ")
311
+
312
+ def save_config(self, config: Dict[str, Any]):
313
+ """Save configuration to file"""
314
+ with open(self.config_file, 'w') as f:
315
+ f.write("# Nocturnal Archive Configuration\n")
316
+ f.write("# Generated automatically - do not edit manually\n\n")
317
+
318
+ for key, value in config.items():
319
+ if value: # Only save non-empty values
320
+ f.write(f"{key}={value}\n")
321
+
322
+ def load_config(self) -> Dict[str, str]:
323
+ """Load configuration from file"""
324
+ config = {}
325
+ if self.config_file.exists():
326
+ with open(self.config_file, 'r') as f:
327
+ for line in f:
328
+ line = line.strip()
329
+ if line and not line.startswith('#'):
330
+ if '=' in line:
331
+ key, value = line.split('=', 1)
332
+ config[key.strip()] = value.strip()
333
+ return config
334
+
335
+ def setup_environment(self):
336
+ """Set up environment variables from config"""
337
+ config = self.load_config()
338
+ dirty = False
339
+ if self._ensure_query_limit(config):
340
+ dirty = True
341
+ limit_value = int(config.get("NOCTURNAL_QUERY_LIMIT", str(DEFAULT_QUERY_LIMIT)))
342
+ os.environ["NOCTURNAL_QUERY_LIMIT"] = str(limit_value)
343
+ os.environ.pop("NOCTURNAL_QUERY_LIMIT_SIG", None)
344
+ for key, value in config.items():
345
+ if key not in MANAGED_SECRETS:
346
+ if not os.getenv(key) and value:
347
+ os.environ[key] = value
348
+ continue
349
+
350
+ if value == KEY_PLACEHOLDER:
351
+ secret = self._retrieve_secret(key)
352
+ if secret and not os.getenv(key):
353
+ os.environ[key] = secret
354
+ continue
355
+
356
+ if value and value != KEY_PLACEHOLDER and self._store_secret(key, value):
357
+ config[key] = KEY_PLACEHOLDER
358
+ config["NOCTURNAL_SECRET_BACKEND"] = "keyring"
359
+ if not os.getenv(key):
360
+ os.environ[key] = value
361
+ dirty = True
362
+ continue
363
+
364
+ if not os.getenv(key) and value:
365
+ os.environ[key] = value
366
+ if dirty:
367
+ self.save_config(config)
368
+ return len(config) > 0
369
+
370
+ def check_setup(self) -> bool:
371
+ """Check if setup is complete"""
372
+ config = self.load_config()
373
+ return (
374
+ self.config_file.exists()
375
+ and bool(config.get('NOCTURNAL_ACCOUNT_EMAIL'))
376
+ and bool(config.get('NOCTURNAL_AUTH_TOKEN'))
377
+ )
378
+
379
+ def get_setup_status(self) -> Dict[str, Any]:
380
+ """Get detailed setup status"""
381
+ config = self.load_config()
382
+ secret_status: Dict[str, bool] = {}
383
+ for key in MANAGED_SECRETS:
384
+ in_config = config.get(key)
385
+ if in_config == KEY_PLACEHOLDER:
386
+ secret_status[key] = bool(self._retrieve_secret(key)) or bool(os.getenv(key))
387
+ else:
388
+ secret_status[key] = bool(in_config) or bool(os.getenv(key))
389
+ return {
390
+ "configured": self.check_setup(),
391
+ "config_file": str(self.config_file),
392
+ "openalex_configured": secret_status.get('OPENALEX_API_KEY', False),
393
+ "pubmed_configured": secret_status.get('PUBMED_API_KEY', False),
394
+ "account_email": config.get('NOCTURNAL_ACCOUNT_EMAIL'),
395
+ "account_id": config.get('NOCTURNAL_ACCOUNT_ID'),
396
+ "terms_accepted": config.get('NOCTURNAL_TERMS_ACCEPTED') == '1',
397
+ "config_keys": list(config.keys())
398
+ }
399
+
400
+ def auto_setup():
401
+ """Automatic setup function"""
402
+ config = NocturnalConfig()
403
+
404
+ # Try to setup environment from existing config
405
+ if config.setup_environment():
406
+ return True
407
+
408
+ # If no config exists, run interactive setup
409
+ print("šŸ”§ Nocturnal Archive needs initial setup")
410
+ return config.interactive_setup()
411
+
412
+ def get_config():
413
+ """Get configuration instance"""
414
+ return NocturnalConfig()
415
+
416
+ if __name__ == "__main__":
417
+ auto_setup()
@@ -0,0 +1,85 @@
1
+ """Telemetry pipeline for the Nocturnal Archive beta."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import threading
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Any, Dict, Optional
11
+
12
+ _LOCK = threading.Lock()
13
+ _MANAGER: Optional["TelemetryManager"] = None
14
+
15
+
16
+ class TelemetryManager:
17
+ """JSONL telemetry writer with optional control-plane streaming."""
18
+
19
+ def __init__(self, log_path: Path, telemetry_token: Optional[str]):
20
+ self.log_path = log_path
21
+ self.telemetry_token = telemetry_token
22
+ self.log_path.parent.mkdir(parents=True, exist_ok=True)
23
+
24
+ @classmethod
25
+ def _from_environment(cls) -> "TelemetryManager":
26
+ root = Path(os.getenv("NOCTURNAL_HOME", str(Path.home() / ".nocturnal_archive")))
27
+ log_dir = root / "logs"
28
+ log_path = log_dir / "beta-telemetry.jsonl"
29
+ token = os.getenv("NOCTURNAL_TELEMETRY_TOKEN") or None
30
+ return cls(log_path=log_path, telemetry_token=token)
31
+
32
+ @classmethod
33
+ def get(cls) -> "TelemetryManager":
34
+ global _MANAGER
35
+ if _MANAGER is None:
36
+ _MANAGER = cls._from_environment()
37
+ return _MANAGER
38
+
39
+ @classmethod
40
+ def refresh(cls) -> None:
41
+ """Force re-reading environment configuration."""
42
+ global _MANAGER
43
+ _MANAGER = cls._from_environment()
44
+
45
+ def record(self, event_type: str, payload: Dict[str, Any]) -> None:
46
+ record = {
47
+ "event": event_type,
48
+ "timestamp": datetime.now(timezone.utc).isoformat(),
49
+ **payload,
50
+ }
51
+ try:
52
+ with _LOCK:
53
+ with self.log_path.open("a", encoding="utf-8") as fh:
54
+ fh.write(json.dumps(record, ensure_ascii=False) + "\n")
55
+ except Exception:
56
+ # Telemetry must never break the agent; swallow errors silently.
57
+ pass
58
+ self._send_remote(record)
59
+
60
+ def _send_remote(self, record: Dict[str, Any]) -> None:
61
+ endpoint = os.getenv("NOCTURNAL_TELEMETRY_ENDPOINT")
62
+ if not endpoint:
63
+ return
64
+ try: # pragma: no cover - network integration best effort
65
+ import requests # type: ignore
66
+ except Exception:
67
+ return
68
+ headers = {}
69
+ if self.telemetry_token:
70
+ headers["Authorization"] = f"Bearer {self.telemetry_token}"
71
+ try: # pragma: no cover - network integration best effort
72
+ requests.post(
73
+ endpoint.rstrip("/") + "/ingest",
74
+ json=record,
75
+ headers=headers,
76
+ timeout=float(os.getenv("NOCTURNAL_TELEMETRY_TIMEOUT", "5")),
77
+ )
78
+ except Exception:
79
+ # Remote telemetry failures are non-fatal; we rely on local logs for replay.
80
+ pass
81
+
82
+
83
+ def disable_telemetry() -> None:
84
+ """Backward-compatible shim; telemetry is now always-on."""
85
+ TelemetryManager.refresh()
cite_agent/ui.py ADDED
@@ -0,0 +1,175 @@
1
+ """
2
+ Polished Terminal UI - Inspired by Cursor/Claude
3
+ Clean, professional, with subtle visual hierarchy
4
+ """
5
+
6
+ from rich.console import Console
7
+ from rich.panel import Panel
8
+ from rich.prompt import Prompt
9
+ from rich.progress import Progress, SpinnerColumn, TextColumn
10
+ from rich.markdown import Markdown
11
+ from rich import box
12
+ from contextlib import contextmanager
13
+ import time
14
+
15
+ console = Console()
16
+
17
+ class NocturnalUI:
18
+ """Polished terminal UI - clean with subtle hierarchy"""
19
+
20
+ # Color palette (subtle, professional)
21
+ ACCENT = "cyan"
22
+ DIM = "dim"
23
+ SUCCESS = "green"
24
+ ERROR = "red"
25
+
26
+ @staticmethod
27
+ def show_welcome():
28
+ """Welcome screen - quick and polished"""
29
+ console.clear()
30
+
31
+ # Quick loading with style
32
+ with Progress(
33
+ SpinnerColumn(spinner_name="dots", style="cyan"),
34
+ TextColumn("[cyan]Initializing Nocturnal Archive...[/cyan]"),
35
+ console=console,
36
+ transient=True
37
+ ) as progress:
38
+ progress.add_task("", total=None)
39
+ time.sleep(1.0)
40
+
41
+ # Clean header with subtle styling
42
+ console.print()
43
+ console.print("ā”Œ" + "─" * 58 + "┐", style="dim")
44
+ console.print("│ [bold cyan]Nocturnal Archive[/bold cyan]" + " " * 35 + "│", style="dim")
45
+ console.print("│ [dim]Research & Finance Intelligence Terminal[/dim]" + " " * 10 + "│", style="dim")
46
+ console.print("ā””" + "─" * 58 + "ā”˜", style="dim")
47
+ console.print()
48
+
49
+ @staticmethod
50
+ def prompt_login() -> tuple[str, str]:
51
+ """Login prompt with subtle panel"""
52
+ console.print()
53
+ console.print("[dim]─── Authentication ───[/dim]")
54
+ console.print()
55
+
56
+ email = Prompt.ask("[cyan]Email[/cyan]", console=console)
57
+ password = Prompt.ask("[cyan]Password[/cyan]", password=True, console=console)
58
+
59
+ return email, password
60
+
61
+ @staticmethod
62
+ def prompt_register() -> tuple[str, str, str]:
63
+ """Registration prompt with subtle panel"""
64
+ console.print()
65
+ console.print("[dim]─── Create Account ───[/dim]")
66
+ console.print()
67
+
68
+ email = Prompt.ask("[cyan]Email[/cyan]", console=console)
69
+ password = Prompt.ask("[cyan]Password[/cyan]", password=True, console=console)
70
+ license_key = Prompt.ask("[cyan]License Key[/cyan]", console=console)
71
+
72
+ return email, password, license_key
73
+
74
+ @staticmethod
75
+ def show_status(email: str, queries_today: int, daily_limit: int):
76
+ """Status bar - subtle and informative"""
77
+ remaining = daily_limit - queries_today
78
+ status_color = "green" if remaining > 10 else "yellow" if remaining > 5 else "red"
79
+
80
+ console.print()
81
+ console.print(
82
+ f"[dim]{email}[/dim] │ [{status_color}]{queries_today}/{daily_limit}[/{status_color}] [dim]queries today[/dim]"
83
+ )
84
+ console.print("[dim]" + "─" * 60 + "[/dim]")
85
+ console.print()
86
+
87
+ @staticmethod
88
+ def prompt_query() -> str:
89
+ """Get user query with styled prompt"""
90
+ return Prompt.ask("[bold cyan]āÆ[/bold cyan]", console=console)
91
+
92
+ @staticmethod
93
+ @contextmanager
94
+ def show_thinking():
95
+ """Thinking spinner - polished"""
96
+ with Progress(
97
+ SpinnerColumn(spinner_name="dots", style="cyan"),
98
+ TextColumn("[dim]Processing your query...[/dim]"),
99
+ console=console,
100
+ transient=True
101
+ ) as progress:
102
+ progress.add_task("", total=None)
103
+ yield
104
+
105
+ @staticmethod
106
+ def show_response(response: str, metadata: dict = None):
107
+ """Show agent response with subtle styling"""
108
+ console.print()
109
+
110
+ # Response content
111
+ console.print(response)
112
+
113
+ # Optional metadata footer (minimal)
114
+ if metadata:
115
+ console.print()
116
+ meta_parts = []
117
+ if metadata.get("tools_used"):
118
+ meta_parts.append(f"[dim]Tools: {', '.join(metadata['tools_used'])}[/dim]")
119
+ if metadata.get("sources"):
120
+ meta_parts.append(f"[dim]Sources: {metadata['sources']}[/dim]")
121
+ if meta_parts:
122
+ console.print(" • ".join(meta_parts))
123
+
124
+ console.print()
125
+
126
+ @staticmethod
127
+ def show_error(message: str):
128
+ """Error message - clear but not aggressive"""
129
+ console.print()
130
+ console.print(f"[red]āœ—[/red] {message}")
131
+ console.print()
132
+
133
+ @staticmethod
134
+ def show_success(message: str):
135
+ """Success message - subtle confirmation"""
136
+ console.print()
137
+ console.print(f"[green]āœ“[/green] {message}")
138
+ console.print()
139
+
140
+ @staticmethod
141
+ def show_info(message: str):
142
+ """Info message"""
143
+ console.print()
144
+ console.print(f"[dim]ℹ[/dim] {message}")
145
+ console.print()
146
+
147
+ @staticmethod
148
+ def show_help():
149
+ """Show help message - clean list"""
150
+ console.print()
151
+ console.print("[bold cyan]Commands[/bold cyan]")
152
+ console.print("[dim]─────────[/dim]")
153
+ console.print(" [cyan]help[/cyan] Show this help message")
154
+ console.print(" [cyan]clear[/cyan] Clear the screen")
155
+ console.print(" [cyan]logout[/cyan] Log out of your account")
156
+ console.print(" [cyan]exit[/cyan] Exit the application")
157
+ console.print()
158
+
159
+ @staticmethod
160
+ def show_tips(tips: list = None):
161
+ """Show helpful tips - clean list"""
162
+ if tips is None:
163
+ tips = [
164
+ "Try: 'Find papers about transformers'",
165
+ "Ask: 'What's Apple's latest revenue?'",
166
+ "Query: 'Analyze sentiment in tech stocks'",
167
+ "Search: 'Compare Google vs Microsoft earnings'",
168
+ ]
169
+
170
+ console.print()
171
+ console.print("[bold cyan]Tips[/bold cyan]")
172
+ console.print("[dim]────[/dim]")
173
+ for tip in tips:
174
+ console.print(f" [dim]•[/dim] {tip}")
175
+ console.print()