sigma-terminal 3.3.2__py3-none-any.whl → 3.4.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.
sigma/app.py CHANGED
@@ -1,4 +1,4 @@
1
- """Sigma v3.3.1 - Finance Research Agent."""
1
+ """Sigma v3.4.0 - Finance Research Agent."""
2
2
 
3
3
  import asyncio
4
4
  import os
@@ -17,13 +17,13 @@ from textual.containers import Container, Horizontal, Vertical, ScrollableContai
17
17
  from textual.widgets import Footer, Input, RichLog, Static
18
18
  from textual.suggester import Suggester
19
19
 
20
- from .config import LLMProvider, get_settings, save_api_key, AVAILABLE_MODELS
20
+ from .config import LLMProvider, get_settings, save_api_key, AVAILABLE_MODELS, SigmaError, ErrorCode
21
21
  from .llm import get_llm
22
22
  from .tools import TOOLS, execute_tool
23
23
  from .backtest import run_backtest, get_available_strategies, BACKTEST_TOOL
24
24
 
25
25
 
26
- __version__ = "3.3.1"
26
+ __version__ = "3.4.0"
27
27
  SIGMA = "σ"
28
28
 
29
29
  # Common stock tickers for recognition
@@ -38,52 +38,107 @@ COMMON_TICKERS = {
38
38
  "SPY", "QQQ", "IWM", "DIA", "VTI", "VOO", "VXX", "ARKK", "XLF", "XLK", "XLE",
39
39
  }
40
40
 
