@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,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)})"