@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,261 @@
|
|
|
1
|
+
"""Textual layout for FinCLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from threading import Lock
|
|
7
|
+
|
|
8
|
+
from textual.app import App, ComposeResult, SystemCommand
|
|
9
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
10
|
+
from textual.screen import Screen
|
|
11
|
+
from textual.worker import Worker, WorkerState
|
|
12
|
+
from textual.widgets import Header, Input, RichLog, Static
|
|
13
|
+
|
|
14
|
+
from fincli import __version__
|
|
15
|
+
from fincli.app.cli.autocomplete import SlashAutocomplete
|
|
16
|
+
from fincli.app.cli.commands import CommandRegistry
|
|
17
|
+
from fincli.app.cli.router import CommandResult, CommandRouter
|
|
18
|
+
from fincli.app.providers.ai.manager import AIProviderManager
|
|
19
|
+
from fincli.app.tui.components import CommandPalette, format_ai_message, format_thinking_message, format_user_message
|
|
20
|
+
from fincli.app.tui.market_provider_selector import MarketProviderSelectorScreen
|
|
21
|
+
from fincli.app.tui.model_selector import AIModelSelectorScreen
|
|
22
|
+
from fincli.app.tui.theme import APP_CSS
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FinCLIApp(App[None]):
|
|
26
|
+
"""Modern terminal dashboard for FinCLI v0.1."""
|
|
27
|
+
|
|
28
|
+
CSS = APP_CSS
|
|
29
|
+
TITLE = f"FinCLI v{__version__}"
|
|
30
|
+
SUB_TITLE = "Financial terminal MVP"
|
|
31
|
+
BINDINGS = [
|
|
32
|
+
("ctrl+c", "quit", "Quit"),
|
|
33
|
+
("ctrl+l", "clear_output", "Clear"),
|
|
34
|
+
("f1", "help", "Help"),
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
super().__init__()
|
|
39
|
+
self.registry = CommandRegistry()
|
|
40
|
+
self.autocomplete = SlashAutocomplete(self.registry)
|
|
41
|
+
self.router = CommandRouter(registry=self.registry)
|
|
42
|
+
self._route_lock = Lock()
|
|
43
|
+
self._worker_index = 0
|
|
44
|
+
self._latest_worker_sequence = 0
|
|
45
|
+
self._worker_meta: dict[str, dict[str, str | bool]] = {}
|
|
46
|
+
|
|
47
|
+
def compose(self) -> ComposeResult:
|
|
48
|
+
yield Header(show_clock=True)
|
|
49
|
+
with Vertical(id="workspace"):
|
|
50
|
+
with Vertical(id="main"):
|
|
51
|
+
yield RichLog(id="output", wrap=True, markup=True, highlight=True)
|
|
52
|
+
yield Static("ready | /dashboard | /market AAPL | /provider status", id="status_bar")
|
|
53
|
+
with Vertical(id="command_area"):
|
|
54
|
+
with Horizontal(id="command_line"):
|
|
55
|
+
yield Static("> ", id="command_prompt")
|
|
56
|
+
yield Input(placeholder="/", id="command_input")
|
|
57
|
+
with VerticalScroll(id="command_palette_scroll"):
|
|
58
|
+
yield CommandPalette(id="command_palette")
|
|
59
|
+
|
|
60
|
+
def on_mount(self) -> None:
|
|
61
|
+
palette = self.query_one(CommandPalette)
|
|
62
|
+
palette.clear_palette()
|
|
63
|
+
self.query_one("#command_palette_scroll", VerticalScroll).styles.display = "none"
|
|
64
|
+
output = self.query_one("#output", RichLog)
|
|
65
|
+
output.write("Loading dashboard...")
|
|
66
|
+
self._submit_route("/dashboard", display_raw="/dashboard", clear_output_before_result=True)
|
|
67
|
+
self.query_one("#command_input", Input).focus()
|
|
68
|
+
|
|
69
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
70
|
+
palette = self.query_one(CommandPalette)
|
|
71
|
+
palette_scroll = self.query_one("#command_palette_scroll", VerticalScroll)
|
|
72
|
+
value = event.value.strip()
|
|
73
|
+
if not value.startswith("/"):
|
|
74
|
+
palette.clear_palette()
|
|
75
|
+
palette_scroll.styles.display = "none"
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
suggestions = self.autocomplete.suggestions_for(value)
|
|
79
|
+
palette_scroll.styles.display = "block"
|
|
80
|
+
palette_scroll.scroll_home(animate=False)
|
|
81
|
+
palette.render_commands(suggestions, value)
|
|
82
|
+
|
|
83
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
84
|
+
event.input.value = ""
|
|
85
|
+
palette = self.query_one(CommandPalette)
|
|
86
|
+
palette.clear_palette()
|
|
87
|
+
self.query_one("#command_palette_scroll", VerticalScroll).styles.display = "none"
|
|
88
|
+
raw = event.value.strip()
|
|
89
|
+
output = self.query_one("#output", RichLog)
|
|
90
|
+
status = self.query_one("#status_bar", Static)
|
|
91
|
+
if raw.lower() == "/ai_model":
|
|
92
|
+
self.push_screen(AIModelSelectorScreen(self.router.config, self._set_ai_model_from_selector))
|
|
93
|
+
status.update("selecting ai model | esc to close")
|
|
94
|
+
return
|
|
95
|
+
if raw.lower() == "/news_model":
|
|
96
|
+
self.push_screen(MarketProviderSelectorScreen(self.router.config, self._set_market_provider_from_selector))
|
|
97
|
+
status.update("selecting market/news provider | esc to close")
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
if raw and (not raw.startswith("/") or raw.lower().startswith("/ai ")):
|
|
101
|
+
prompt = raw[4:].strip() if raw.lower().startswith("/ai ") else raw
|
|
102
|
+
self._handle_ai_chat(prompt)
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
if raw.lower() == "/clear":
|
|
106
|
+
self._invalidate_pending_workers()
|
|
107
|
+
output.clear()
|
|
108
|
+
status.update("cleared | /help untuk command")
|
|
109
|
+
return
|
|
110
|
+
if raw.lower() == "/exit":
|
|
111
|
+
self.exit()
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
self._submit_route(raw, display_raw=raw)
|
|
115
|
+
|
|
116
|
+
def action_clear_output(self) -> None:
|
|
117
|
+
self._invalidate_pending_workers()
|
|
118
|
+
self.query_one("#output", RichLog).clear()
|
|
119
|
+
self.query_one("#status_bar", Static).update("cleared | /help untuk command")
|
|
120
|
+
|
|
121
|
+
def action_help(self) -> None:
|
|
122
|
+
output = self.query_one("#output", RichLog)
|
|
123
|
+
output.write(self.router.route("/help").renderable)
|
|
124
|
+
|
|
125
|
+
def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
|
|
126
|
+
"""Return a curated command palette for FinCLI."""
|
|
127
|
+
yield SystemCommand(
|
|
128
|
+
"Keys",
|
|
129
|
+
"Show or hide FinCLI keyboard shortcuts.",
|
|
130
|
+
self._toggle_help_panel,
|
|
131
|
+
)
|
|
132
|
+
yield SystemCommand(
|
|
133
|
+
"Maximize Panel",
|
|
134
|
+
"Maximize the active FinCLI panel.",
|
|
135
|
+
screen.action_maximize,
|
|
136
|
+
)
|
|
137
|
+
yield SystemCommand(
|
|
138
|
+
"Save Screenshot",
|
|
139
|
+
"Save the current FinCLI screen as an SVG file.",
|
|
140
|
+
lambda: self.set_timer(0.1, self.deliver_screenshot),
|
|
141
|
+
)
|
|
142
|
+
yield SystemCommand(
|
|
143
|
+
"Change Theme",
|
|
144
|
+
"Open Textual theme selector for the terminal UI.",
|
|
145
|
+
self.action_change_theme,
|
|
146
|
+
)
|
|
147
|
+
yield SystemCommand(
|
|
148
|
+
"Clear Output",
|
|
149
|
+
"Clear the main output log.",
|
|
150
|
+
self.action_clear_output,
|
|
151
|
+
)
|
|
152
|
+
yield SystemCommand(
|
|
153
|
+
"Quit FinCLI",
|
|
154
|
+
"Exit FinCLI and return to the terminal.",
|
|
155
|
+
self.action_quit,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def _toggle_help_panel(self) -> None:
|
|
159
|
+
if self.screen.query("HelpPanel"):
|
|
160
|
+
self.action_hide_help_panel()
|
|
161
|
+
else:
|
|
162
|
+
self.action_show_help_panel()
|
|
163
|
+
|
|
164
|
+
def _set_ai_model_from_selector(self, provider: str, model: str) -> None:
|
|
165
|
+
self.router.ai_provider = AIProviderManager().create(provider)
|
|
166
|
+
output = self.query_one("#output", RichLog)
|
|
167
|
+
status = self.query_one("#status_bar", Static)
|
|
168
|
+
output.write(f"AI model aktif: {provider} / {model}")
|
|
169
|
+
status.update(f"ready | ai model: {provider}/{model}")
|
|
170
|
+
|
|
171
|
+
def _set_market_provider_from_selector(self, providers: tuple[str, ...]) -> None:
|
|
172
|
+
self.router._refresh_market_service()
|
|
173
|
+
self.router.cache.clear()
|
|
174
|
+
output = self.query_one("#output", RichLog)
|
|
175
|
+
status = self.query_one("#status_bar", Static)
|
|
176
|
+
output.write(f"Provider market/news priority aktif: {', '.join(providers)}")
|
|
177
|
+
status.update(f"ready | market provider: {providers[0] if providers else 'yfinance'}")
|
|
178
|
+
|
|
179
|
+
def _handle_ai_chat(self, prompt: str) -> None:
|
|
180
|
+
output = self.query_one("#output", RichLog)
|
|
181
|
+
status = self.query_one("#status_bar", Static)
|
|
182
|
+
output.write(format_user_message(prompt))
|
|
183
|
+
output.write(format_thinking_message("routing prompt to active AI provider..."))
|
|
184
|
+
self._submit_route(f"/ai {prompt}", display_raw="/ai", chat=True)
|
|
185
|
+
|
|
186
|
+
def _submit_route(
|
|
187
|
+
self,
|
|
188
|
+
raw: str,
|
|
189
|
+
*,
|
|
190
|
+
display_raw: str,
|
|
191
|
+
chat: bool = False,
|
|
192
|
+
clear_output_before_result: bool = False,
|
|
193
|
+
) -> None:
|
|
194
|
+
"""Run a router command without blocking Textual's UI thread."""
|
|
195
|
+
self._worker_index += 1
|
|
196
|
+
worker_name = f"route-{self._worker_index}"
|
|
197
|
+
self._latest_worker_sequence = self._worker_index
|
|
198
|
+
self._worker_meta[worker_name] = {
|
|
199
|
+
"raw": raw,
|
|
200
|
+
"display_raw": display_raw,
|
|
201
|
+
"chat": chat,
|
|
202
|
+
"clear_output_before_result": clear_output_before_result,
|
|
203
|
+
"sequence": str(self._worker_index),
|
|
204
|
+
}
|
|
205
|
+
self.query_one("#status_bar", Static).update(f"running | {display_raw}")
|
|
206
|
+
self.run_worker(
|
|
207
|
+
lambda: self._route_in_worker(raw),
|
|
208
|
+
name=worker_name,
|
|
209
|
+
group="router",
|
|
210
|
+
description=display_raw,
|
|
211
|
+
thread=True,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def _invalidate_pending_workers(self) -> None:
|
|
215
|
+
self._latest_worker_sequence = max(self._latest_worker_sequence, self._worker_index) + 1
|
|
216
|
+
|
|
217
|
+
def _route_in_worker(self, raw: str) -> CommandResult:
|
|
218
|
+
with self._route_lock:
|
|
219
|
+
return self.router.route(raw)
|
|
220
|
+
|
|
221
|
+
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
|
222
|
+
worker = event.worker
|
|
223
|
+
meta = self._worker_meta.get(worker.name or "")
|
|
224
|
+
if meta is None:
|
|
225
|
+
return
|
|
226
|
+
if event.state not in {WorkerState.SUCCESS, WorkerState.ERROR, WorkerState.CANCELLED}:
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
self._worker_meta.pop(worker.name or "", None)
|
|
230
|
+
sequence = int(str(meta.get("sequence", "0")))
|
|
231
|
+
if sequence < self._latest_worker_sequence:
|
|
232
|
+
return
|
|
233
|
+
output = self.query_one("#output", RichLog)
|
|
234
|
+
status = self.query_one("#status_bar", Static)
|
|
235
|
+
display_raw = str(meta["display_raw"])
|
|
236
|
+
|
|
237
|
+
if event.state == WorkerState.CANCELLED:
|
|
238
|
+
status.update(f"cancelled | {display_raw}")
|
|
239
|
+
return
|
|
240
|
+
if event.state == WorkerState.ERROR:
|
|
241
|
+
output.write(f"Error menjalankan {display_raw}: {worker.error}")
|
|
242
|
+
status.update(f"error | {display_raw}")
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
result = worker.result
|
|
246
|
+
if bool(meta.get("clear_output_before_result")):
|
|
247
|
+
output.clear()
|
|
248
|
+
if result.clear:
|
|
249
|
+
output.clear()
|
|
250
|
+
elif result.renderable:
|
|
251
|
+
if bool(meta.get("chat")) and result.status == "ready":
|
|
252
|
+
output.write(format_ai_message(str(result.renderable)))
|
|
253
|
+
else:
|
|
254
|
+
output.write(result.renderable)
|
|
255
|
+
|
|
256
|
+
if bool(meta.get("chat")):
|
|
257
|
+
status.update(f"{result.status} | ai chat")
|
|
258
|
+
else:
|
|
259
|
+
status.update(f"{result.status} | last: {display_raw or 'empty'}")
|
|
260
|
+
if result.should_exit:
|
|
261
|
+
self.exit()
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Interactive market/news provider 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.market.manager import MarketProviderManager
|
|
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 MarketProviderChoice:
|
|
23
|
+
provider: str
|
|
24
|
+
label: str
|
|
25
|
+
env_keys: tuple[str, ...]
|
|
26
|
+
description: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class PriorityChoice:
|
|
31
|
+
label: str
|
|
32
|
+
providers: tuple[str, ...]
|
|
33
|
+
description: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
PROVIDER_LABELS = {
|
|
37
|
+
"yfinance": "Yahoo Finance",
|
|
38
|
+
"custom": "Custom API",
|
|
39
|
+
"finnhub": "Finnhub",
|
|
40
|
+
"twelvedata": "Twelve Data",
|
|
41
|
+
}
|
|
42
|
+
PROVIDER_ENV_KEYS = {
|
|
43
|
+
"yfinance": (),
|
|
44
|
+
"custom": ("MARKET_DATA_API_KEY", "MARKET_DATA_BASE_URL"),
|
|
45
|
+
"finnhub": ("FINNHUB_API_KEY",),
|
|
46
|
+
"twelvedata": ("TWELVE_DATA_API_KEY",),
|
|
47
|
+
}
|
|
48
|
+
DEFAULT_FALLBACK_ORDER = ("twelvedata", "finnhub", "custom", "yfinance")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def market_provider_choices() -> tuple[MarketProviderChoice, ...]:
|
|
52
|
+
"""Return selector choices from the market provider catalog."""
|
|
53
|
+
choices: list[MarketProviderChoice] = []
|
|
54
|
+
for info in MarketProviderManager().list_providers():
|
|
55
|
+
choices.append(
|
|
56
|
+
MarketProviderChoice(
|
|
57
|
+
provider=info.name,
|
|
58
|
+
label=PROVIDER_LABELS.get(info.name, info.name.title()),
|
|
59
|
+
env_keys=PROVIDER_ENV_KEYS.get(info.name, ()),
|
|
60
|
+
description=f"{info.status}; realtime={info.realtime}; {info.notes}",
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
return tuple(choices)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def recommended_provider_priority(primary: str) -> tuple[str, ...]:
|
|
67
|
+
"""Build a conservative fallback chain with yfinance as final fallback."""
|
|
68
|
+
normalized = primary.lower().strip()
|
|
69
|
+
if normalized == "yfinance":
|
|
70
|
+
return ("yfinance",)
|
|
71
|
+
priority = [normalized] if normalized else ["yfinance"]
|
|
72
|
+
for provider in DEFAULT_FALLBACK_ORDER:
|
|
73
|
+
if provider != "yfinance" and provider not in priority:
|
|
74
|
+
priority.append(provider)
|
|
75
|
+
if "yfinance" not in priority:
|
|
76
|
+
priority.append("yfinance")
|
|
77
|
+
return tuple(priority)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def priority_presets(primary: str) -> tuple[PriorityChoice, ...]:
|
|
81
|
+
"""Return selectable fallback presets for a primary provider."""
|
|
82
|
+
recommended = recommended_provider_priority(primary)
|
|
83
|
+
minimal = tuple(dict.fromkeys((primary.lower(), "yfinance")))
|
|
84
|
+
all_data_first = tuple(provider for provider in DEFAULT_FALLBACK_ORDER if provider in recommended)
|
|
85
|
+
return (
|
|
86
|
+
PriorityChoice("Recommended fallback", recommended, "Primary provider first, then other providers, yfinance last."),
|
|
87
|
+
PriorityChoice("Primary + yfinance", minimal, "Use selected provider and delayed yfinance fallback only."),
|
|
88
|
+
PriorityChoice("Data API priority", all_data_first, "Try realtime-capable providers before yfinance."),
|
|
89
|
+
PriorityChoice("YFinance only", ("yfinance",), "Delayed fallback only, no API key required."),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class MarketProviderSelectorScreen(ModalScreen[tuple[str, ...] | None]):
|
|
94
|
+
"""Modal selector for market/news provider and fallback priority."""
|
|
95
|
+
|
|
96
|
+
BINDINGS = [
|
|
97
|
+
("escape", "cancel", "Cancel"),
|
|
98
|
+
("tab", "change_provider", "Provider"),
|
|
99
|
+
("up", "cursor_up", "Up"),
|
|
100
|
+
("down", "cursor_down", "Down"),
|
|
101
|
+
("enter", "select", "Select"),
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
def __init__(self, config: ConfigManager, on_selected: Callable[[tuple[str, ...]], None]) -> None:
|
|
105
|
+
super().__init__()
|
|
106
|
+
self.config = config
|
|
107
|
+
self.on_selected = on_selected
|
|
108
|
+
self.mode = "provider"
|
|
109
|
+
self.selected_index = 0
|
|
110
|
+
self.selected_provider = config.settings.market_provider
|
|
111
|
+
self.search = ""
|
|
112
|
+
|
|
113
|
+
def compose(self) -> ComposeResult:
|
|
114
|
+
with Vertical(id="ai_selector_card"):
|
|
115
|
+
yield Static(id="ai_selector_title")
|
|
116
|
+
yield Static(id="ai_selector_provider")
|
|
117
|
+
yield Input(placeholder="Search providers...", id="ai_selector_search")
|
|
118
|
+
with VerticalScroll(id="ai_selector_scroll"):
|
|
119
|
+
yield Static(id="ai_selector_list")
|
|
120
|
+
yield Static(id="ai_selector_help")
|
|
121
|
+
|
|
122
|
+
def on_mount(self) -> None:
|
|
123
|
+
self._sync_search_placeholder()
|
|
124
|
+
self._render_selector()
|
|
125
|
+
self.query_one("#ai_selector_search", Input).focus()
|
|
126
|
+
|
|
127
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
128
|
+
self.search = event.value.strip().lower()
|
|
129
|
+
self.selected_index = 0
|
|
130
|
+
self._render_selector()
|
|
131
|
+
|
|
132
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
133
|
+
event.stop()
|
|
134
|
+
self.action_select()
|
|
135
|
+
|
|
136
|
+
def on_key(self, event: Key) -> None:
|
|
137
|
+
if event.key == "up":
|
|
138
|
+
event.stop()
|
|
139
|
+
self.action_cursor_up()
|
|
140
|
+
elif event.key == "down":
|
|
141
|
+
event.stop()
|
|
142
|
+
self.action_cursor_down()
|
|
143
|
+
elif event.key == "tab":
|
|
144
|
+
event.stop()
|
|
145
|
+
self.action_change_provider()
|
|
146
|
+
elif event.key == "escape":
|
|
147
|
+
event.stop()
|
|
148
|
+
self.action_cancel()
|
|
149
|
+
|
|
150
|
+
def action_cursor_up(self) -> None:
|
|
151
|
+
total = len(self._visible_items())
|
|
152
|
+
if total:
|
|
153
|
+
self.selected_index = (self.selected_index - 1) % total
|
|
154
|
+
self._render_selector()
|
|
155
|
+
|
|
156
|
+
def action_cursor_down(self) -> None:
|
|
157
|
+
total = len(self._visible_items())
|
|
158
|
+
if total:
|
|
159
|
+
self.selected_index = (self.selected_index + 1) % total
|
|
160
|
+
self._render_selector()
|
|
161
|
+
|
|
162
|
+
def action_change_provider(self) -> None:
|
|
163
|
+
self._set_mode("provider")
|
|
164
|
+
|
|
165
|
+
def action_select(self) -> None:
|
|
166
|
+
items = self._visible_items()
|
|
167
|
+
if not items:
|
|
168
|
+
return
|
|
169
|
+
selected = items[self.selected_index]
|
|
170
|
+
if self.mode == "provider":
|
|
171
|
+
provider = selected.provider # type: ignore[attr-defined]
|
|
172
|
+
self.selected_provider = provider
|
|
173
|
+
self._set_mode("priority")
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
providers = selected.providers # type: ignore[attr-defined]
|
|
177
|
+
self.config.set_market_provider_priority(list(providers))
|
|
178
|
+
self.on_selected(providers)
|
|
179
|
+
self.dismiss(providers)
|
|
180
|
+
|
|
181
|
+
def action_cancel(self) -> None:
|
|
182
|
+
self.dismiss(None)
|
|
183
|
+
|
|
184
|
+
def _set_mode(self, mode: str) -> None:
|
|
185
|
+
self.mode = mode
|
|
186
|
+
self.selected_index = 0
|
|
187
|
+
self.search = ""
|
|
188
|
+
self.query_one("#ai_selector_search", Input).value = ""
|
|
189
|
+
self._sync_search_placeholder()
|
|
190
|
+
self._render_selector()
|
|
191
|
+
|
|
192
|
+
def _sync_search_placeholder(self) -> None:
|
|
193
|
+
search = self.query_one("#ai_selector_search", Input)
|
|
194
|
+
search.placeholder = "Search fallback presets..." if self.mode == "priority" else "Search providers..."
|
|
195
|
+
|
|
196
|
+
def _visible_items(self) -> list[MarketProviderChoice] | list[PriorityChoice]:
|
|
197
|
+
if self.mode == "priority":
|
|
198
|
+
presets = list(priority_presets(self.selected_provider))
|
|
199
|
+
if self.search:
|
|
200
|
+
presets = [
|
|
201
|
+
preset
|
|
202
|
+
for preset in presets
|
|
203
|
+
if self.search in preset.label.lower() or self.search in ",".join(preset.providers)
|
|
204
|
+
]
|
|
205
|
+
return presets
|
|
206
|
+
|
|
207
|
+
choices = list(market_provider_choices())
|
|
208
|
+
if self.search:
|
|
209
|
+
choices = [
|
|
210
|
+
choice
|
|
211
|
+
for choice in choices
|
|
212
|
+
if self.search in choice.label.lower() or self.search in choice.provider or self.search in choice.description.lower()
|
|
213
|
+
]
|
|
214
|
+
return choices
|
|
215
|
+
|
|
216
|
+
def _render_selector(self) -> None:
|
|
217
|
+
title = self.query_one("#ai_selector_title", Static)
|
|
218
|
+
provider = self.query_one("#ai_selector_provider", Static)
|
|
219
|
+
body = self.query_one("#ai_selector_list", Static)
|
|
220
|
+
help_text = self.query_one("#ai_selector_help", Static)
|
|
221
|
+
|
|
222
|
+
if self.mode == "priority":
|
|
223
|
+
title.update("Select Provider Priority")
|
|
224
|
+
provider.update(f"[cyan]Primary:[/] {self.selected_provider} [dim](tab to change provider)[/]")
|
|
225
|
+
else:
|
|
226
|
+
title.update("Select Market/News Provider")
|
|
227
|
+
provider.update("")
|
|
228
|
+
|
|
229
|
+
body.update(self._items_text(self._visible_items()))
|
|
230
|
+
help_text.update(self._help_text())
|
|
231
|
+
|
|
232
|
+
def _items_text(self, items: list[MarketProviderChoice] | list[PriorityChoice]) -> Text:
|
|
233
|
+
text = Text()
|
|
234
|
+
text.append("Providers\n" if self.mode == "provider" else "Fallback presets\n", style="bold dim")
|
|
235
|
+
for index, item in enumerate(items):
|
|
236
|
+
selected = index == self.selected_index
|
|
237
|
+
prefix = "> " if selected else " "
|
|
238
|
+
style = "black on cyan" if selected else "white"
|
|
239
|
+
if isinstance(item, MarketProviderChoice):
|
|
240
|
+
current = " * (current)" if item.provider == self.config.settings.market_provider else ""
|
|
241
|
+
key_status = _provider_key_status(item)
|
|
242
|
+
line = f"{prefix}{item.label}{current} {key_status}\n"
|
|
243
|
+
detail = f" {item.description}\n"
|
|
244
|
+
else:
|
|
245
|
+
chain = " -> ".join(item.providers)
|
|
246
|
+
line = f"{prefix}{item.label}: {chain}\n"
|
|
247
|
+
detail = f" {item.description}\n"
|
|
248
|
+
text.append(line, style=style)
|
|
249
|
+
text.append(detail, style="dim" if not selected else "black on cyan")
|
|
250
|
+
if not items:
|
|
251
|
+
text.append("No matches.\n", style="dim")
|
|
252
|
+
return text
|
|
253
|
+
|
|
254
|
+
def _help_text(self) -> str:
|
|
255
|
+
if self.mode == "priority":
|
|
256
|
+
return "Type to search, up/down navigate, Enter to save priority, Tab to change provider, Esc to close"
|
|
257
|
+
return "Type to search, up/down navigate, Enter to select provider, Esc to close"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _provider_key_status(choice: MarketProviderChoice) -> str:
|
|
261
|
+
if not choice.env_keys:
|
|
262
|
+
return "(no key required)"
|
|
263
|
+
statuses = []
|
|
264
|
+
for key in choice.env_keys:
|
|
265
|
+
masked = mask_secret(os.getenv(key))
|
|
266
|
+
statuses.append(f"{key}={masked}")
|
|
267
|
+
return f"({', '.join(statuses)})"
|