41
- # Small sigma animation frames (minimal footprint)
42
- SMALL_SIGMA_FRAMES = [
43
- "[bold blue]σ[/bold blue]",
44
- "[bold cyan]σ[/bold cyan]",
45
- "[bold white]σ[/bold white]",
46
- "[bold #60a5fa]σ[/bold #60a5fa]",
41
+ # Sigma animation frames - color cycling for thinking/processing state
42
+ SIGMA_FRAMES = [
43
+ "[bold #3b82f6]s[/bold #3b82f6]",
44
+ "[bold #60a5fa]si[/bold #60a5fa]",
45
+ "[bold #93c5fd]sig[/bold #93c5fd]",
46
+ "[bold #bfdbfe]sigm[/bold #bfdbfe]",
47
+ "[bold white]sigma[/bold white]",
48
+ "[bold #bfdbfe]sigm[/bold #bfdbfe]",
49
+ "[bold #93c5fd]sig[/bold #93c5fd]",
50
+ "[bold #60a5fa]si[/bold #60a5fa]",
51
+ "[bold #3b82f6]s[/bold #3b82f6]",
52
+ "[bold cyan].[/bold cyan]",
47
53
  ]
48
54
 
49
- # Tool call animation frames
50
- TOOL_CALL_FRAMES = [
51
- "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"
55
+ # Sigma pulse animation (color breathing)
56
+ SIGMA_PULSE_FRAMES = [
57
+ "[bold #1e40af]o[/bold #1e40af]",
58
+ "[bold #2563eb]o[/bold #2563eb]",
59
+ "[bold #3b82f6]o[/bold #3b82f6]",
60
+ "[bold #60a5fa]o[/bold #60a5fa]",
61
+ "[bold #93c5fd]o[/bold #93c5fd]",
62
+ "[bold #bfdbfe]o[/bold #bfdbfe]",
63
+ "[bold #93c5fd]o[/bold #93c5fd]",
64
+ "[bold #60a5fa]o[/bold #60a5fa]",
65
+ "[bold #3b82f6]o[/bold #3b82f6]",
66
+ "[bold #2563eb]o[/bold #2563eb]",
67
+ ]
68
+
69
+ # Tool call spinner frames - ASCII based
70
+ TOOL_SPINNER_FRAMES = [
71
+ "|", "/", "-", "\\"
52
72
  ]
53
73
 
54
74
  # Welcome banner - clean design
55
75
  WELCOME_BANNER = """
56
- [bold blue]███████╗██╗ ██████╗ ███╗ ███╗ █████╗ [/bold blue]
57
- [bold blue]██╔════╝██║██╔════╝ ████╗ ████║██╔══██╗[/bold blue]
58
- [bold blue]███████╗██║██║ ███╗██╔████╔██║███████║[/bold blue]
59
- [bold blue]╚════██║██║██║ ██║██║╚██╔╝██║██╔══██║[/bold blue]
60
- [bold blue]███████║██║╚██████╔╝██║ ╚═╝ ██║██║ ██║[/bold blue]
61
- [bold blue]╚══════╝╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝[/bold blue]
62
-
63
- [bold cyan]Finance Research Agent[/bold cyan] [dim]v3.3.1 | Native macOS[/dim]
76
+ [bold #3b82f6]███████╗██╗ ██████╗ ███╗ ███╗ █████╗ [/bold #3b82f6]
77
+ [bold #60a5fa]██╔════╝██║██╔════╝ ████╗ ████║██╔══██╗[/bold #60a5fa]
78
+ [bold #93c5fd]███████╗██║██║ ███╗██╔████╔██║███████║[/bold #93c5fd]
79
+ [bold #60a5fa]╚════██║██║██║ ██║██║╚██╔╝██║██╔══██║[/bold #60a5fa]
80
+ [bold #3b82f6]███████║██║╚██████╔╝██║ ╚═╝ ██║██║ ██║[/bold #3b82f6]
81
+ [bold #1d4ed8]╚══════╝╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝[/bold #1d4ed8]
82
+
83
+ [bold cyan]Finance Research Agent[/bold cyan] [dim]v3.4.0[/dim]
64
84
  """
65
85
 
66
- SYSTEM_PROMPT = """You are Sigma, a Finance Research Agent. You provide comprehensive market analysis, trading strategies, and investment insights.
86
+ SYSTEM_PROMPT = """You are Sigma (σ), an elite AI-powered Finance Research Agent. You combine the analytical rigor of a quantitative analyst, the market intuition of a seasoned portfolio manager, and the communication clarity of a top financial advisor.
67
87
 
68
88
  CORE CAPABILITIES:
69
- - Real-time market data analysis (quotes, charts, technicals)
70
- - Fundamental analysis (financials, ratios, earnings)
71
- - Technical analysis (RSI, MACD, Bollinger Bands, moving averages)
89
+ - Real-time market data analysis (quotes, charts, technicals) via yfinance and Polygon.io
90
+ - Fundamental analysis (financials, ratios, earnings, valuations)
91
+ - Technical analysis (RSI, MACD, Bollinger Bands, moving averages, support/resistance)
72
92
  - Backtesting strategies (SMA crossover, RSI, MACD, Bollinger, momentum, breakout)
73
93
  - Portfolio analysis and optimization
74
- - Sector and market overview
75
- - Insider and fund activity tracking
76
-
77
- RESPONSE STYLE:
78
- - Be concise and data-driven
79
- - Lead with key insights, then supporting data
80
- - Use tables for comparative data when appropriate
81
- - Always cite specific numbers and metrics
82
- - Provide actionable recommendations when asked
83
- - Format currency and percentages properly
84
- - Use STRONG BUY, BUY, HOLD, SELL, STRONG SELL ratings when appropriate
85
-
86
- When users ask about stocks, always gather current data using your tools before responding."""
94
+ - Sector and market overview with economic indicators
95
+ - Insider trading and institutional activity tracking
96
+ - Financial news search and SEC filings analysis
97
+
98
+ RESPONSE PHILOSOPHY:
99
+ 1. BE PROACTIVE: Anticipate follow-up questions and address them
100
+ 2. BE THOROUGH: When analyzing, cover fundamentals + technicals + sentiment
101
+ 3. BE ACTIONABLE: Always end with clear recommendations or next steps
102
+ 4. BE DATA-DRIVEN: Cite specific numbers, dates, and percentages
103
+ 5. BE HONEST: Acknowledge uncertainty and risks
104
+
105
+ RESPONSE FORMAT:
106
+ - Start with a 1-2 sentence executive summary (the key takeaway)
107
+ - Use structured sections with clear headers (## format)
108
+ - Present comparative data in markdown tables
109
+ - Highlight key metrics: **bold** for critical numbers
110
+ - Use bullet points for easy scanning
111
+ - End with "**Bottom Line:**" or "**Recommendation:**"
112
+
113
+ RATING SYSTEM (when asked for recommendations):
114
+ - STRONG BUY [A+]: Exceptional opportunity, high conviction
115
+ - BUY [A]: Favorable outlook, solid fundamentals
116
+ - HOLD [B]: Fair value, wait for better entry
117
+ - SELL [C]: Concerns outweigh positives
118
+ - STRONG SELL [D]: Significant risks, avoid
119
+
120
+ DATA GATHERING RULES:
121
+ - ALWAYS use tools to fetch current data before answering stock questions
122
+ - Use multiple tools for comprehensive analysis (quote + technicals + fundamentals)
123
+ - Cross-reference data points for accuracy
124
+ - If a tool fails, try alternative approaches or acknowledge the limitation
125
+
126
+ PROACTIVE INSIGHTS:
127
+ When analyzing any stock, proactively mention:
128
+ - Recent earnings surprises or upcoming earnings dates
129
+ - Major analyst rating changes
130
+ - Unusual volume or price movements
131
+ - Relevant sector trends
132
+ - Key support/resistance levels
133
+ - Comparison to peers when relevant
134
+
135
+ FORBIDDEN:
136
+ - Never fabricate data or prices
137
+ - Never provide data without fetching it first
138
+ - Never give buy/sell advice without disclosure of risks
139
+ - Never claim certainty about future performance
140
+
141
+ Remember: Users trust you for professional-grade financial research. Exceed their expectations with every response."""
87
142
 
88
143
  # Enhanced autocomplete suggestions with more variety
89
144
  SUGGESTIONS = [
@@ -142,6 +197,28 @@ SUGGESTIONS = [
142
197
  "/backtest",
143
198
  ]
144
199
 
200
+ # Extended action verbs for smart completion
201
+ ACTION_VERBS = [
202
+ "analyze", "compare", "show", "get", "what is", "tell me about",
203
+ "technical analysis", "fundamentals", "price", "quote", "chart",
204
+ "backtest", "insider trading", "institutional", "analyst", "earnings",
205
+ "financials", "valuation", "news", "sector", "market", "portfolio"
206
+ ]
207
+
208
+ # Ticker categories for smart suggestions
209
+ TICKER_CATEGORIES = {
210
+ "tech": ["AAPL", "MSFT", "GOOGL", "AMZN", "META", "NVDA", "AMD", "INTC", "CRM", "ADBE"],
211
+ "finance": ["JPM", "BAC", "GS", "MS", "V", "MA", "BRK.B", "C", "WFC", "AXP"],
212
+ "healthcare": ["JNJ", "UNH", "PFE", "MRK", "ABBV", "LLY", "TMO", "ABT", "BMY", "GILD"],
213
+ "consumer": ["AMZN", "TSLA", "HD", "NKE", "MCD", "SBUX", "COST", "WMT", "TGT", "LOW"],
214
+ "energy": ["XOM", "CVX", "COP", "SLB", "EOG", "MPC", "PSX", "VLO", "OXY", "HAL"],
215
+ "etf": ["SPY", "QQQ", "IWM", "DIA", "VTI", "VOO", "VXX", "ARKK", "XLF", "XLK"],
216
+ "crypto": ["COIN", "MSTR", "RIOT", "MARA", "HUT"],
217
+ "ev": ["TSLA", "RIVN", "LCID", "NIO", "XPEV", "LI"],
218
+ "ai": ["NVDA", "AMD", "MSFT", "GOOGL", "META", "PLTR", "AI", "PATH", "SNOW"],
219
+ "semiconductor": ["NVDA", "AMD", "INTC", "AVGO", "QCOM", "TXN", "MU", "AMAT", "LRCX", "ASML"],
220
+ }
221
+
145
222
 
146
223
  def extract_tickers(text: str) -> List[str]:
147
224
  """Extract stock tickers from text."""
@@ -167,34 +244,174 @@ def extract_tickers(text: str) -> List[str]:
167
244
 
168
245
 
169
246
  class SigmaSuggester(Suggester):
170
- """Enhanced autocomplete suggester with ticker recognition."""
247
+ """Smart autocomplete with fuzzy matching and context awareness."""
171
248
 
172
249
  def __init__(self):
173
- super().__init__(use_cache=True, case_sensitive=False)
250
+ super().__init__(use_cache=False, case_sensitive=False)
251
+ self._build_suggestion_index()
252
+
253
+ def _build_suggestion_index(self):
254
+ """Build an index of all possible suggestions for fast lookup."""
255
+ self.all_suggestions = []
256
+
257
+ # Add static suggestions
258
+ self.all_suggestions.extend(SUGGESTIONS)
259
+
260
+ # Generate dynamic suggestions for all tickers
261
+ for ticker in COMMON_TICKERS:
262
+ self.all_suggestions.extend([
263
+ f"analyze {ticker}",
264
+ f"technical analysis of {ticker}",
265
+ f"fundamentals of {ticker}",
266
+ f"price of {ticker}",
267
+ f"quote {ticker}",
268
+ f"insider trading {ticker}",
269
+ f"earnings {ticker}",
270
+ f"news {ticker}",
271
+ ])
272
+
273
+ # Add category-based suggestions
274
+ for category, tickers in TICKER_CATEGORIES.items():
275
+ self.all_suggestions.append(f"best {category} stocks")
276
+ self.all_suggestions.append(f"{category} sector performance")
277
+ if len(tickers) >= 3:
278
+ self.all_suggestions.append(f"compare {' '.join(tickers[:3])}")
279
+
280
+ def _fuzzy_match(self, query: str, target: str) -> float:
281
+ """Calculate fuzzy match score (0-1). Higher is better."""
282
+ query = query.lower()
283
+ target = target.lower()
284
+
285
+ # Exact prefix match is best
286
+ if target.startswith(query):
287
+ return 1.0 + len(query) / len(target)
288
+
289
+ # Check if all query chars appear in order
290
+ q_idx = 0
291
+ matches = 0
292
+ consecutive = 0
293
+ max_consecutive = 0
294
+ last_match = -1
295
+
296
+ for t_idx, char in enumerate(target):
297
+ if q_idx < len(query) and char == query[q_idx]:
298
+ matches += 1
299
+ if last_match == t_idx - 1:
300
+ consecutive += 1
301
+ max_consecutive = max(max_consecutive, consecutive)
302
+ else:
303
+ consecutive = 1
304
+ last_match = t_idx
305
+ q_idx += 1
306
+
307
+ if q_idx < len(query):
308
+ # Not all chars matched
309
+ # Try substring match
310
+ if query in target:
311
+ return 0.5
312
+ return 0
313
+
314
+ # Score based on match quality
315
+ score = (matches / len(query)) * 0.5 + (max_consecutive / len(query)) * 0.3
316
+ # Bonus for shorter targets (more relevant)
317
+ score += (1 - len(target) / 100) * 0.2
318
+
319
+ return score
320
+
321
+ def _get_context_suggestions(self, value: str) -> List[str]:
322
+ """Get context-aware suggestions based on partial input."""
323
+ suggestions = []
324
+ value_lower = value.lower().strip()
325
+
326
+ # Command shortcuts
327
+ if value.startswith("/"):
328
+ cmd = value_lower[1:]
329
+ commands = ["/help", "/clear", "/keys", "/models", "/status", "/backtest",
330
+ "/provider", "/model", "/setkey", "/tickers"]
331
+ for c in commands:
332
+ if c[1:].startswith(cmd):
333
+ suggestions.append(c)
334
+ return suggestions
335
+
336
+ # Detect if user is typing a ticker
337
+ words = value.split()
338
+ last_word = words[-1] if words else ""
339
+
340
+ if last_word.startswith("$") or (last_word.isupper() and len(last_word) >= 1):
341
+ ticker_prefix = last_word.lstrip("$").upper()
342
+ matching_tickers = [t for t in COMMON_TICKERS if t.startswith(ticker_prefix)]
343
+
344
+ # If we have an action verb, complete with ticker
345
+ action_words = ["analyze", "compare", "technical", "price", "quote",
346
+ "fundamentals", "insider", "earnings", "news"]
347
+ prefix = " ".join(words[:-1]).lower() if len(words) > 1 else ""
348
+
349
+ for ticker in matching_tickers[:5]:
350
+ if prefix:
351
+ suggestions.append(f"{prefix} {ticker}")
352
+ else:
353
+ suggestions.append(f"analyze {ticker}")
354
+ return suggestions
355
+
356
+ # Natural language patterns
357
+ patterns = [
358
+ ("ana", "analyze"),
359
+ ("tech", "technical analysis of"),
360
+ ("fun", "fundamentals of"),
361
+ ("comp", "compare"),
362
+ ("back", "backtest"),
363
+ ("mark", "market overview"),
364
+ ("sect", "sector performance"),
365
+ ("pri", "price of"),
366
+ ("quo", "quote"),
367
+ ("ins", "insider trading"),
368
+ ("ear", "earnings"),
369
+ ("wha", "what should I know about"),
370
+ ("sho", "should I buy"),
371
+ ("is ", "is NVDA overvalued"),
372
+ ("how", "how is"),
373
+ ("bes", "best tech stocks"),
374
+ ]
375
+
376
+ for prefix, expansion in patterns:
377
+ if value_lower.startswith(prefix):
378
+ if expansion.endswith("of") or expansion.endswith("about"):
379
+ # Add popular tickers
380
+ for ticker in ["AAPL", "NVDA", "MSFT", "TSLA", "GOOGL"]:
381
+ suggestions.append(f"{expansion} {ticker}")
382
+ else:
383
+ suggestions.append(expansion)
384
+
385
+ return suggestions
174
386
 
175
387
  async def get_suggestion(self, value: str) -> Optional[str]:
176
- """Get autocomplete suggestion."""
177
- if not value or len(value) < 2:
388
+ """Get the best autocomplete suggestion."""
389
+ if not value or len(value) < 1:
178
390
  return None
179
391
 
180
- value_lower = value.lower()
392
+ value_lower = value.lower().strip()
393
+
394
+ # Get context-aware suggestions first
395
+ context_suggestions = self._get_context_suggestions(value)
396
+ if context_suggestions:
397
+ return context_suggestions[0]
181
398
 
182
- # Check for ticker pattern (all caps or starts with $)
183
- if value.startswith("$") or value.isupper():
184
- ticker = value.lstrip("$").upper()
185
- for common in COMMON_TICKERS:
186
- if common.startswith(ticker) and common != ticker:
187
- return f"analyze {common}"
399
+ # Fuzzy match against all suggestions
400
+ scored = []
401
+ for suggestion in self.all_suggestions:
402
+ score = self._fuzzy_match(value_lower, suggestion)
403
+ if score > 0:
404
+ scored.append((score, suggestion))
188
405
 
189
- # Standard suggestions
190
- for suggestion in SUGGESTIONS:
191
- if suggestion.lower().startswith(value_lower):
192
- return suggestion
406
+ # Sort by score and return best
407
+ scored.sort(key=lambda x: x[0], reverse=True)
193
408
 
194
- # Try partial match in middle of suggestion
195
- for suggestion in SUGGESTIONS:
196
- if value_lower in suggestion.lower():
197
- return suggestion
409
+ if scored:
410
+ return scored[0][1]
411
+
412
+ # Last resort: if looks like a ticker, suggest analyze
413
+ if value.isupper() and len(value) <= 5 and value.isalpha():
414
+ return f"analyze {value}"
198
415
 
199
416
  return None
200
417
 
@@ -230,8 +447,8 @@ Screen {
230
447
 
231
448
  #status-bar {
232
449
  height: 3;
233
- background: #0d1117;
234
- border-top: solid #1a1a2e;
450
+ background: #0f1419;
451
+ border-top: solid #1e293b;
235
452
  padding: 0 2;
236
453
  dock: bottom;
237
454
  }
@@ -256,9 +473,9 @@ Screen {
256
473
  #tool-calls-display {
257
474
  width: 100%;
258
475
  height: auto;
259
- max-height: 6;
260
- background: #0d1117;
261
- border: solid #1a1a2e;
476
+ max-height: 8;
477
+ background: #0f1419;
478
+ border: round #1e293b;
262
479
  margin: 0 2;
263
480
  padding: 0 1;
264
481
  display: none;
@@ -271,7 +488,7 @@ Screen {
271
488
  #input-area {
272
489
  height: 5;
273
490
  padding: 1 2;
274
- background: #0d1117;
491
+ background: #0f1419;
275
492
  }
276
493
 
277
494
  #input-row {
@@ -283,22 +500,24 @@ Screen {
283
500
  width: 4;
284
501
  height: 3;
285
502
  content-align: center middle;
503
+ background: transparent;
286
504
  }
287
505
 
288
506
  #prompt-input {
289
507
  width: 1fr;
290
- background: #1a1a2e;
291
- border: solid #3b82f6;
292
- color: #ffffff;
508
+ background: #1e293b;
509
+ border: tall #3b82f6;
510
+ color: #f8fafc;
293
511
  padding: 0 1;
294
512
  }
295
513
 
296
514
  #prompt-input:focus {
297
- border: solid #60a5fa;
515
+ border: tall #60a5fa;
516
+ background: #1e3a5f;
298
517
  }
299
518
 
300
519
  #prompt-input.-autocomplete {
301
- border: solid #22c55e;
520
+ border: tall #22c55e;
302
521
  }
303
522
 
304
523
  #ticker-highlight {
@@ -306,6 +525,7 @@ Screen {
306
525
  height: 1;
307
526
  padding: 0 1;
308
527
  background: transparent;
528
+ color: #22d3ee;
309
529
  }
310
530
 
311
531
  Footer {
@@ -380,7 +600,7 @@ class ToolCallDisplay(Static):
380
600
 
381
601
  def _animate(self):
382
602
  """Animate the spinner."""
383
- self.frame = (self.frame + 1) % len(TOOL_CALL_FRAMES)
603
+ self.frame = (self.frame + 1) % len(TOOL_SPINNER_FRAMES)
384
604
  for tc in self.tool_calls:
385
605
  if tc["status"] == "running":
386
606
  tc["frame"] = self.frame
@@ -394,38 +614,48 @@ class ToolCallDisplay(Static):
394
614
  lines = []
395
615
  for tc in self.tool_calls:
396
616
  if tc["status"] == "running":
397
- spinner = TOOL_CALL_FRAMES[tc["frame"]]
617
+ spinner = TOOL_SPINNER_FRAMES[tc["frame"] % len(TOOL_SPINNER_FRAMES)]
398
618
  lines.append(f" [cyan]{spinner}[/cyan] [bold]{tc['name']}[/bold] [dim]executing...[/dim]")
399
619
  else:
400
- lines.append(f" [green][/green] [bold]{tc['name']}[/bold] [green]complete[/green]")
620
+ lines.append(f" [green][ok][/green] [bold]{tc['name']}[/bold] [green]done[/green]")
401
621
 
402
622
  self.update(Text.from_markup("\n".join(lines)))
403
623
 
404
624
 
405
625
  class SigmaIndicator(Static):
406
- """Pulsing sigma indicator with minimal footprint."""
626
+ """Animated sigma indicator - typewriter style when active."""
407
627
 
408
628
  def __init__(self, *args, **kwargs):
409
629
  super().__init__(*args, **kwargs)
410
630
  self.active = False
631
+ self.mode = "idle" # idle, thinking, tool
411
632
  self.frame = 0
412
633
  self.timer = None
413
634
 
414
635
  def on_mount(self):
415
636
  self.update(Text.from_markup(f"[bold blue]{SIGMA}[/bold blue]"))
416
637
 
417
- def set_active(self, active: bool):
638
+ def set_active(self, active: bool, mode: str = "thinking"):
418
639
  self.active = active
640
+ self.mode = mode if active else "idle"
419
641
  if active and not self.timer:
420
- self.timer = self.set_interval(0.15, self._pulse)
642
+ self.frame = 0
643
+ interval = 0.08 if mode == "thinking" else 0.12
644
+ self.timer = self.set_interval(interval, self._animate)
421
645
  elif not active and self.timer:
422
646
  self.timer.stop()
423
647
  self.timer = None
424
648
  self.update(Text.from_markup(f"[bold blue]{SIGMA}[/bold blue]"))
425
649
 
426
- def _pulse(self):
427
- self.frame = (self.frame + 1) % len(SMALL_SIGMA_FRAMES)
428
- self.update(Text.from_markup(SMALL_SIGMA_FRAMES[self.frame]))
650
+ def _animate(self):
651
+ if self.mode == "thinking":
652
+ # Typewriter effect: s -> si -> sig -> sigm -> sigma -> sigm -> ...
653
+ self.frame = (self.frame + 1) % len(SIGMA_FRAMES)
654
+ self.update(Text.from_markup(SIGMA_FRAMES[self.frame]))
655
+ else:
656
+ # Pulse effect for tool calls
657
+ self.frame = (self.frame + 1) % len(SIGMA_PULSE_FRAMES)
658
+ self.update(Text.from_markup(SIGMA_PULSE_FRAMES[self.frame]))
429
659
 
430
660
 
431
661
  class TickerHighlight(Static):
@@ -538,20 +768,28 @@ class SigmaApp(App):
538
768
  chat.write_welcome()
539
769
 
540
770
  provider = getattr(self.settings.default_provider, 'value', str(self.settings.default_provider))
541
- chat.write_system(f"{SIGMA} Provider: {provider} | Model: {self.settings.default_model}")
542
- chat.write_system(f"{SIGMA} Type /help for commands • Ctrl+H for quick help Tab to autocomplete")
771
+ chat.write_system(f"{SIGMA} Provider: [bold]{provider}[/bold] | Model: [bold]{self.settings.default_model}[/bold]")
772
+ chat.write_system(f"{SIGMA} Type [cyan]/help[/cyan] for commands • [cyan]/keys[/cyan] to set up API keys")
543
773
  chat.write_system("")
544
774
 
545
775
  self._init_llm()
546
776
  self.query_one("#prompt-input", Input).focus()
547
777
 
548
778
  def _init_llm(self):
779
+ """Initialize the LLM client with proper error handling."""
549
780
  try:
550
781
  self.llm = get_llm(self.settings.default_provider, self.settings.default_model)
782
+ except SigmaError as e:
783
+ chat = self.query_one("#chat-log", ChatLog)
784
+ chat.write_error(f"[E{e.code}] {e.message}")
785
+ if e.details.get("hint"):
786
+ chat.write_system(f"[dim]Hint: {e.details['hint']}[/dim]")
787
+ self.llm = None
551
788
  except Exception as e:
552
789
  chat = self.query_one("#chat-log", ChatLog)
553
- chat.write_error(f"LLM init failed: {e}")
554
- chat.write_system("Use /keys to configure API keys")
790
+ chat.write_error(f"[E{ErrorCode.PROVIDER_ERROR}] Failed to initialize: {str(e)[:100]}")
791
+ chat.write_system("[dim]Use /keys to configure API keys[/dim]")
792
+ self.llm = None
555
793
 
556
794
  @on(Input.Changed)
557
795
  def on_input_change(self, event: Input.Changed):
@@ -687,37 +925,87 @@ class SigmaApp(App):
687
925
  ))
