@drico2008/fincli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +644 -0
  2. package/fincli/__init__.py +3 -0
  3. package/fincli/app/__init__.py +1 -0
  4. package/fincli/app/analysis/__init__.py +1 -0
  5. package/fincli/app/analysis/ai_prompts.py +33 -0
  6. package/fincli/app/analysis/analyzer.py +119 -0
  7. package/fincli/app/analysis/assistant_context.py +161 -0
  8. package/fincli/app/analysis/indicators.py +143 -0
  9. package/fincli/app/analysis/market_structure.py +106 -0
  10. package/fincli/app/analysis/technical_debate.py +251 -0
  11. package/fincli/app/analysis/technical_signal.py +203 -0
  12. package/fincli/app/cli/__init__.py +1 -0
  13. package/fincli/app/cli/autocomplete.py +17 -0
  14. package/fincli/app/cli/commands.py +82 -0
  15. package/fincli/app/cli/router.py +1257 -0
  16. package/fincli/app/main.py +16 -0
  17. package/fincli/app/modules/__init__.py +1 -0
  18. package/fincli/app/modules/economic_calendar.py +139 -0
  19. package/fincli/app/modules/exporter.py +51 -0
  20. package/fincli/app/modules/journal.py +65 -0
  21. package/fincli/app/modules/journal_analytics.py +70 -0
  22. package/fincli/app/modules/portfolio.py +34 -0
  23. package/fincli/app/modules/scanner.py +105 -0
  24. package/fincli/app/modules/transactions.py +84 -0
  25. package/fincli/app/modules/watchlist.py +25 -0
  26. package/fincli/app/providers/__init__.py +1 -0
  27. package/fincli/app/providers/ai/__init__.py +1 -0
  28. package/fincli/app/providers/ai/anthropic_provider.py +11 -0
  29. package/fincli/app/providers/ai/base.py +29 -0
  30. package/fincli/app/providers/ai/gemini_provider.py +11 -0
  31. package/fincli/app/providers/ai/groq_provider.py +11 -0
  32. package/fincli/app/providers/ai/http_provider.py +145 -0
  33. package/fincli/app/providers/ai/huggingface_provider.py +11 -0
  34. package/fincli/app/providers/ai/manager.py +60 -0
  35. package/fincli/app/providers/ai/openai_provider.py +11 -0
  36. package/fincli/app/providers/ai/openrouter_provider.py +11 -0
  37. package/fincli/app/providers/ai/together_provider.py +11 -0
  38. package/fincli/app/providers/market/__init__.py +1 -0
  39. package/fincli/app/providers/market/base.py +77 -0
  40. package/fincli/app/providers/market/custom_provider.py +169 -0
  41. package/fincli/app/providers/market/finnhub_provider.py +187 -0
  42. package/fincli/app/providers/market/manager.py +123 -0
  43. package/fincli/app/providers/market/news_provider.py +28 -0
  44. package/fincli/app/providers/market/symbols.py +182 -0
  45. package/fincli/app/providers/market/twelvedata_provider.py +167 -0
  46. package/fincli/app/providers/market/yfinance_provider.py +447 -0
  47. package/fincli/app/services/__init__.py +1 -0
  48. package/fincli/app/services/market_data.py +203 -0
  49. package/fincli/app/services/market_overview.py +111 -0
  50. package/fincli/app/storage/__init__.py +1 -0
  51. package/fincli/app/storage/cache.py +38 -0
  52. package/fincli/app/storage/config.py +114 -0
  53. package/fincli/app/storage/database.py +101 -0
  54. package/fincli/app/storage/market_cache.py +92 -0
  55. package/fincli/app/tui/__init__.py +1 -0
  56. package/fincli/app/tui/components.py +55 -0
  57. package/fincli/app/tui/layout.py +261 -0
  58. package/fincli/app/tui/market_provider_selector.py +267 -0
  59. package/fincli/app/tui/model_selector.py +412 -0
  60. package/fincli/app/tui/theme.py +157 -0
  61. package/fincli/app/utils/__init__.py +1 -0
  62. package/fincli/app/utils/errors.py +33 -0
  63. package/fincli/app/utils/formatting.py +17 -0
  64. package/fincli/app/utils/logger.py +19 -0
  65. package/npm/bin/fincli.js +35 -0
  66. package/npm/postinstall.js +72 -0
  67. package/package.json +23 -0
  68. package/pyproject.toml +31 -0
  69. package/requirements.txt +9 -0
