sigma-terminal 3.3.2__py3-none-any.whl → 3.4.1__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.1 - 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.1"
27
27
  SIGMA = "σ"
28
28
 
29
29
  # Common stock tickers for recognition
@@ -38,52 +38,156 @@ 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]",
41
+
42
+ def format_return(value: float, include_sign: bool = True) -> str:
43
+ """Format a return value with color coding. Green for positive, red for negative."""
44
+ if value > 0:
45
+ sign = "+" if include_sign else ""
46
+ return f"[#22c55e]{sign}{value:.2f}%[/#22c55e]"
47
+ elif value < 0:
48
+ return f"[#ef4444]{value:.2f}%[/#ef4444]"
49
+ else:
50
+ return f"[dim]{value:.2f}%[/dim]"
51
+
52
+
53
+ def format_price_change(price: float, change: float, change_pct: float) -> str:
54
+ """Format price with change indicators."""
55
+ if change >= 0:
56
+ arrow = "^"
57
+ color = "#22c55e"
58
+ sign = "+"
59
+ else:
60
+ arrow = "v"
61
+ color = "#ef4444"
62
+ sign = ""
63
+ return f"[bold]${price:.2f}[/bold] [{color}]{arrow} {sign}{change:.2f} ({sign}{change_pct:.2f}%)[/{color}]"
64
+
65
+
66
+ def format_metric(label: str, value: str, good: Optional[bool] = None) -> str:
67
+ """Format a metric with optional good/bad coloring."""
68
+ if good is True:
69
+ return f"[dim]{label}:[/dim] [#22c55e]{value}[/#22c55e]"
70
+ elif good is False:
71
+ return f"[dim]{label}:[/dim] [#ef4444]{value}[/#ef4444]"
72
+ else:
73
+ return f"[dim]{label}:[/dim] [bold]{value}[/bold]"
74
+
75
+
76
+ # Sigma animation frames - smooth color breathing like Claude Code
77
+ SIGMA_FRAMES = [
78
+ "[bold #1e3a8a]σ[/bold #1e3a8a]",
79
+ "[bold #1e40af]σ[/bold #1e40af]",
80
+ "[bold #2563eb]σ[/bold #2563eb]",
81
+ "[bold #3b82f6]σ[/bold #3b82f6]",
82
+ "[bold #60a5fa]σ[/bold #60a5fa]",
83
+ "[bold #93c5fd]σ[/bold #93c5fd]",
84
+ "[bold #bfdbfe]σ[/bold #bfdbfe]",
45
85
  "[bold white]σ[/bold white]",
86
+ "[bold #bfdbfe]σ[/bold #bfdbfe]",
87
+ "[bold #93c5fd]σ[/bold #93c5fd]",
46
88
  "[bold #60a5fa]σ[/bold #60a5fa]",
89
+ "[bold #3b82f6]σ[/bold #3b82f6]",
90
+ "[bold #2563eb]σ[/bold #2563eb]",
91
+ "[bold #1e40af]σ[/bold #1e40af]",
47
92
  ]
48
93
 
49
- # Tool call animation frames
50
- TOOL_CALL_FRAMES = [
51
- "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"
94
+ # Sigma pulse animation for tool calls (faster pulse)
95
+ SIGMA_PULSE_FRAMES = [
96
+ "[bold #22c55e]σ[/bold #22c55e]",
97
+ "[bold #4ade80]σ[/bold #4ade80]",
98
+ "[bold #86efac]σ[/bold #86efac]",
99
+ "[bold #bbf7d0]σ[/bold #bbf7d0]",
100
+ "[bold #86efac]σ[/bold #86efac]",
101
+ "[bold #4ade80]σ[/bold #4ade80]",
102
+ "[bold #22c55e]σ[/bold #22c55e]",
103
+ "[bold #16a34a]σ[/bold #16a34a]",
104
+ ]
105
+
106
+ # Tool call spinner frames - classic ASCII spinner
107
+ TOOL_SPINNER_FRAMES = [
108
+ "|", "/", "-", "\\"
52
109
  ]
53
110
 
54
111
  # Welcome banner - clean design
55
112
  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]