688
926
 
689
927
  def _show_keys(self, chat: ChatLog):
690
- chat.write_system(f"""
691
- [bold]{SIGMA} API Keys[/bold]
692
- Set key: /setkey <provider> <key>
928
+ """Show comprehensive API key management interface."""
929
+ keys_help = f"""
930
+ [bold cyan]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold cyan]
931
+ [bold] {SIGMA} API KEY MANAGER [/bold]
932
+ [bold cyan]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold cyan]
933
+
934
+ [bold yellow]QUICK SETUP[/bold yellow]
935
+ Set a key: [cyan]/setkey <provider> <your-api-key>[/cyan]
936
+
937
+ [bold yellow]LLM PROVIDERS[/bold yellow]
938
+ [bold]google[/bold] → https://aistudio.google.com/apikey
939
+ [bold]openai[/bold] → https://platform.openai.com/api-keys
940
+ [bold]anthropic[/bold] → https://console.anthropic.com/settings/keys
941
+ [bold]groq[/bold] → https://console.groq.com/keys [dim](free!)[/dim]
942
+ [bold]xai[/bold] → https://console.x.ai
943
+
944
+ [bold yellow]DATA PROVIDERS[/bold yellow] [dim](optional - enhances data quality)[/dim]
945
+ [bold]polygon[/bold] → https://polygon.io/dashboard/api-keys
946
+
947
+ [bold yellow]EXAMPLES[/bold yellow]
948
+ /setkey google AIzaSyB...
949
+ /setkey openai sk-proj-...
950
+ /setkey polygon abc123...
951
+ /provider groq [dim]← switch to groq[/dim]
693
952
 
