@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.
- package/README.md +644 -0
- package/fincli/__init__.py +3 -0
- package/fincli/app/__init__.py +1 -0
- package/fincli/app/analysis/__init__.py +1 -0
- package/fincli/app/analysis/ai_prompts.py +33 -0
- package/fincli/app/analysis/analyzer.py +119 -0
- package/fincli/app/analysis/assistant_context.py +161 -0
- package/fincli/app/analysis/indicators.py +143 -0
- package/fincli/app/analysis/market_structure.py +106 -0
- package/fincli/app/analysis/technical_debate.py +251 -0
- package/fincli/app/analysis/technical_signal.py +203 -0
- package/fincli/app/cli/__init__.py +1 -0
- package/fincli/app/cli/autocomplete.py +17 -0
- package/fincli/app/cli/commands.py +82 -0
- package/fincli/app/cli/router.py +1257 -0
- package/fincli/app/main.py +16 -0
- package/fincli/app/modules/__init__.py +1 -0
- package/fincli/app/modules/economic_calendar.py +139 -0
- package/fincli/app/modules/exporter.py +51 -0
- package/fincli/app/modules/journal.py +65 -0
- package/fincli/app/modules/journal_analytics.py +70 -0
- package/fincli/app/modules/portfolio.py +34 -0
- package/fincli/app/modules/scanner.py +105 -0
- package/fincli/app/modules/transactions.py +84 -0
- package/fincli/app/modules/watchlist.py +25 -0
- package/fincli/app/providers/__init__.py +1 -0
- package/fincli/app/providers/ai/__init__.py +1 -0
- package/fincli/app/providers/ai/anthropic_provider.py +11 -0
- package/fincli/app/providers/ai/base.py +29 -0
- package/fincli/app/providers/ai/gemini_provider.py +11 -0
- package/fincli/app/providers/ai/groq_provider.py +11 -0
- package/fincli/app/providers/ai/http_provider.py +145 -0
- package/fincli/app/providers/ai/huggingface_provider.py +11 -0
- package/fincli/app/providers/ai/manager.py +60 -0
- package/fincli/app/providers/ai/openai_provider.py +11 -0
- package/fincli/app/providers/ai/openrouter_provider.py +11 -0
- package/fincli/app/providers/ai/together_provider.py +11 -0
- package/fincli/app/providers/market/__init__.py +1 -0
- package/fincli/app/providers/market/base.py +77 -0
- package/fincli/app/providers/market/custom_provider.py +169 -0
- package/fincli/app/providers/market/finnhub_provider.py +187 -0
- package/fincli/app/providers/market/manager.py +123 -0
- package/fincli/app/providers/market/news_provider.py +28 -0
- package/fincli/app/providers/market/symbols.py +182 -0
- package/fincli/app/providers/market/twelvedata_provider.py +167 -0
- package/fincli/app/providers/market/yfinance_provider.py +447 -0
- package/fincli/app/services/__init__.py +1 -0
- package/fincli/app/services/market_data.py +203 -0
- package/fincli/app/services/market_overview.py +111 -0
- package/fincli/app/storage/__init__.py +1 -0
- package/fincli/app/storage/cache.py +38 -0
- package/fincli/app/storage/config.py +114 -0
- package/fincli/app/storage/database.py +101 -0
- package/fincli/app/storage/market_cache.py +92 -0
- package/fincli/app/tui/__init__.py +1 -0
- package/fincli/app/tui/components.py +55 -0
- package/fincli/app/tui/layout.py +261 -0
- package/fincli/app/tui/market_provider_selector.py +267 -0
- package/fincli/app/tui/model_selector.py +412 -0
- package/fincli/app/tui/theme.py +157 -0
- package/fincli/app/utils/__init__.py +1 -0
- package/fincli/app/utils/errors.py +33 -0
- package/fincli/app/utils/formatting.py +17 -0
- package/fincli/app/utils/logger.py +19 -0
- package/npm/bin/fincli.js +35 -0
- package/npm/postinstall.js +72 -0
- package/package.json +23 -0
- package/pyproject.toml +31 -0
- 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
|
+
)
|