113
+ [bold #3b82f6]███████╗██╗ ██████╗ ███╗ ███╗ █████╗ [/bold #3b82f6]
114
+ [bold #60a5fa]██╔════╝██║██╔════╝ ████╗ ████║██╔══██╗[/bold #60a5fa]
115
+ [bold #93c5fd]███████╗██║██║ ███╗██╔████╔██║███████║[/bold #93c5fd]
116
+ [bold #60a5fa]╚════██║██║██║ ██║██║╚██╔╝██║██╔══██║[/bold #60a5fa]
117
+ [bold #3b82f6]███████║██║╚██████╔╝██║ ╚═╝ ██║██║ ██║[/bold #3b82f6]
118
+ [bold #1d4ed8]╚══════╝╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝[/bold #1d4ed8]
119
+
120
+ [bold cyan]Finance Research Agent[/bold cyan] [dim]v3.4.1[/dim]
64
121
  """
65
122
 
66
- SYSTEM_PROMPT = """You are Sigma, a Finance Research Agent. You provide comprehensive market analysis, trading strategies, and investment insights.
123
+ 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
124
 
68
125
  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)
72
- - Backtesting strategies (SMA crossover, RSI, MACD, Bollinger, momentum, breakout)
73
- - 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."""
126
+ - Real-time market data (quotes, prices, volume) via yfinance and Polygon.io
127
+ - Chart generation (candlestick, line, technical, comparison charts)
128
+ - Fundamental analysis (financials, ratios, earnings, valuations, balance sheets)
129
+ - Technical analysis (RSI, MACD, Bollinger Bands, SMA/EMA, support/resistance)
130
+ - Valuation analysis (P/E, P/B, PEG, EV/EBITDA with fair value assessment)
131
+ - Risk metrics (volatility, VaR, max drawdown, Sharpe, Sortino, Beta, Alpha)
132
+ - Earnings analysis (EPS, upcoming dates, quarterly history, surprises)
133
+ - Dividend analysis (yield, payout ratio, growth, ex-dividend dates)
134
+ - Options analysis (put/call ratio, implied volatility, sentiment)
135
+ - Peer comparison (industry peers on key metrics)
136
+ - Backtesting (SMA crossover, RSI, MACD, Bollinger, momentum, breakout)
137
+ - Market overview with sector performance and economic indicators
138
+ - Insider trading and institutional activity tracking
139
+ - Financial news search and SEC filings analysis
140
+
141
+ CHART GENERATION:
142
+ - Use generate_stock_chart for single stock charts (candlestick, line, technical)
143
+ - Use generate_comparison_chart for comparing multiple stocks visually
144
+ - Charts are saved as PNG files - always mention the file path
145
+
146
+ RESPONSE PHILOSOPHY:
147
+ 1. BE PROACTIVE: Anticipate follow-up questions and address them
148
+ 2. BE THOROUGH: When analyzing, cover fundamentals + technicals + sentiment
149
+ 3. BE ACTIONABLE: Always end with clear recommendations or next steps
150
+ 4. BE DATA-DRIVEN: Cite specific numbers, dates, and percentages
151
+ 5. BE HONEST: Acknowledge uncertainty and risks
152
+
153
+ RESPONSE FORMAT:
154
+ - Start with a 1-2 sentence executive summary (the key takeaway)
155
+ - Use structured sections with clear headers (## format)
156
+ - Present comparative data in markdown tables
157
+ - Highlight key metrics: **bold** for critical numbers
158
+ - For returns: indicate positive (+) or negative (-) clearly
159
+ - Use bullet points for easy scanning
160
+ - End with "**Bottom Line:**" or "**Recommendation:**"
161
+
162
+ RATING SYSTEM (when asked for recommendations):
163
+ - STRONG BUY [A+]: Exceptional opportunity, high conviction
164
+ - BUY [A]: Favorable outlook, solid fundamentals
165
+ - HOLD [B]: Fair value, wait for better entry
166
+ - SELL [C]: Concerns outweigh positives
167
+ - STRONG SELL [D]: Significant risks, avoid
168
+
169
+ DATA GATHERING RULES:
170
+ - ALWAYS use tools to fetch current data before answering stock questions
171
+ - Use multiple tools for comprehensive analysis (quote + technicals + fundamentals)
172
+ - Cross-reference data points for accuracy
173
+ - If a tool fails, try alternative approaches or acknowledge the limitation
174
+
175
+ PROACTIVE INSIGHTS:
176
+ When analyzing any stock, proactively mention:
177
+ - Recent earnings surprises or upcoming earnings dates
178
+ - Major analyst rating changes
179
+ - Unusual volume or price movements
180
+ - Relevant sector trends
181
+ - Key support/resistance levels
182
+ - Comparison to peers when relevant
183
+
184
+ FORBIDDEN:
185
+ - Never fabricate data or prices
186
+ - Never provide data without fetching it first
187
+ - Never give buy/sell advice without disclosure of risks
188
+ - Never claim certainty about future performance
189
+
190
+ Remember: Users trust you for professional-grade financial research. Exceed their expectations with every response."""
87
191
 
88
192
  # Enhanced autocomplete suggestions with more variety
89
193
  SUGGESTIONS = [
@@ -128,11 +232,27 @@ SUGGESTIONS = [
128
232
  "insider trading for AAPL",
129
233
  "institutional holders of NVDA",
130
234
  "analyst recommendations for TSLA",
235
+ # Chart generation
236
+ "plot AAPL stock chart",
237
+ "chart NVDA candlestick",
238
+ "show me a chart of TSLA",
239
+ "compare AAPL MSFT GOOGL chart",
240
+ "technical chart for SPY",
241
+ "generate chart for AMZN",
242
+ # Advanced analysis
243
+ "valuation of AAPL",
244
+ "risk metrics for NVDA",
245
+ "is TSLA overvalued",
246
+ "dividend analysis of JNJ",
247
+ "options summary for SPY",
248
+ "peer comparison for NVDA",
249
+ "earnings analysis of AAPL",
131
250
  # Natural language queries
132
251
  "what should I know about AAPL",
133
- "is NVDA overvalued",
134
252
  "best tech stocks right now",
135
253
  "should I buy TSLA",
254
+ "how risky is NVDA",
255
+ "what is the P/E of MSFT",
136
256
  # Commands
137
257
  "/help",
138
258
  "/clear",
@@ -142,6 +262,29 @@ SUGGESTIONS = [
142
262
  "/backtest",
143
263
  ]
144
264
 
265
+ # Extended action verbs for smart completion
266
+ ACTION_VERBS = [
267
+ "analyze", "compare", "show", "get", "what is", "tell me about",
268
+ "technical analysis", "fundamentals", "price", "quote", "chart",
269
+ "backtest", "insider trading", "institutional", "analyst", "earnings",
270
+ "financials", "valuation", "risk", "dividend", "options", "peers",
271
+ "news", "sector", "market", "portfolio"
272
+ ]
273
+
274
+ # Ticker categories for smart suggestions
275
+ TICKER_CATEGORIES = {
276
+ "tech": ["AAPL", "MSFT", "GOOGL", "AMZN", "META", "NVDA", "AMD", "INTC", "CRM", "ADBE"],
277
+ "finance": ["JPM", "BAC", "GS", "MS", "V", "MA", "BRK.B", "C", "WFC", "AXP"],
278
+ "healthcare": ["JNJ", "UNH", "PFE", "MRK", "ABBV", "LLY", "TMO", "ABT", "BMY", "GILD"],
279
+ "consumer": ["AMZN", "TSLA", "HD", "NKE", "MCD", "SBUX", "COST", "WMT", "TGT", "LOW"],
280
+ "energy": ["XOM", "CVX", "COP", "SLB", "EOG", "MPC", "PSX", "VLO", "OXY", "HAL"],
281
+ "etf": ["SPY", "QQQ", "IWM", "DIA", "VTI", "VOO", "VXX", "ARKK", "XLF", "XLK"],
282
+ "crypto": ["COIN", "MSTR", "RIOT", "MARA", "HUT"],
283
+ "ev": ["TSLA", "RIVN", "LCID", "NIO", "XPEV", "LI"],
284
+ "ai": ["NVDA", "AMD", "MSFT", "GOOGL", "META", "PLTR", "AI", "PATH", "SNOW"],
285
+ "semiconductor": ["NVDA", "AMD", "INTC", "AVGO", "QCOM", "TXN", "MU", "AMAT", "LRCX", "ASML"],
286
+ }
287
+
145
288
 
146
289
  def extract_tickers(text: str) -> List[str]:
147
290
  """Extract stock tickers from text."""
@@ -167,34 +310,174 @@ def extract_tickers(text: str) -> List[str]:
167
310
 
168
311
 
169
312
  class SigmaSuggester(Suggester):
170
- """Enhanced autocomplete suggester with ticker recognition."""
313
+ """Smart autocomplete with fuzzy matching and context awareness."""
171
314
 
172
315
  def __init__(self):
173
- super().__init__(use_cache=True, case_sensitive=False)
316
+ super().__init__(use_cache=False, case_sensitive=False)
317
+ self._build_suggestion_index()
318
+
319
+ def _build_suggestion_index(self):
320
+ """Build an index of all possible suggestions for fast lookup."""
321
+ self.all_suggestions = []
322
+
323
+ # Add static suggestions
324
+ self.all_suggestions.extend(SUGGESTIONS)
325
+
326
+ # Generate dynamic suggestions for all tickers
327
+ for ticker in COMMON_TICKERS:
328
+ self.all_suggestions.extend([
329
+ f"analyze {ticker}",
330
+ f"technical analysis of {ticker}",
331
+ f"fundamentals of {ticker}",
332
+ f"price of {ticker}",
333
+ f"quote {ticker}",
334
+ f"insider trading {ticker}",
335
+ f"earnings {ticker}",
336
+ f"news {ticker}",
337
+ ])
338
+
339
+ # Add category-based suggestions
340
+ for category, tickers in TICKER_CATEGORIES.items():
341
+ self.all_suggestions.append(f"best {category} stocks")
342
+ self.all_suggestions.append(f"{category} sector performance")
343
+ if len(tickers) >= 3:
344
+ self.all_suggestions.append(f"compare {' '.join(tickers[:3])}")
345
+
346
+ def _fuzzy_match(self, query: str, target: str) -> float:
347
+ """Calculate fuzzy match score (0-1). Higher is better."""
348
+ query = query.lower()
349
+ target = target.lower()
350
+
351
+ # Exact prefix match is best
352
+ if target.startswith(query):
353
+ return 1.0 + len(query) / len(target)
354
+
355
+ # Check if all query chars appear in order
356
+ q_idx = 0
357
+ matches = 0
358
+ consecutive = 0
359
+ max_consecutive = 0
360
+ last_match = -1
361
+
362
+ for t_idx, char in enumerate(target):
363
+ if q_idx < len(query) and char == query[q_idx]:
364
+ matches += 1
365
+ if last_match == t_idx - 1:
366
+ consecutive += 1
367
+ max_consecutive = max(max_consecutive, consecutive)
368
+ else:
369
+ consecutive = 1
370
+ last_match = t_idx
371
+ q_idx += 1
372
+
373
+ if q_idx < len(query):
374
+ # Not all chars matched
375
+ # Try substring match
376
+ if query in target:
377
+ return 0.5
378
+ return 0
379
+
380
+ # Score based on match quality
381
+ score = (matches / len(query)) * 0.5 + (max_consecutive / len(query)) * 0.3
382
+ # Bonus for shorter targets (more relevant)
383
+ score += (1 - len(target) / 100) * 0.2
384
+
385
+ return score
386
+
387
+ def _get_context_suggestions(self, value: str) -> List[str]:
388
+ """Get context-aware suggestions based on partial input."""
389
+ suggestions = []
390
+ value_lower = value.lower().strip()
391
+
392
+ # Command shortcuts
393
+ if value.startswith("/"):
394
+ cmd = value_lower[1:]
395
+ commands = ["/help", "/clear", "/keys", "/models", "/status", "/backtest",
396
+ "/provider", "/model", "/setkey", "/tickers"]
397
+ for c in commands:
398
+ if c[1:].startswith(cmd):
399
+ suggestions.append(c)
400
+ return suggestions
401
+
402
+ # Detect if user is typing a ticker
403
+ words = value.split()
404
+ last_word = words[-1] if words else ""
405
+
406
+ if last_word.startswith("$") or (last_word.isupper() and len(last_word) >= 1):
407
+ ticker_prefix = last_word.lstrip("$").upper()
408
+ matching_tickers = [t for t in COMMON_TICKERS if t.startswith(ticker_prefix)]
409
+
410
+ # If we have an action verb, complete with ticker
411
+ action_words = ["analyze", "compare", "technical", "price", "quote",
412
+ "fundamentals", "insider", "earnings", "news"]
413
+ prefix = " ".join(words[:-1]).lower() if len(words) > 1 else ""
414
+
415
+ for ticker in matching_tickers[:5]:
416
+ if prefix:
417
+ suggestions.append(f"{prefix} {ticker}")
418
+ else:
419
+ suggestions.append(f"analyze {ticker}")
420
+ return suggestions
421
+
422
+ # Natural language patterns
423
+ patterns = [
424
+ ("ana", "analyze"),
425
+ ("tech", "technical analysis of"),
426
+ ("fun", "fundamentals of"),
427
+ ("comp", "compare"),
428
+ ("back", "backtest"),
429
+ ("mark", "market overview"),
430
+ ("sect", "sector performance"),
431
+ ("pri", "price of"),
432
+ ("quo", "quote"),
433
+ ("ins", "insider trading"),
434
+ ("ear", "earnings"),
435
+ ("wha", "what should I know about"),
436
+ ("sho", "should I buy"),
437
+ ("is ", "is NVDA overvalued"),
438
+ ("how", "how is"),
439
+ ("bes", "best tech stocks"),
440
+ ]
441
+
442
+ for prefix, expansion in patterns:
443
+ if value_lower.startswith(prefix):
444
+ if expansion.endswith("of") or expansion.endswith("about"):
445
+ # Add popular tickers
446
+ for ticker in ["AAPL", "NVDA", "MSFT", "TSLA", "GOOGL"]:
447
+ suggestions.append(f"{expansion} {ticker}")
448
+ else:
449
+ suggestions.append(expansion)
450
+
451
+ return suggestions
174
452
 
175
453
  async def get_suggestion(self, value: str) -> Optional[str]:
176
- """Get autocomplete suggestion."""
177
- if not value or len(value) < 2:
454
+ """Get the best autocomplete suggestion."""
455
+ if not value or len(value) < 1:
178
456
  return None
179
457
 
180
- value_lower = value.lower()
458
+ value_lower = value.lower().strip()
459
+
460
+ # Get context-aware suggestions first
461
+ context_suggestions = self._get_context_suggestions(value)
462
+ if context_suggestions:
463
+ return context_suggestions[0]
181
464
 
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}"
465
+ # Fuzzy match against all suggestions
466
+ scored = []
467
+ for suggestion in self.all_suggestions:
468
+ score = self._fuzzy_match(value_lower, suggestion)
469
+ if score > 0:
470
+ scored.append((score, suggestion))
188
471
 
189
- # Standard suggestions
190
- for suggestion in SUGGESTIONS:
191
- if suggestion.lower().startswith(value_lower):
192
- return suggestion
472
+ # Sort by score and return best
473
+ scored.sort(key=lambda x: x[0], reverse=True)
193
474
 
194
- # Try partial match in middle of suggestion
195
- for suggestion in SUGGESTIONS:
196
- if value_lower in suggestion.lower():
197
- return suggestion
475
+ if scored:
476
+ return scored[0][1]
477
+
478
+ # Last resort: if looks like a ticker, suggest analyze
479
+ if value.isupper() and len(value) <= 5 and value.isalpha():
480
+ return f"analyze {value}"
198
481
 
199
482
  return None
200
483
 
@@ -230,8 +513,8 @@ Screen {
230
513
 
231
514
  #status-bar {
232
515
  height: 3;
233
- background: #0d1117;
234
- border-top: solid #1a1a2e;
516
+ background: #0f1419;
517
+ border-top: solid #1e293b;
235
518
  padding: 0 2;
236
519
  dock: bottom;
237
520
  }
@@ -256,9 +539,9 @@ Screen {
256
539
  #tool-calls-display {
257
540
  width: 100%;
258
541
  height: auto;
259
- max-height: 6;
260
- background: #0d1117;
261
- border: solid #1a1a2e;
542
+ max-height: 8;
543
+ background: #0f1419;
544
+ border: round #1e293b;
262
545
  margin: 0 2;
263
546
  padding: 0 1;
264
547
  display: none;
@@ -271,7 +554,7 @@ Screen {
271
554
  #input-area {
272
555
  height: 5;
273
556
  padding: 1 2;
274
- background: #0d1117;
557
+ background: #0f1419;
275
558
  }
276
559
 
277
560
  #input-row {
@@ -283,22 +566,24 @@ Screen {
283
566
  width: 4;
284
567
  height: 3;
285
568
  content-align: center middle;
569
+ background: transparent;
286
570
  }
287
571
 
288
572
  #prompt-input {
289
573
  width: 1fr;
290
- background: #1a1a2e;
291
- border: solid #3b82f6;
292
- color: #ffffff;
574
+ background: #1e293b;
575
+ border: tall #3b82f6;
576
+ color: #f8fafc;
293
577
  padding: 0 1;
294
578
  }
295
579
 
296
580
  #prompt-input:focus {
297
- border: solid #60a5fa;
581
+ border: tall #60a5fa;
582
+ background: #1e3a5f;
298
583
  }
299
584
 
300
585
  #prompt-input.-autocomplete {
301
- border: solid #22c55e;
586
+ border: tall #22c55e;
302
587
  }
303
588
 
304
589
  #ticker-highlight {
@@ -306,6 +591,7 @@ Screen {
306
591
  height: 1;
307
592
  padding: 0 1;
308
593
  background: transparent;
594
+ color: #22d3ee;
309
595
  }
310
596
 
311
597
  Footer {
@@ -345,7 +631,7 @@ Footer > .footer--description {
345
631
 
346
632
 
347
633
  class ToolCallDisplay(Static):
348
- """Animated display for tool calls."""
634
+ """Animated display for tool calls - professional tool execution view."""
349
635
 
350
636
  def __init__(self, *args, **kwargs):
351
637
  super().__init__(*args, **kwargs)
@@ -355,11 +641,13 @@ class ToolCallDisplay(Static):
355
641
 
356
642
  def add_tool_call(self, name: str, status: str = "running"):
357
643
  """Add a tool call to the display."""
358
- self.tool_calls.append({"name": name, "status": status, "frame": 0})
644
+ # Format tool name nicely
645
+ display_name = name.replace("_", " ").title()
646
+ self.tool_calls.append({"name": name, "display": display_name, "status": status, "frame": 0})
359
647
  self.add_class("visible")
360
- self._render()
648
+ self._update_display()
361
649
  if not self.timer:
362
- self.timer = self.set_interval(0.1, self._animate)
650
+ self.timer = self.set_interval(0.06, self._animate) # Faster animation
363
651
 
364
652
  def complete_tool_call(self, name: str):
365
653
  """Mark a tool call as complete."""
@@ -367,7 +655,7 @@ class ToolCallDisplay(Static):
367
655
  if tc["name"] == name and tc["status"] == "running":
368
656
  tc["status"] = "complete"
369
657
  break
370
- self._render()
658
+ self._update_display()
371
659
 
372
660
  def clear(self):
373
661
  """Clear all tool calls."""
@@ -380,52 +668,64 @@ class ToolCallDisplay(Static):
380
668
 
381
669
  def _animate(self):
382
670
  """Animate the spinner."""
383
- self.frame = (self.frame + 1) % len(TOOL_CALL_FRAMES)
671
+ self.frame = (self.frame + 1) % len(TOOL_SPINNER_FRAMES)
384
672
  for tc in self.tool_calls:
385
673
  if tc["status"] == "running":
386
674
  tc["frame"] = self.frame
387
- self._render()
675
+ self._update_display()
388
676
 
389
- def _render(self):
390
- """Render the tool calls display."""
677
+ def _update_display(self):
678
+ """Update the tool calls display content."""
391
679
  if not self.tool_calls:
680
+ self.update("")
392
681
  return
393
682
 
394
683
  lines = []
395
684
  for tc in self.tool_calls:
396
685
  if tc["status"] == "running":
397
- spinner = TOOL_CALL_FRAMES[tc["frame"]]
398
- lines.append(f" [cyan]{spinner}[/cyan] [bold]{tc['name']}[/bold] [dim]executing...[/dim]")
686
+ spinner = TOOL_SPINNER_FRAMES[tc["frame"] % len(TOOL_SPINNER_FRAMES)]
687
+ lines.append(f" [bold #60a5fa]{spinner}[/bold #60a5fa] [bold white]{tc['display']}[/bold white] [dim italic]running...[/dim italic]")
399
688
  else:
400
- lines.append(f" [green][/green] [bold]{tc['name']}[/bold] [green]complete[/green]")
689
+ lines.append(f" [bold #22c55e][OK][/bold #22c55e] [bold white]{tc['display']}[/bold white] [#22c55e]done[/#22c55e]")
401
690
 
402
691
  self.update(Text.from_markup("\n".join(lines)))
403
692
 
404
693
 
405
694
  class SigmaIndicator(Static):
406
- """Pulsing sigma indicator with minimal footprint."""
695
+ """Animated sigma indicator - typewriter style when active."""
407
696
 
408
697
  def __init__(self, *args, **kwargs):
409
698
  super().__init__(*args, **kwargs)
410
699
  self.active = False
700
+ self.mode = "idle" # idle, thinking, tool
411
701
  self.frame = 0
412
702
  self.timer = None
413
703
 
414
704
  def on_mount(self):
415
705
  self.update(Text.from_markup(f"[bold blue]{SIGMA}[/bold blue]"))
416
706
 
417
- def set_active(self, active: bool):
707
+ def set_active(self, active: bool, mode: str = "thinking"):
418
708
  self.active = active
709
+ self.mode = mode if active else "idle"
419
710
  if active and not self.timer:
420
- self.timer = self.set_interval(0.15, self._pulse)
711
+ self.frame = 0
712
+ # Fast smooth animation - 0.05s for thinking, 0.04s for tool calls
713
+ interval = 0.05 if mode == "thinking" else 0.04
714
+ self.timer = self.set_interval(interval, self._animate)
421
715
  elif not active and self.timer:
422
716
  self.timer.stop()
423
717
  self.timer = None
424
718
  self.update(Text.from_markup(f"[bold blue]{SIGMA}[/bold blue]"))
425
719
 
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]))
720
+ def _animate(self):
721
+ if self.mode == "thinking":
722
+ # Typewriter effect: s -> si -> sig -> sigm -> sigma -> sigm -> ...
723
+ self.frame = (self.frame + 1) % len(SIGMA_FRAMES)
724
+ self.update(Text.from_markup(SIGMA_FRAMES[self.frame]))
725
+ else:
726
+ # Pulse effect for tool calls
727
+ self.frame = (self.frame + 1) % len(SIGMA_PULSE_FRAMES)
728
+ self.update(Text.from_markup(SIGMA_PULSE_FRAMES[self.frame]))
429
729
 
430
730
 
431
731
  class TickerHighlight(Static):
@@ -538,20 +838,28 @@ class SigmaApp(App):
538
838
  chat.write_welcome()
539
839
 
540
840
  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")
841
+ chat.write_system(f"{SIGMA} Provider: [bold]{provider}[/bold] | Model: [bold]{self.settings.default_model}[/bold]")
842
+ chat.write_system(f"{SIGMA} Type [cyan]/help[/cyan] for commands • [cyan]/keys[/cyan] to set up API keys")
543
843
  chat.write_system("")
544
844
 
545
845
  self._init_llm()
546
846
  self.query_one("#prompt-input", Input).focus()
547
847
 
548
848
  def _init_llm(self):
849
+ """Initialize the LLM client with proper error handling."""
549
850
  try:
550
851
  self.llm = get_llm(self.settings.default_provider, self.settings.default_model)
852
+ except SigmaError as e:
853
+ chat = self.query_one("#chat-log", ChatLog)
854
+ chat.write_error(f"[E{e.code}] {e.message}")
855
+ if e.details.get("hint"):
856
+ chat.write_system(f"[dim]Hint: {e.details['hint']}[/dim]")
857
+ self.llm = None
551
858
  except Exception as e:
552
859
  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")
860
+ chat.write_error(f"[E{ErrorCode.PROVIDER_ERROR}] Failed to initialize: {str(e)[:100]}")
861
+ chat.write_system("[dim]Use /keys to configure API keys[/dim]")
862
+ self.llm = None
555
863
 
556
864
  @on(Input.Changed)
557
865
  def on_input_change(self, event: Input.Changed):
@@ -613,59 +921,58 @@ class SigmaApp(App):
613
921
  def _show_comprehensive_help(self, chat: ChatLog):
614
922
  """Show comprehensive help with examples."""
615
923
  help_text = f"""
616
- [bold cyan]═══════════════════════════════════════════════════════════════[/bold cyan]
617
- [bold] {SIGMA} SIGMA HELP CENTER [/bold]
618
- [bold cyan]═══════════════════════════════════════════════════════════════[/bold cyan]
619
-
620
- [bold yellow]QUICK START[/bold yellow]
621
- Just type naturally! Examples:
622
- "analyze AAPL" - Full analysis of Apple
623
- "compare NVDA AMD INTC" - Compare multiple stocks
624
- "is TSLA overvalued?" - Get AI insights
625
- "market overview" - See major indices
626
-
627
- [bold yellow]COMMANDS[/bold yellow]
628
- [cyan]/help[/cyan] This help screen
629
- [cyan]/clear[/cyan] Clear chat history
630
- [cyan]/keys[/cyan] Configure API keys
631
- [cyan]/models[/cyan] Show available models
632
- [cyan]/status[/cyan] Current configuration
633
- [cyan]/backtest[/cyan] Show backtest strategies
634
- [cyan]/provider <name>[/cyan] Switch AI provider
635
- [cyan]/model <name>[/cyan] Switch model
636
- [cyan]/setkey <p> <k>[/cyan] Set API key
637
- [cyan]/tickers[/cyan] Popular tickers list
638
-
639
- [bold yellow]ANALYSIS EXAMPLES[/bold yellow]
640
- "technical analysis of SPY"
641
- "fundamentals of MSFT"
642
- "insider trading for AAPL"
643
- • "analyst recommendations for NVDA"
644
- • "sector performance"
645
-
646
- [bold yellow]BACKTESTING[/bold yellow]
647
- "backtest SMA crossover on AAPL"
648
- "backtest RSI strategy on SPY"
649
- "backtest MACD on NVDA"
650
- Strategies: sma_crossover, rsi, macd, bollinger, momentum, breakout
651
-
652
- [bold yellow]KEYBOARD SHORTCUTS[/bold yellow]
653
- [bold]Tab[/bold] Autocomplete suggestion
654
- [bold]Ctrl+L[/bold] Clear chat
655
- [bold]Ctrl+M[/bold] Show models
656
- [bold]Ctrl+H[/bold] Toggle quick help
657
- [bold]Ctrl+P[/bold] Command palette
658
- [bold]Esc[/bold] Cancel operation
659
-
660
- [bold yellow]TIPS[/bold yellow]
661
- • Type [cyan]$AAPL[/cyan] or [cyan]AAPL[/cyan] - tickers auto-detected
662
- Use Tab for smart autocomplete
663
- • Detected tickers shown next to input
924
+ [bold white on #1e3a8a] [/bold white on #1e3a8a]
925
+ [bold white on #1e3a8a] {SIGMA} S I G M A H E L P C E N T E R [/bold white on #1e3a8a]
926
+ [bold white on #1e3a8a] [/bold white on #1e3a8a]
927
+
928
+ [bold #3b82f6]GETTING STARTED[/bold #3b82f6]
929
+ Type naturally - Sigma understands finance queries:
930
+ [dim]>>[/dim] analyze AAPL [dim]Full company analysis[/dim]
931
+ [dim]>>[/dim] compare NVDA AMD INTC [dim]Side-by-side comparison[/dim]
932
+ [dim]>>[/dim] technical analysis SPY [dim]RSI, MACD, Bollinger Bands[/dim]
933
+ [dim]>>[/dim] plot TSLA chart [dim]Generate price chart[/dim]
934
+ [dim]>>[/dim] backtest SMA on AAPL [dim]Strategy simulation[/dim]
935
+
936
+ [bold #3b82f6]COMMANDS[/bold #3b82f6]
937
+ [cyan]/help[/cyan] Full help documentation
938
+ [cyan]/clear[/cyan] Clear conversation history
939
+ [cyan]/keys[/cyan] API key configuration
940
+ [cyan]/models[/cyan] Available AI models
941
+ [cyan]/status[/cyan] Current settings
942
+ [cyan]/backtest[/cyan] Backtesting strategies
943
+ [cyan]/provider[/cyan] [dim]<name>[/dim] Switch provider (google, openai, anthropic)
944
+ [cyan]/model[/cyan] [dim]<name>[/dim] Switch model
945
+ [cyan]/setkey[/cyan] [dim]<p> <key>[/dim] Set API key
946
+ [cyan]/tickers[/cyan] Popular ticker list
947
+
948
+ [bold #3b82f6]ANALYSIS CAPABILITIES[/bold #3b82f6]
949
+ [bold]Fundamental[/bold] financials, earnings, valuation, balance sheet
950
+ [bold]Technical[/bold] RSI, MACD, SMA/EMA, Bollinger, support/resistance
951
+ [bold]Sentiment[/bold] analyst ratings, insider trades, institutional holdings
952
+ [bold]Market[/bold] sector performance, economic indicators, market news
953
+ [bold]Charts[/bold] candlestick, line, technical, comparison charts
954
+
955
+ [bold #3b82f6]BACKTEST STRATEGIES[/bold #3b82f6]
956
+ [bold]sma_crossover[/bold] SMA 20/50 crossover signals
957
+ [bold]rsi[/bold] RSI mean reversion (30/70)
958
+ [bold]macd[/bold] MACD momentum signals
959
+ [bold]bollinger[/bold] Bollinger Bands bounce
960
+ [bold]momentum[/bold] Dual momentum strategy
961
+ [bold]breakout[/bold] Price breakout signals
962
+
963
+ [bold #3b82f6]KEYBOARD[/bold #3b82f6]
964
+ [bold]Tab[/bold] Smart autocomplete
965
+ [bold]Ctrl+L[/bold] Clear chat
966
+ [bold]Ctrl+M[/bold] Models menu
967
+ [bold]Ctrl+H[/bold] Quick help
968
+ [bold]Ctrl+P[/bold] Command palette
969
+
970
+ [dim]Returns: [/dim][#22c55e]+green = gain[/#22c55e][dim], [/dim][#ef4444]-red = loss[/#ef4444][dim] | Tickers auto-detected from input[/dim]
664
971
  """
665
972
  chat.write(Panel(
666
973
  Text.from_markup(help_text),
667
974
  title=f"[bold cyan]{SIGMA} Help[/bold cyan]",
668
- border_style="cyan",
975
+ border_style="#3b82f6",
669
976
  padding=(0, 1),
670
977
  ))
671
978
 
@@ -687,37 +994,88 @@ class SigmaApp(App):
687
994
  ))
688
995
 
689
996
  def _show_keys(self, chat: ChatLog):
690
- chat.write_system(f"""
691
- [bold]{SIGMA} API Keys[/bold]
692
- Set key: /setkey <provider> <key>
997
+ """Show comprehensive API key management interface."""
998
+ keys_help = f"""
999
+ [bold cyan]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold cyan]
1000
+ [bold] {SIGMA} API KEY MANAGER [/bold]
1001
+ [bold cyan]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold cyan]
1002
+
1003
+ [bold yellow]QUICK SETUP[/bold yellow]
1004
+ Set a key: [cyan]/setkey <provider> <your-api-key>[/cyan]
1005
+
1006
+ [bold yellow]LLM PROVIDERS[/bold yellow]
1007
+ [bold]google[/bold] → https://aistudio.google.com/apikey
1008
+ [bold]openai[/bold] → https://platform.openai.com/api-keys
1009
+ [bold]anthropic[/bold] → https://console.anthropic.com/settings/keys
1010
+ [bold]groq[/bold] → https://console.groq.com/keys [dim](free!)[/dim]
1011
+ [bold]xai[/bold] → https://console.x.ai
1012
+
1013
+ [bold yellow]DATA PROVIDERS[/bold yellow] [dim](optional - enhances data quality)[/dim]
1014
+ [bold]polygon[/bold] → https://polygon.io/dashboard/api-keys
1015
+
1016
+ [bold yellow]EXAMPLES[/bold yellow]
1017
+ /setkey google AIzaSyB...
1018
+ /setkey openai sk-proj-...
1019
+ /setkey polygon abc123...
1020
+ /provider groq [dim]← switch to groq[/dim]
693
1021
 