694
- Providers: google, openai, anthropic, groq, xai
695
- Example: /setkey google AIzaSy...
696
- """)
953
+ [bold yellow]TIPS[/bold yellow]
954
+ [green]Groq[/green] is free and fast - great for starting out
955
+ • [green]Ollama[/green] runs locally - no key needed (/provider ollama)
956
+ • Keys are stored in [dim]~/.sigma/config.env[/dim]
957
+ """
958
+ chat.write(Panel(
959
+ Text.from_markup(keys_help),
960
+ title=f"[bold cyan]{SIGMA} API Keys[/bold cyan]",
961
+ border_style="cyan",
962
+ padding=(0, 1),
963
+ ))
697
964
  self._show_status(chat)
698
965
 
699
966
  def _show_status(self, chat: ChatLog):
700
- table = Table(show_header=False, box=None, padding=(0, 2))
701
- table.add_column("", style="bold")
702
- table.add_column("")
967
+ """Show current configuration status."""
968
+ table = Table(show_header=True, box=None, padding=(0, 2), title=f"{SIGMA} Current Status")
969
+ table.add_column("Setting", style="bold")
970
+ table.add_column("Value")
971
+ table.add_column("Status")
703
972
 
704
973
  provider = getattr(self.settings.default_provider, 'value', str(self.settings.default_provider))