@@ -0,0 +1,412 @@
1
+ """Interactive AI provider/model selector screen."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import os
7
+ from typing import Callable
8
+
9
+ from rich.text import Text
10
+ from textual.app import ComposeResult
11
+ from textual.containers import Vertical, VerticalScroll
12
+ from textual.events import Key
13
+ from textual.screen import ModalScreen
14
+ from textual.widgets import Input, Static
15
+
16
+ from fincli.app.providers.ai.manager import AIProviderManager
17
+ from fincli.app.storage.config import ConfigManager
18
+ from fincli.app.utils.formatting import mask_secret
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class ProviderChoice:
23
+ provider: str
24
+ label: str
25
+ env_key: str
26
+ category: str = "Popular"
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class ModelChoice:
31
+ provider: str
32
+ model: str
33
+ label: str
34
+ context: str = ""
35
+
36
+
37
+ PROVIDERS: tuple[ProviderChoice, ...] = (
38
+ ProviderChoice("openrouter", "OpenRouter", "OPENROUTER_API_KEY"),
39
+ ProviderChoice("openai", "OpenAI", "OPENAI_API_KEY"),
40
+ ProviderChoice("anthropic", "Anthropic", "ANTHROPIC_API_KEY"),
41
+ ProviderChoice("gemini", "Gemini", "GEMINI_API_KEY"),
42
+ ProviderChoice("groq", "Groq", "GROQ_API_KEY"),
43
+ ProviderChoice("together", "Together AI", "TOGETHER_API_KEY"),
44
+ ProviderChoice("huggingface", "HuggingFace", "HUGGINGFACE_API_KEY"),
45
+ )
46
+
47
+
48
+ MODEL_CATALOG: dict[str, tuple[ModelChoice, ...]] = {
49
+ "openrouter": (
50
+ # Routers / dynamic aliases
51
+ ModelChoice("openrouter", "openai/gpt-4o-mini", "GPT-4o Mini", "128K"),
52
+ ModelChoice("openrouter", "openrouter/free", "Free Models Router", "200K"),
53
+ ModelChoice("openrouter", "openrouter/auto", "Auto Router", ""),
54
+ ModelChoice("openrouter", "openrouter/fusion", "Fusion", "128K"),
55
+ ModelChoice("openrouter", "openrouter/pareto-code", "Pareto Code Router", "2M"),
56
+ ModelChoice("openrouter", "openrouter/owl-alpha", "Owl Alpha", "1M"),
57
+ ModelChoice("openrouter", "openrouter/bodybuilder", "Body Builder", "128K"),
58
+
59
+ # OpenAI via OpenRouter
60
+ ModelChoice("openrouter", "~openai/gpt-latest", "OpenAI GPT Latest", "1.05M"),
61
+ ModelChoice("openrouter", "~openai/gpt-mini-latest", "OpenAI GPT Mini Latest", "400K"),
62
+ ModelChoice("openrouter", "openai/gpt-chat-latest", "OpenAI GPT Chat Latest", "400K"),
63
+ ModelChoice("openrouter", "openai/gpt-5.5-pro", "OpenAI GPT-5.5 Pro", "1.05M"),
64
+ ModelChoice("openrouter", "openai/gpt-5.5", "OpenAI GPT-5.5", "1.05M"),
65
+ ModelChoice("openrouter", "openai/gpt-5.4-image-2", "OpenAI GPT-5.4 Image 2", "272K"),
66
+
67
+ # Anthropic via OpenRouter
68
+ ModelChoice("openrouter", "~anthropic/claude-opus-latest", "Claude Opus Latest", "1M"),
69
+ ModelChoice("openrouter", "~anthropic/claude-sonnet-latest", "Claude Sonnet Latest", "1M"),
70
+ ModelChoice("openrouter", "~anthropic/claude-haiku-latest", "Claude Haiku Latest", "200K"),
71
+ ModelChoice("openrouter", "anthropic/claude-opus-4.8", "Claude Opus 4.8", "1M"),
72
+ ModelChoice("openrouter", "anthropic/claude-opus-4.8-fast", "Claude Opus 4.8 Fast", "1M"),
73
+ ModelChoice("openrouter", "anthropic/claude-opus-4.7", "Claude Opus 4.7", "1M"),
74
+ ModelChoice("openrouter", "anthropic/claude-opus-4.7-fast", "Claude Opus 4.7 Fast", "1M"),
75
+ ModelChoice("openrouter", "anthropic/claude-opus-4.6-fast", "Claude Opus 4.6 Fast", "1M"),
76
+
77
+ # Google / Gemini via OpenRouter
78
+ ModelChoice("openrouter", "~google/gemini-pro-latest", "Gemini Pro Latest", "1M"),
79
+ ModelChoice("openrouter", "~google/gemini-flash-latest", "Gemini Flash Latest", "1M"),
80
+ ModelChoice("openrouter", "google/gemini-3.5-flash", "Gemini 3.5 Flash", "1M"),
81
+ ModelChoice("openrouter", "google/gemini-3.1-flash-lite", "Gemini 3.1 Flash Lite", "1M"),
82
+
83
+ # Qwen via OpenRouter
84
+ ModelChoice("openrouter", "qwen/qwen3.7-plus", "Qwen3.7 Plus", "1M"),
85
+ ModelChoice("openrouter", "qwen/qwen3.7-max", "Qwen3.7 Max", "1M"),
86
+ ModelChoice("openrouter", "qwen/qwen3.6-plus", "Qwen3.6 Plus", "1M"),
87
+ ModelChoice("openrouter", "qwen/qwen3.6-flash", "Qwen3.6 Flash", "1M"),
88
+ ModelChoice("openrouter", "qwen/qwen3.6-max-preview", "Qwen3.6 Max Preview", "256K"),
89
+ ModelChoice("openrouter", "qwen/qwen3.6-35b-a3b", "Qwen3.6 35B A3B", "262K"),
90
+ ModelChoice("openrouter", "qwen/qwen3.6-27b", "Qwen3.6 27B", "262K"),
91
+ ModelChoice("openrouter", "qwen/qwen3.5-plus-20260420", "Qwen3.5 Plus 2026-04-20", "1M"),
92
+
93
+ # DeepSeek / xAI / Mistral / others via OpenRouter
94
+ ModelChoice("openrouter", "deepseek/deepseek-v4-pro", "DeepSeek V4 Pro", "1M"),
95
+ ModelChoice("openrouter", "deepseek/deepseek-v4-flash", "DeepSeek V4 Flash", "1M"),
96
+ ModelChoice("openrouter", "x-ai/grok-build-0.1", "Grok Build 0.1", "256K"),
97
+ ModelChoice("openrouter", "x-ai/grok-4.3", "Grok 4.3", "1M"),
98
+ ModelChoice("openrouter", "x-ai/grok-4.20", "Grok 4.20", "2M"),
99
+ ModelChoice("openrouter", "x-ai/grok-4.20-multi-agent", "Grok 4.20 Multi-Agent", "2M"),
100
+ ModelChoice("openrouter", "mistralai/mistral-medium-3-5", "Mistral Medium 3.5", "262K"),
101
+ ModelChoice("openrouter", "minimax/minimax-m3", "MiniMax M3", "1M"),
102
+ ModelChoice("openrouter", "moonshotai/kimi-k2.6", "Kimi K2.6", "262K"),
103
+ ModelChoice("openrouter", "~moonshotai/kimi-latest", "Kimi Latest", "262K"),
104
+ ModelChoice("openrouter", "nvidia/nemotron-3-ultra-550b-a55b", "Nemotron 3 Ultra", "1M"),
105
+ ModelChoice("openrouter", "stepfun/step-3.7-flash", "Step 3.7 Flash", "256K"),
106
+ ModelChoice("openrouter", "perceptron/perceptron-mk1", "Perceptron Mk1", "32K"),
107
+ ModelChoice("openrouter", "inclusionai/ring-2.6-1t", "Ring-2.6-1T", "262K"),
108
+ ModelChoice("openrouter", "inclusionai/ling-2.6-1t", "Ling-2.6-1T", "262K"),
109
+ ModelChoice("openrouter", "inclusionai/ling-2.6-flash", "Ling-2.6 Flash", "262K"),
110
+ ModelChoice("openrouter", "tencent/hy3-preview", "Tencent Hy3 Preview", "262K"),
111
+ ModelChoice("openrouter", "xiaomi/mimo-v2.5-pro", "MiMo V2.5 Pro", "1M"),
112
+ ModelChoice("openrouter", "xiaomi/mimo-v2.5", "MiMo V2.5", "1M"),
113
+ ModelChoice("openrouter", "z-ai/glm-5.1", "GLM 5.1", "203K"),
114
+ ModelChoice("openrouter", "z-ai/glm-5v-turbo", "GLM 5V Turbo", "203K"),
115
+ ModelChoice("openrouter", "ibm-granite/granite-4.1-8b", "Granite 4.1 8B", "131K"),
116
+ ModelChoice("openrouter", "arcee-ai/trinity-large-thinking", "Trinity Large Thinking", "262K"),
117
+
118
+ # Verified free OpenRouter variants / free-priority
119
+ ModelChoice("openrouter", "nvidia/nemotron-3.5-content-safety:free", "Nemotron 3.5 Content Safety Free", "128K"),
120
+ ModelChoice("openrouter", "nvidia/nemotron-3-ultra-550b-a55b:free", "Nemotron 3 Ultra Free", "1M"),
121
+ ModelChoice("openrouter", "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free", "Nemotron 3 Nano Omni Free", "256K"),
122
+ ModelChoice("openrouter", "poolside/laguna-xs.2:free", "Laguna XS.2 Free", "262K"),
123
+ ModelChoice("openrouter", "poolside/laguna-m.1:free", "Laguna M.1 Free", "262K"),
124
+ ModelChoice("openrouter", "moonshotai/kimi-k2.6:free", "Kimi K2.6 Free", "262K"),
125
+ ModelChoice("openrouter", "google/gemma-4-26b-a4b-it:free", "Gemma 4 26B A4B Free", "262K"),
126
+ ModelChoice("openrouter", "google/gemma-4-31b-it:free", "Gemma 4 31B Free", "262K"),
127
+ ModelChoice("openrouter", "liquid/lfm-2.5-1.2b-thinking:free", "LFM2.5 1.2B Thinking Free", "32K"),
128
+ ModelChoice("openrouter", "liquid/lfm-2.5-1.2b-instruct:free", "LFM2.5 1.2B Instruct Free", "32K"),
129
+ ModelChoice("openrouter", "nvidia/nemotron-3-nano-30b-a3b:free", "Nemotron 3 Nano 30B A3B Free", "131K"),
130
+ ModelChoice("openrouter", "nvidia/nemotron-nano-12b-v2-vl:free", "Nemotron Nano 12B V2 VL Free", "128K"),
131
+ ModelChoice("openrouter", "openai/gpt-oss-120b:free", "GPT OSS 120B Free", "131K"),
132
+ ModelChoice("openrouter", "openai/gpt-oss-20b:free", "GPT OSS 20B Free", "131K"),
133
+ ModelChoice("openrouter", "z-ai/glm-4.5-air:free", "GLM 4.5 Air Free", "131K"),
134
+ ModelChoice("openrouter", "qwen/qwen3-coder:free", "Qwen3 Coder Free", "1M"),
135
+ ModelChoice("openrouter", "cognitivecomputations/dolphin-mistral-24b-venice-edition:free", "Dolphin Mistral 24B Venice Free", "32K"),
136
+ ModelChoice("openrouter", "meta-llama/llama-3.3-70b-instruct:free", "Llama 3.3 70B Free", "131K"),
137
+ ModelChoice("openrouter", "meta-llama/llama-3.2-3b-instruct:free", "Llama 3.2 3B Free", "131K"),
138
+ ),
139
+
140
+ "openai": (
141
+ ModelChoice("openai", "gpt-5.5", "GPT-5.5", "1M"),
142
+ ModelChoice("openai", "gpt-5.4", "GPT-5.4", "1M"),
143
+ ModelChoice("openai", "gpt-5.4-mini", "GPT-5.4 Mini", "400K"),
144
+ ModelChoice("openai", "gpt-5.4-nano", "GPT-5.4 Nano", "400K"),
145
+ ModelChoice("openai", "gpt-4.1", "GPT-4.1", "1M"),
146
+ ModelChoice("openai", "gpt-4.1-mini", "GPT-4.1 Mini", "1M"),
147
+ ModelChoice("openai", "gpt-4.1-nano", "GPT-4.1 Nano", "1M"),
148
+ ModelChoice("openai", "gpt-4o", "GPT-4o", "128K"),
149
+ ModelChoice("openai", "gpt-4o-mini", "GPT-4o Mini", "128K"),
150
+ ),
151
+
152
+ "anthropic": (
153
+ ModelChoice("anthropic", "claude-opus-4-8", "Claude Opus 4.8", "1M"),
154
+ ModelChoice("anthropic", "claude-sonnet-4-6", "Claude Sonnet 4.6", "1M"),
155
+ ModelChoice("anthropic", "claude-haiku-4-5", "Claude Haiku 4.5", "200K"),
156
+ ModelChoice("anthropic", "claude-3-5-sonnet-latest", "Claude 3.5 Sonnet Latest", "200K"),
157
+ ModelChoice("anthropic", "claude-3-5-haiku-latest", "Claude 3.5 Haiku Latest", "200K"),
158
+ ModelChoice("anthropic", "claude-3-opus-latest", "Claude 3 Opus Latest", "200K"),
159
+ ),
160
+
161
+ "gemini": (
162
+ ModelChoice("gemini", "gemini-3.5-flash", "Gemini 3.5 Flash", "1M"),
163
+ ModelChoice("gemini", "gemini-3.1-pro-preview", "Gemini 3.1 Pro Preview", "1M"),
164
+ ModelChoice("gemini", "gemini-3.1-pro-preview-customtools", "Gemini 3.1 Pro Preview Custom Tools", "1M"),
165
+ ModelChoice("gemini", "gemini-3-flash-preview", "Gemini 3 Flash Preview", "1M"),
166
+ ModelChoice("gemini", "gemini-2.5-pro", "Gemini 2.5 Pro", "1M"),
167
+ ModelChoice("gemini", "gemini-2.5-flash", "Gemini 2.5 Flash", "1M"),
168
+ ModelChoice("gemini", "gemini-2.5-flash-lite", "Gemini 2.5 Flash-Lite", "1M"),
169
+ ),
170
+
171
+ "groq": (
172
+ ModelChoice("groq", "llama-3.1-8b-instant", "Llama 3.1 8B Instant", "131K"),
173
+ ModelChoice("groq", "llama-3.3-70b-versatile", "Llama 3.3 70B Versatile", "131K"),
174
+ ModelChoice("groq", "openai/gpt-oss-120b", "GPT OSS 120B", "131K"),
175
+ ModelChoice("groq", "openai/gpt-oss-20b", "GPT OSS 20B", "131K"),
176
+ ModelChoice("groq", "groq/compound", "Groq Compound", "131K"),
177
+ ModelChoice("groq", "groq/compound-mini", "Groq Compound Mini", "131K"),
178
+ ModelChoice("groq", "meta-llama/llama-4-scout-17b-16e-instruct", "Llama 4 Scout 17B 16E", "131K"),
179
+ ModelChoice("groq", "qwen/qwen3-32b", "Qwen3 32B", "131K"),
180
+ ModelChoice("groq", "openai/gpt-oss-safeguard-20b", "GPT OSS Safeguard 20B", "131K"),
181
+ ),
182
+
183
+ "together": (
184
+ ModelChoice("together", "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo", "Llama 3.1 70B Turbo", "128K"),
185
+ ModelChoice("together", "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo", "Llama 3.1 8B Turbo", "128K"),
186
+ ModelChoice("together", "Qwen/Qwen2.5-72B-Instruct-Turbo", "Qwen 2.5 72B Turbo", "128K"),
187
+ ModelChoice("together", "deepseek-ai/DeepSeek-R1", "DeepSeek R1", ""),
188
+ ModelChoice("together", "deepseek-ai/DeepSeek-V3", "DeepSeek V3", ""),
189
+ ModelChoice("together", "mistralai/Mixtral-8x7B-Instruct-v0.1", "Mixtral 8x7B Instruct", "32K"),
190
+ ModelChoice("together", "mistralai/Mistral-7B-Instruct-v0.3", "Mistral 7B Instruct v0.3", "32K"),
191
+ ),
192
+
193
+ "huggingface": (
194
+ ModelChoice("huggingface", "meta-llama/Llama-3.1-8B-Instruct", "Llama 3.1 8B", ""),
195
+ ModelChoice("huggingface", "meta-llama/Llama-3.1-70B-Instruct", "Llama 3.1 70B", ""),
196
+ ModelChoice("huggingface", "meta-llama/Llama-3.3-70B-Instruct", "Llama 3.3 70B", ""),
197
+ ModelChoice("huggingface", "Qwen/Qwen2.5-7B-Instruct", "Qwen 2.5 7B", ""),
198
+ ModelChoice("huggingface", "Qwen/Qwen2.5-72B-Instruct", "Qwen 2.5 72B", ""),
199
+ ModelChoice("huggingface", "Qwen/QwQ-32B", "QwQ 32B", ""),
200
+ ModelChoice("huggingface", "mistralai/Mistral-7B-Instruct-v0.3", "Mistral 7B Instruct", ""),
201
+ ModelChoice("huggingface", "mistralai/Mixtral-8x7B-Instruct-v0.1", "Mixtral 8x7B Instruct", ""),
202
+ ModelChoice("huggingface", "google/gemma-2-9b-it", "Gemma 2 9B IT", ""),
203
+ ModelChoice("huggingface", "google/gemma-2-27b-it", "Gemma 2 27B IT", ""),
204
+ ModelChoice("huggingface", "deepseek-ai/DeepSeek-R1", "DeepSeek R1", ""),
205
+ ModelChoice("huggingface", "deepseek-ai/DeepSeek-V3-0324", "DeepSeek V3 0324", ""),
206
+ ModelChoice("huggingface", "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B", "DeepSeek R1 Distill Qwen 32B", ""),
207
+ ModelChoice("huggingface", "microsoft/Phi-3.5-mini-instruct", "Phi 3.5 Mini Instruct", ""),
208
+ ),
209
+ }
210
+
211
+
212
+ class AIModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
213
+ """Modal selector for AI provider and model."""
214
+
215
+ BINDINGS = [
216
+ ("escape", "cancel", "Cancel"),
217
+ ("tab", "change_provider", "Provider"),
218
+ ("up", "cursor_up", "Up"),
219
+ ("down", "cursor_down", "Down"),
220
+ ("enter", "select", "Select"),
221
+ ]
222
+
223
+ def __init__(self, config: ConfigManager, on_selected: Callable[[str, str], None]) -> None:
224
+ super().__init__()
225
+ self.config = config
226
+ self.on_selected = on_selected
227
+ self.mode = "provider"
228
+ self.selected_index = 0
229
+ self.selected_provider = config.settings.ai_provider
230
+ self.search = ""
231
+
232
+ def compose(self) -> ComposeResult:
233
+ with Vertical(id="ai_selector_card"):
234
+ yield Static(id="ai_selector_title")
235
+ yield Static(id="ai_selector_provider")
236
+ yield Input(placeholder="Search providers...", id="ai_selector_search")
237
+ with VerticalScroll(id="ai_selector_scroll"):
238
+ yield Static(id="ai_selector_list")
239
+ yield Static(id="ai_selector_help")
240
+
241
+ def on_mount(self) -> None:
242
+ self._sync_search_placeholder()
243
+ self._render_selector()
244
+ self.query_one("#ai_selector_search", Input).focus()
245
+
246
+ def on_input_changed(self, event: Input.Changed) -> None:
247
+ self.search = event.value.strip().lower()
248
+ self.selected_index = 0
249
+ self._render_selector()
250
+
251
+ def on_input_submitted(self, event: Input.Submitted) -> None:
252
+ event.stop()
253
+ self.action_select()
254
+
255
+ def on_key(self, event: Key) -> None:
256
+ if event.key == "up":
257
+ event.stop()
258
+ self.action_cursor_up()
259
+ elif event.key == "down":
260
+ event.stop()
261
+ self.action_cursor_down()
262
+ elif event.key == "tab":
263
+ event.stop()
264
+ self.action_change_provider()
265
+ elif event.key == "escape":
266
+ event.stop()
267
+ self.action_cancel()
268
+
269
+ def action_cursor_up(self) -> None:
270
+ total = len(self._visible_items())
271
+ if total:
272
+ self.selected_index = (self.selected_index - 1) % total
273
+ self._render_selector()
274
+
275
+ def action_cursor_down(self) -> None:
276
+ total = len(self._visible_items())
277
+ if total:
278
+ self.selected_index = (self.selected_index + 1) % total
279
+ self._render_selector()
280
+
281
+ def action_change_provider(self) -> None:
282
+ self._set_mode("provider")
283
+
284
+ def action_select(self) -> None:
285
+ items = self._visible_items()
286
+ if not items:
287
+ return
288
+ selected = items[self.selected_index]
289
+ if self.mode == "provider":
290
+ provider = selected.provider # type: ignore[attr-defined]
291
+ self.selected_provider = provider
292
+ if _has_key(provider):
293
+ self._set_mode("configured")
294
+ else:
295
+ self._set_mode("model")
296
+ return
297
+ if self.mode == "configured":
298
+ if selected == "Configure again":
299
+ self._set_mode("model")
300
+ else:
301
+ self._set_mode("model")
302
+ return
303
+ model = selected.model # type: ignore[attr-defined]
304
+ self.config.set_ai_model(self.selected_provider, model)
305
+ self.on_selected(self.selected_provider, model)
306
+ self.dismiss((self.selected_provider, model))
307
+
308
+ def action_cancel(self) -> None:
309
+ self.dismiss(None)
310
+
311
+ def _set_mode(self, mode: str) -> None:
312
+ self.mode = mode
313
+ self.selected_index = 0
314
+ self.search = ""
315
+ self.query_one("#ai_selector_search", Input).value = ""
316
+ self._sync_search_placeholder()
317
+ self._render_selector()
318
+
319
+ def _sync_search_placeholder(self) -> None:
320
+ search = self.query_one("#ai_selector_search", Input)
321
+ search.placeholder = "Search models..." if self.mode == "model" else "Search providers..."
322
+
323
+ def _visible_items(self) -> list[ProviderChoice] | list[ModelChoice] | list[str]:
324
+ if self.mode == "provider":
325
+ items = list(PROVIDERS)
326
+ if self.search:
327
+ items = [item for item in items if self.search in item.label.lower() or self.search in item.provider]
328
+ return items
329
+ if self.mode == "configured":
330
+ return ["Use existing configuration", "Configure again"]
331
+ models = list(MODEL_CATALOG.get(self.selected_provider, ()))
332
+ if self.search:
333
+ models = [item for item in models if self.search in item.label.lower() or self.search in item.model.lower()]
334
+ if not models:
335
+ models = [ModelChoice(self.selected_provider, self.config.settings.ai_model, self.config.settings.ai_model, "current/custom")]
336
+ return models
337
+
338
+ def _render_selector(self) -> None:
339
+ title = self.query_one("#ai_selector_title", Static)
340
+ provider = self.query_one("#ai_selector_provider", Static)
341
+ body = self.query_one("#ai_selector_list", Static)
342
+ help_text = self.query_one("#ai_selector_help", Static)
343
+
344
+ if self.mode == "provider":
345
+ title.update("Select Provider")
346
+ provider.update("")
347
+ elif self.mode == "configured":
348
+ choice = _provider_choice(self.selected_provider)
349
+ label = choice.label if choice else self.selected_provider
350
+ title.update(f"{label} is already configured")
351
+ provider.update(f"[cyan]Provider:[/] {label} [dim]{_masked_key(self.selected_provider)}[/]")
352
+ else:
353
+ choice = _provider_choice(self.selected_provider)
354
+ label = choice.label if choice else self.selected_provider
355
+ title.update("Select Model")
356
+ provider.update(f"[cyan]Provider:[/] {label} [dim](tab to change provider)[/]")
357
+
358
+ items = self._visible_items()
359
+ body.update(self._items_text(items))
360
+ help_text.update(self._help_text())
361
+
362
+ def _items_text(self, items: list[ProviderChoice] | list[ModelChoice] | list[str]) -> Text:
363
+ text = Text()
364
+ if self.mode == "provider":
365
+ text.append("Popular\n", style="bold dim")
366
+ elif self.mode == "model":
367
+ hidden = max(0, len(MODEL_CATALOG.get(self.selected_provider, ())) - len(items))
368
+ text.append(f"{hidden} filtered\n" if self.search else "Available models\n", style="bold dim")
369
+
370
+ for index, item in enumerate(items):
371
+ selected = index == self.selected_index
372
+ prefix = "> " if selected else " "
373
+ style = "black on cyan" if selected else "white"
374
+ if isinstance(item, ProviderChoice):
375
+ current = " • (current)" if item.provider == self.config.settings.ai_provider else ""
376
+ configured = " ●" if _has_key(item.provider) else ""
377
+ line = f"{prefix}{item.label}{current}{configured}\n"
378
+ elif isinstance(item, ModelChoice):
379
+ current = " • (current)" if item.model == self.config.settings.ai_model else ""
380
+ context = f" {item.context}" if item.context else ""
381
+ line = f"{prefix}{item.label}{context}{current}\n"
382
+ else:
383
+ line = f"{prefix}{item}\n"
384
+ text.append(line, style=style)
385
+ if not items:
386
+ text.append("No matches.\n", style="dim")
387
+ return text
388
+
389
+ def _help_text(self) -> str:
390
+ if self.mode == "configured":
391
+ return "Up/down navigate, Enter to select, Esc to go back"
392
+ if self.mode == "model":
393
+ return "Type to search, up/down navigate, Enter to select, Tab to change provider, Esc to close"
394
+ return "Type to search, up/down navigate, Enter to select, Esc to close"
395
+
396
+
397
+ def _provider_choice(provider: str) -> ProviderChoice | None:
398
+ normalized = provider.lower()
399
+ return next((choice for choice in PROVIDERS if choice.provider == normalized), None)
400
+
401
+
402
+ def _has_key(provider: str) -> bool:
403
+ choice = _provider_choice(provider)
404
+ return bool(choice and os.getenv(choice.env_key))
405
+
406
+
407
+ def _masked_key(provider: str) -> str:
408
+ choice = _provider_choice(provider)
409
+ if choice is None:
410
+ return "not configured"
411
+ masked = mask_secret(os.getenv(choice.env_key))
412
+ return "not configured" if masked == "not set" else f"configured {masked}"
@@ -0,0 +1,157 @@
1
+ """Theme constants for the Textual UI."""
2
+
3
+ APP_CSS = """
4
+ Screen {
5
+ background: #050505;
6
+ color: #e5e7eb;
7
+ }
8
+
9
+ Header {
10
+ background: #0b0f14;
11
+ color: #22d3ee;
12
+ text-style: bold;
13
+ }
14
+
15
+ #workspace {
16
+ height: 1fr;
17
+ width: 100%;
18
+ }
19
+
20
+ #main {
21
+ width: 1fr;
22
+ height: 1fr;
23
+ padding: 1 6;
24
+ background: #050505;
25
+ }
26
+
27
+ #output {
28
+ background: #050505;
29
+ color: #e5e7eb;
30
+ border: none;
31
+ }
32
+
33
+ #command_area {
34
+ dock: bottom;
35
+ height: auto;
36
+ background: #050505;
37
+ padding: 0 6 1 6;
38
+ }
39
+
40
+ #command_line {
41
+ height: 3;
42
+ margin: 0 0 1 0;
43
+ border: none;
44
+ background: #262a27;
45
+ color: #f8fafc;
46
+ }
47
+
48
+ #command_prompt {
49
+ width: 3;
50
+ height: 3;
51
+ background: #262a27;
52
+ color: #22d3ee;
53
+ text-style: bold;
54
+ padding: 0 0 0 2;
55
+ }
56
+
57
+ #command_input {
58
+ width: 1fr;
59
+ height: 3;
60
+ border: none;
61
+ background: #262a27;
62
+ color: #f8fafc;
63
+ padding: 0 2 0 0;
64
+ }
65
+
66
+ #command_input:focus {
67
+ border: none;
68
+ }
69
+
70
+ #command_palette_scroll {
71
+ height: 9;
72
+ margin: 0 0 0 0;
73
+ background: #050505;
74
+ color: #f8fafc;
75
+ scrollbar-size: 1 1;
76
+ scrollbar-background: #050505;
77
+ scrollbar-color: #22d3ee;
78
+ }
79
+
80
+ #command_palette {
81
+ height: auto;
82
+ margin: 0 0 0 0;
83
+ background: #050505;
84
+ color: #f8fafc;
85
+ }
86
+
87
+ #status_bar {
88
+ dock: bottom;
89
+ height: 1;
90
+ background: #0b0f14;
91
+ color: #64748b;
92
+ padding: 0 6;
93
+ }
94
+
95
+ .section-title {
96
+ color: #7dd3fc;
97
+ text-style: bold;
98
+ }
99
+
100
+ .muted {
101
+ color: #94a3b8;
102
+ }
103
+
104
+ #ai_selector_card {
105
+ width: 78;
106
+ height: 30;
107
+ background: #252525;
108
+ color: #f8fafc;
109
+ padding: 1;
110
+ }
111
+
112
+ #ai_selector_title {
113
+ height: 2;
114
+ color: #f8fafc;
115
+ text-style: bold;
116
+ }
117
+
118
+ #ai_selector_provider {
119
+ height: 2;
120
+ color: #f8fafc;
121
+ padding: 0 2;
122
+ }
123
+
124
+ #ai_selector_search {
125
+ height: 3;
126
+ margin: 0 1 1 1;
127
+ border: solid #8a8a8a;
128
+ background: #252525;
129
+ color: #f8fafc;
130
+ padding: 0 1;
131
+ }
132
+
133
+ #ai_selector_search:focus {
134
+ border: solid #a3a3a3;
135
+ }
136
+
137
+ #ai_selector_scroll {
138
+ height: 1fr;
139
+ margin: 0 1;
140
+ background: #252525;
141
+ scrollbar-size: 1 1;
142
+ scrollbar-background: #252525;
143
+ scrollbar-color: #22d3ee;
144
+ }
145
+
146
+ #ai_selector_list {
147
+ height: auto;
148
+ background: #252525;
149
+ color: #f8fafc;
150
+ }
151
+
152
+ #ai_selector_help {
153
+ height: 3;
154
+ color: #9ca3af;
155
+ padding: 1 0 0 0;
156
+ }
157
+ """
@@ -0,0 +1 @@
1
+ """Utility modules."""
@@ -0,0 +1,33 @@
1
+ """Application error types."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class FinCLIError(Exception):
7
+ """Base error for user-facing FinCLI failures."""
8
+
9
+ help_text: str | None = None
10
+
11
+ def __init__(self, message: str, help_text: str | None = None) -> None:
12
+ super().__init__(message)
13
+ self.help_text = help_text
14
+
15
+
16
+ class ConfigError(FinCLIError):
17
+ """Raised when configuration cannot be loaded or saved."""
18
+
19
+
20
+ class StorageError(FinCLIError):
21
+ """Raised when local storage fails."""
22
+
23
+
24
+ class CommandError(FinCLIError):
25
+ """Raised when a command is invalid or incomplete."""
26
+
27
+
28
+ class ProviderError(FinCLIError):
29
+ """Raised when an external provider fails."""
30
+
31
+
32
+ class RateLimitError(ProviderError):
33
+ """Raised when a provider is rate-limited."""
@@ -0,0 +1,17 @@
1
+ """Small formatting helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def mask_secret(value: str | None) -> str:
7
+ """Mask API keys and tokens before displaying them."""
8
+ if not value:
9
+ return "not set"
10
+ if len(value) <= 8:
11
+ return "set"
12
+ return f"{value[:4]}...{value[-4:]}"
13
+
14
+
15
+ def normalize_symbol(symbol: str) -> str:
16
+ """Normalize user-entered market symbols."""
17
+ return symbol.strip().upper()
@@ -0,0 +1,19 @@
1
+ """Logging setup for FinCLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+
8
+ APP_DIR = Path.home() / ".fincli"
9
+ LOG_FILE = APP_DIR / "fincli.log"
10
+
11
+
12
+ def configure_logging() -> None:
13
+ """Configure file logging without leaking to the terminal UI."""
14
+ APP_DIR.mkdir(parents=True, exist_ok=True)
15
+ logging.basicConfig(
16
+ filename=LOG_FILE,
17
+ level=logging.INFO,
18
+ format="%(asctime)s %(levelname)s %(name)s %(message)s",
19
+ )