694
- Providers: google, openai, anthropic, groq, xai
695
- Example: /setkey google AIzaSy...
696
- """)
1022
+ [bold yellow]TIPS[/bold yellow]
1023
+ [green]Groq[/green] is free and fast - great for starting out
1024
+ • [green]Ollama[/green] runs locally - no key needed (/provider ollama)
1025
+ • Keys are stored in [dim]~/.sigma/config.env[/dim]
1026
+ """
1027
+ chat.write(Panel(
1028
+ Text.from_markup(keys_help),
1029
+ title=f"[bold cyan]{SIGMA} API Keys[/bold cyan]",
1030
+ border_style="cyan",
1031
+ padding=(0, 1),
1032
+ ))
697
1033
  self._show_status(chat)
698
1034
 
699
1035
  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("")
1036
+ """Show current configuration status."""
1037
+ table = Table(show_header=True, box=None, padding=(0, 2), title=f"{SIGMA} Current Status")
1038
+ table.add_column("Setting", style="bold")
1039
+ table.add_column("Value")
1040
+ table.add_column("Status")
703
1041
 
704
1042
  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),
1043
+ table.add_row("Provider", provider, "[green][*][/green] Active")
1044
+ table.add_row("Model", self.settings.default_model, "")
1045
+ table.add_row("", "", "")
1046
+
1047
+ # LLM Keys - show FULL keys (no masking)
1048
+ llm_keys = [
1049
+ ("Google", self.settings.google_api_key, "google"),
1050
+ ("OpenAI", self.settings.openai_api_key, "openai"),
1051
+ ("Anthropic", self.settings.anthropic_api_key, "anthropic"),
1052
+ ("Groq", self.settings.groq_api_key, "groq"),
1053
+ ("xAI", self.settings.xai_api_key, "xai"),
715
1054
  ]
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))
1055
+ for name, key, prov in llm_keys:
1056
+ if key:
1057
+ # Show full key - no masking
1058
+ display_key = key
1059
+ status = "[green]OK[/green]"
1060
+ else:
1061
+ display_key = "[dim]not set[/dim]"
1062
+ status = "[dim]--[/dim]"
1063
+
1064
+ # Highlight active provider
1065
+ if prov == provider:
1066
+ name = f"[bold cyan]{name}[/bold cyan]"
1067
+ table.add_row(f" {name}", Text.from_markup(display_key), Text.from_markup(status))
1068
+
1069
+ table.add_row("", "", "")
719
1070
 