705
- table.add_row("Provider", provider)
706
- table.add_row("Model", self.settings.default_model)
707
- table.add_row("", "")
708
-
709
- keys = [
710
- ("Google", self.settings.google_api_key),
711
- ("OpenAI", self.settings.openai_api_key),
712
- ("Anthropic", self.settings.anthropic_api_key),
713
- ("Groq", self.settings.groq_api_key),
714
- ("xAI", self.settings.xai_api_key),
974
+ table.add_row("Provider", provider, "[green][*][/green] Active")
975
+ table.add_row("Model", self.settings.default_model, "")
976
+ table.add_row("", "", "")
977
+
978
+ # LLM Keys
979
+ llm_keys = [
980
+ ("Google", self.settings.google_api_key, "google"),
981
+ ("OpenAI", self.settings.openai_api_key, "openai"),
982
+ ("Anthropic", self.settings.anthropic_api_key, "anthropic"),
983
+ ("Groq", self.settings.groq_api_key, "groq"),
984
+ ("xAI", self.settings.xai_api_key, "xai"),
715
985
  ]
716
- for name, key in keys:
717
- status = "[green]OK[/green]" if key else "[dim]--[/dim]"
718
- table.add_row(f" {name}", Text.from_markup(status))
986
+ for name, key, prov in llm_keys:
987
+ if key:
988
+ masked = key[:8] + "..." + key[-4:] if len(key) > 12 else "***"
989
+ status = "[green][ok][/green]"
990
+ else:
991
+ masked = "[dim]not set[/dim]"
992
+ status = "[dim]--[/dim]"
993
+
994
+ # Highlight active provider
995
+ if prov == provider:
996
+ name = f"[bold cyan]{name}[/bold cyan]"
997
+ table.add_row(f" {name}", Text.from_markup(masked), Text.from_markup(status))
998
+
999
+ table.add_row("", "", "")
719
1000
 
720
- chat.write(Panel(table, title=f"[cyan]{SIGMA} Config[/cyan]", border_style="dim"))
1001
+ # Data Keys
1002
+ polygon_key = getattr(self.settings, 'polygon_api_key', None)
1003
+ if polygon_key:
1004
+ table.add_row(" Polygon", polygon_key[:8] + "...", Text.from_markup("[green][ok][/green]"))
1005
+ else:
1006
+ table.add_row(" Polygon", Text.from_markup("[dim]not set[/dim]"), Text.from_markup("[dim]optional[/dim]"))
1007
+
1008
+ chat.write(Panel(table, border_style="dim"))
721
1009
 
722
1010
  def _show_models(self, chat: ChatLog):
723
1011
  table = Table(title=f"{SIGMA} Models", show_header=True, border_style="dim")
@@ -756,18 +1044,46 @@ Example: /setkey google AIzaSy...
756
1044
  chat.write_system(f"Model: {model}")
757
1045
 
758
1046
  def _set_key(self, provider: str, key: str, chat: ChatLog):
1047
+ """Save an API key for a provider."""
1048
+ # Normalize provider name
1049
+ provider = provider.lower().strip()
1050
+ valid_providers = ["google", "openai", "anthropic", "groq", "xai", "polygon", "alphavantage", "exa"]
1051
+
1052
+ if provider not in valid_providers:
1053
+ chat.write_error(f"[E{ErrorCode.INVALID_INPUT}] Unknown provider: {provider}")
1054
+ chat.write_system(f"Valid providers: {', '.join(valid_providers)}")
1055
+ return
1056
+
1057
+ # Basic key validation
1058
+ key = key.strip()
1059
+ if len(key) < 10:
1060
+ chat.write_error(f"[E{ErrorCode.INVALID_INPUT}] API key seems too short")
1061
+ return
1062
+
759
1063
  try:
760
- save_api_key(LLMProvider(provider), key)
761
- chat.write_system(f"{SIGMA} Key saved for {provider}")
762
- if provider == getattr(self.settings.default_provider, 'value', ''):
763
- self._init_llm()
1064
+ success = save_api_key(provider, key)
1065
+ if success:
1066
+ # Reload settings
1067
+ self.settings = get_settings()
1068
+
1069
+ # Show success with masked key
1070
+ masked = key[:6] + "..." + key[-4:] if len(key) > 10 else "***"
1071
+ chat.write_system(f"[green][ok][/green] {SIGMA} Key saved for [bold]{provider}[/bold]: {masked}")
1072
+
1073
+ # Auto-switch to this provider if it's an LLM provider and we don't have an LLM
1074
+ llm_providers = ["google", "openai", "anthropic", "groq", "xai"]
1075
+ if provider in llm_providers:
1076
+ if not self.llm or provider == getattr(self.settings.default_provider, 'value', ''):
1077
+ self._switch_provider(provider, chat)
1078
+ else:
1079
+ chat.write_error(f"[E{ErrorCode.UNKNOWN_ERROR}] Failed to save key")
764
1080
  except Exception as e:
765
- chat.write_error(str(e))
1081
+ chat.write_error(f"[E{ErrorCode.UNKNOWN_ERROR}] {str(e)}")
766
1082
 
767
1083
  @work(exclusive=True)
768
1084
  async def _process_query(self, query: str, chat: ChatLog):
769
1085
  if not self.llm:
770
- chat.write_error("No LLM. Use /keys to configure.")
1086
+ chat.write_error(f"[E{ErrorCode.API_KEY_MISSING}] No LLM configured. Use /keys to set up.")
771
1087
  return
772
1088
 
773
1089
  self.is_processing = True
@@ -777,7 +1093,7 @@ Example: /setkey google AIzaSy...
777
1093
 
778
1094
  # Clear ticker highlight and start sigma animation
779
1095
  ticker_highlight.update("")
780
- indicator.set_active(True)
1096
+ indicator.set_active(True, mode="thinking")
781
1097
 
782
1098
  try:
783
1099
  messages = [{"role": "system", "content": SYSTEM_PROMPT}]
@@ -808,10 +1124,24 @@ Example: /setkey google AIzaSy...
808
1124
  if len(self.conversation) > 20:
809
1125
  self.conversation = self.conversation[-20:]
810
1126
  else:
811
- chat.write_error("No response")
1127
+ chat.write_error(f"[E{ErrorCode.RESPONSE_INVALID}] No response received")
1128
+
1129
+ except SigmaError as e:
1130
+ tool_display.clear()
1131
+ # Format SigmaError nicely
1132
+ chat.write_error(f"[E{e.code}] {e.message}")
1133
+ if e.details.get("hint"):
1134
+ chat.write_system(f"[dim]Hint: {e.details['hint']}[/dim]")
812
1135
  except Exception as e:
813
1136
  tool_display.clear()
814
- chat.write_error(str(e))
1137
+ # Try to parse common API errors
1138
+ error_str = str(e)
1139
+ if "401" in error_str or "invalid" in error_str.lower() and "key" in error_str.lower():
1140
+ chat.write_error(f"[E{ErrorCode.API_KEY_INVALID}] API key is invalid. Use /keys to update.")
1141
+ elif "429" in error_str or "rate" in error_str.lower():
1142
+ chat.write_error(f"[E{ErrorCode.API_KEY_RATE_LIMITED}] Rate limit hit. Wait a moment and try again.")
1143
+ else:
1144
+ chat.write_error(f"[E{ErrorCode.PROVIDER_ERROR}] {error_str[:200]}")
815
1145
  finally:
816
1146
  indicator.set_active(False)
817
1147
  self.is_processing = False