720
- chat.write(Panel(table, title=f"[cyan]{SIGMA} Config[/cyan]", border_style="dim"))
1071
+ # Data Keys - show FULL keys
1072
+ polygon_key = getattr(self.settings, 'polygon_api_key', None)
1073
+ if polygon_key:
1074
+ table.add_row(" Polygon", polygon_key, Text.from_markup("[green]OK[/green]"))
1075
+ else:
1076
+ table.add_row(" Polygon", Text.from_markup("[dim]not set[/dim]"), Text.from_markup("[dim]optional[/dim]"))
1077
+
1078
+ chat.write(Panel(table, border_style="dim"))
721
1079
 
722
1080
  def _show_models(self, chat: ChatLog):
723
1081
  table = Table(title=f"{SIGMA} Models", show_header=True, border_style="dim")
@@ -756,18 +1114,45 @@ Example: /setkey google AIzaSy...
756
1114
  chat.write_system(f"Model: {model}")
757
1115
 
758
1116
  def _set_key(self, provider: str, key: str, chat: ChatLog):
1117
+ """Save an API key for a provider."""
1118
+ # Normalize provider name
1119
+ provider = provider.lower().strip()
1120
+ valid_providers = ["google", "openai", "anthropic", "groq", "xai", "polygon", "alphavantage", "exa"]
1121
+
1122
+ if provider not in valid_providers:
1123
+ chat.write_error(f"[E{ErrorCode.INVALID_INPUT}] Unknown provider: {provider}")
1124
+ chat.write_system(f"Valid providers: {', '.join(valid_providers)}")
1125
+ return
1126
+
1127
+ # Basic key validation
1128
+ key = key.strip()
1129
+ if len(key) < 10:
1130
+ chat.write_error(f"[E{ErrorCode.INVALID_INPUT}] API key seems too short")
1131
+ return
1132
+
759
1133
  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()
1134
+ success = save_api_key(provider, key)
1135
+ if success:
1136
+ # Reload settings
1137
+ self.settings = get_settings()
1138
+
1139
+ # Show success with FULL key (no masking)
1140
+ chat.write_system(f"[green]OK[/green] {SIGMA} Key saved for [bold]{provider}[/bold]: {key}")
1141
+
1142
+ # Auto-switch to this provider if it's an LLM provider and we don't have an LLM
1143
+ llm_providers = ["google", "openai", "anthropic", "groq", "xai"]
1144
+ if provider in llm_providers:
1145
+ if not self.llm or provider == getattr(self.settings.default_provider, 'value', ''):
1146
+ self._switch_provider(provider, chat)
1147
+ else:
1148
+ chat.write_error(f"[E{ErrorCode.UNKNOWN_ERROR}] Failed to save key")
764
1149
  except Exception as e:
765
- chat.write_error(str(e))
1150
+ chat.write_error(f"[E{ErrorCode.UNKNOWN_ERROR}] {str(e)}")
766
1151
 
767
1152
  @work(exclusive=True)
768
1153
  async def _process_query(self, query: str, chat: ChatLog):
769
1154
  if not self.llm:
770
- chat.write_error("No LLM. Use /keys to configure.")
1155
+ chat.write_error(f"[E{ErrorCode.API_KEY_MISSING}] No LLM configured. Use /keys to set up.")
771
1156
  return
772
1157
 
773
1158
  self.is_processing = True
@@ -777,7 +1162,7 @@ Example: /setkey google AIzaSy...
777
1162
 
778
1163
  # Clear ticker highlight and start sigma animation
779
1164
  ticker_highlight.update("")
780
- indicator.set_active(True)
1165
+ indicator.set_active(True, mode="thinking")
781
1166
 
782
1167
  try:
783
1168
  messages = [{"role": "system", "content": SYSTEM_PROMPT}]
@@ -808,10 +1193,24 @@ Example: /setkey google AIzaSy...
808
1193
  if len(self.conversation) > 20:
809
1194
  self.conversation = self.conversation[-20:]
810
1195
  else:
811
- chat.write_error("No response")
1196
+ chat.write_error(f"[E{ErrorCode.RESPONSE_INVALID}] No response received")
1197
+
1198
+ except SigmaError as e:
1199
+ tool_display.clear()
1200
+ # Format SigmaError nicely
1201
+ chat.write_error(f"[E{e.code}] {e.message}")
1202
+ if e.details.get("hint"):
1203
+ chat.write_system(f"[dim]Hint: {e.details['hint']}[/dim]")
812
1204
  except Exception as e:
813
1205
  tool_display.clear()
814
- chat.write_error(str(e))
1206
+ # Try to parse common API errors
1207
+ error_str = str(e)
1208
+ if "401" in error_str or "invalid" in error_str.lower() and "key" in error_str.lower():
1209
+ chat.write_error(f"[E{ErrorCode.API_KEY_INVALID}] API key is invalid. Use /keys to update.")
1210
+ elif "429" in error_str or "rate" in error_str.lower():
1211
+ chat.write_error(f"[E{ErrorCode.API_KEY_RATE_LIMITED}] Rate limit hit. Wait a moment and try again.")
1212
+ else:
1213
+ chat.write_error(f"[E{ErrorCode.PROVIDER_ERROR}] {error_str[:200]}")
815
1214
  finally:
816
1215
  indicator.set_active(False)
817
1216
  self.is_processing = False