@drico2008/fincli 0.1.9 → 0.2.2

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 (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +909 -718
  3. package/fincli/__init__.py +3 -3
  4. package/fincli/app/agents/__init__.py +5 -0
  5. package/fincli/app/agents/registry.py +76 -0
  6. package/fincli/app/analysis/ai_prompts.py +23 -16
  7. package/fincli/app/analysis/analyzer.py +107 -100
  8. package/fincli/app/analysis/assistant_context.py +187 -186
  9. package/fincli/app/analysis/backtest.py +179 -0
  10. package/fincli/app/analysis/gameplay_plan.py +79 -0
  11. package/fincli/app/analysis/multi_timeframe.py +180 -0
  12. package/fincli/app/analysis/trading_methods.py +144 -0
  13. package/fincli/app/cli/commands.py +105 -83
  14. package/fincli/app/cli/router.py +2123 -1294
  15. package/fincli/app/connectors/__init__.py +5 -0
  16. package/fincli/app/connectors/catalog.py +148 -0
  17. package/fincli/app/connectors/news_connectors.py +412 -0
  18. package/fincli/app/modules/alerts.py +80 -0
  19. package/fincli/app/modules/economic_calendar.py +374 -1
  20. package/fincli/app/modules/reports.py +151 -0
  21. package/fincli/app/modules/scanner.py +111 -93
  22. package/fincli/app/modules/transactions.py +84 -84
  23. package/fincli/app/modules/user_profile.py +84 -0
  24. package/fincli/app/plugins/loader.py +72 -0
  25. package/fincli/app/providers/ai/manager.py +60 -60
  26. package/fincli/app/providers/market/alphavantage_provider.py +194 -0
  27. package/fincli/app/providers/market/base.py +98 -77
  28. package/fincli/app/providers/market/custom_provider.py +186 -169
  29. package/fincli/app/providers/market/manager.py +84 -1
  30. package/fincli/app/providers/market/symbols.py +143 -0
  31. package/fincli/app/providers/market/twelvedata_provider.py +167 -167
  32. package/fincli/app/research/__init__.py +7 -0
  33. package/fincli/app/research/engine.py +75 -0
  34. package/fincli/app/research/formatter.py +22 -0
  35. package/fincli/app/research/models.py +18 -0
  36. package/fincli/app/research/prompt_builder.py +47 -0
  37. package/fincli/app/services/macro_data.py +50 -0
  38. package/fincli/app/services/market_data.py +203 -203
  39. package/fincli/app/services/news_aggregator.py +90 -0
  40. package/fincli/app/services/web_research.py +267 -267
  41. package/fincli/app/storage/config.py +122 -88
  42. package/fincli/app/storage/database.py +200 -101
  43. package/fincli/app/storage/secrets.py +8 -2
  44. package/fincli/app/tui/components.py +68 -50
  45. package/fincli/app/tui/layout.py +269 -258
  46. package/fincli/app/tui/market_provider_selector.py +3 -1
  47. package/fincli/app/tui/theme.py +134 -74
  48. package/fincli/app/utils/formatting.py +123 -60
  49. package/package.json +23 -23
  50. package/pyproject.toml +35 -35
@@ -1,55 +1,73 @@
1
- """Reusable TUI components."""
2
-
3
- from __future__ import annotations
4
-
5
- from rich.markdown import Markdown
6
- from rich.panel import Panel
7
- from rich.table import Table
8
- from rich.text import Text
1
+ """Reusable TUI components."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.markdown import Markdown
6
+ from rich.panel import Panel
7
+ from rich.table import Table
8
+ from rich.text import Text
9
9
  from textual.widgets import Static
10
-
11
- from fincli.app.cli.commands import CommandSpec
12
-
13
-
14
- class CommandPalette(Static):
15
- """Slash command palette shown near the command input."""
16
-
17
- def render_commands(self, commands: list[CommandSpec], query: str = "") -> None:
18
- table = Table.grid(expand=True)
19
- table.add_column("Command", style="white", no_wrap=True, ratio=1)
20
- table.add_column("Description", style="bright_black", justify="right", ratio=3)
21
-
22
- for index, command in enumerate(commands):
23
- command_text = command.name
24
- description = command.description
25
- if index == 0:
26
- command_text = f"[black on cyan]> {command.name}[/]"
27
- description = f"[black on cyan]{command.description}[/]"
28
- table.add_row(command_text, description)
29
-
30
- if len(commands) > 6:
31
- table.add_row("[bright_black]v more[/]", "[bright_black]Ketik command lebih spesifik[/]")
32
-
33
- title = f"[cyan]>[/] {query or '/'}"
34
- self.update(Panel(table, title=title, border_style="bright_black", padding=(0, 1)))
35
-
36
- def clear_palette(self) -> None:
37
- self.update("")
38
-
39
-
40
- def format_user_message(message: str) -> Panel:
41
- text = Text()
42
- text.append("> ", style="bold cyan")
43
- text.append(message, style="bold white")
44
- return Panel(text, border_style="#2f332f", style="on #2b2f2b", padding=(0, 1))
10
+
11
+ from fincli.app.cli.commands import CommandSpec
12
+
13
+
14
+ class CommandPalette(Static):
15
+ """Slash command palette shown near the command input."""
16
+
17
+ def render_commands(self, commands: list[CommandSpec], query: str = "") -> None:
18
+ table = Table.grid(expand=True)
19
+ table.add_column("Command", style="white", no_wrap=True, ratio=1)
20
+ table.add_column("Description", style="bright_black", justify="right", ratio=3)
21
+
22
+ for index, command in enumerate(commands):
23
+ command_text = command.name
24
+ description = command.description
25
+ if index == 0:
26
+ command_text = f"[black on cyan]> {command.name}[/]"
27
+ description = f"[black on cyan]{command.description}[/]"
28
+ table.add_row(command_text, description)
29
+
30
+ if len(commands) > 6:
31
+ table.add_row("[bright_black]v more[/]", "[bright_black]Ketik command lebih spesifik[/]")
32
+
33
+ title = f"[cyan]>[/] {query or '/'}"
34
+ self.update(Panel(table, title=title, border_style="bright_black", padding=(0, 1)))
35
+
36
+ def clear_palette(self) -> None:
37
+ self.update("")
38
+
39
+
40
+ def format_user_message(message: str) -> Panel:
41
+ text = Text()
42
+ text.append("> ", style="bold cyan")
43
+ text.append(message, style="bold white")
44
+ return Panel(text, border_style="#2f332f", style="on #2b2f2b", padding=(0, 1))
45
+
46
+
47
+ def format_thinking_message(message: str) -> Text:
48
+ text = Text()
49
+ text.append("> Thinking: ", style="dim")
50
+ text.append(message, style="italic dim")
51
+ return text
52
+
53
+
54
+ def format_ai_message(message: str) -> Markdown:
55
+ return Markdown(message)
45
56
 
46
57
 
47
- def format_thinking_message(message: str) -> Text:
48
- text = Text()
49
- text.append("> Thinking: ", style="dim")
50
- text.append(message, style="italic dim")
51
- return text
58
+ def write_output_entry(log: object, renderable: object) -> None:
59
+ """Write one output entry with a single blank line separator.
52
60
 
61
+ No visual barrier characters are emitted here; Rich/Textual renderables keep
62
+ their own borders if they need one.
63
+ """
53
64
 
54
- def format_ai_message(message: str) -> Markdown:
55
- return Markdown(message)
65
+ items = getattr(log, "items", None)
66
+ if isinstance(items, list) and items:
67
+ log.write("")
68
+ log.write(renderable)
69
+ return
70
+ line_count = getattr(log, "line_count", 0)
71
+ if isinstance(line_count, int) and line_count > 0:
72
+ log.write("")
73
+ log.write(renderable)
@@ -1,262 +1,273 @@
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.css.query import NoMatches
11
- from textual.screen import Screen
12
- from textual.worker import Worker, WorkerState
13
- from textual.widgets import Header, Input, RichLog, Static
14
-
15
- from fincli import __version__
16
- from fincli.app.cli.autocomplete import SlashAutocomplete
17
- from fincli.app.cli.commands import CommandRegistry
18
- from fincli.app.cli.router import CommandResult, CommandRouter
19
- from fincli.app.providers.ai.manager import AIProviderManager
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.css.query import NoMatches
11
+ from textual.screen import Screen
12
+ from textual.worker import Worker, WorkerState
13
+ from textual.widgets import Header, Input, RichLog, Static
14
+
15
+ from fincli import __version__
16
+ from fincli.app.cli.autocomplete import SlashAutocomplete
17
+ from fincli.app.cli.commands import CommandRegistry
18
+ from fincli.app.cli.router import CommandResult, CommandRouter
19
+ from fincli.app.providers.ai.manager import AIProviderManager
20
20
  from fincli.app.tui.components import CommandPalette, format_thinking_message, format_user_message
21
- from fincli.app.tui.market_provider_selector import MarketProviderSelectorScreen
22
- from fincli.app.tui.model_selector import AIModelSelectorScreen
23
- from fincli.app.tui.theme import APP_CSS
24
-
25
-
26
- class FinCLIApp(App[None]):
27
- """Modern terminal dashboard for FinCLI v0.1."""
28
-
29
- CSS = APP_CSS
30
- TITLE = f"FinCLI v{__version__}"
31
- SUB_TITLE = "Financial terminal MVP"
32
- BINDINGS = [
33
- ("ctrl+c", "quit", "Quit"),
34
- ("ctrl+l", "clear_output", "Clear"),
35
- ("f1", "help", "Help"),
36
- ]
37
-
38
- def __init__(self) -> None:
39
- super().__init__()
40
- self.registry = CommandRegistry()
41
- self.autocomplete = SlashAutocomplete(self.registry)
42
- self.router = CommandRouter(registry=self.registry)
43
- self._route_lock = Lock()
44
- self._worker_index = 0
45
- self._latest_worker_sequence = 0
46
- self._worker_meta: dict[str, dict[str, str | bool]] = {}
47
-
48
- def compose(self) -> ComposeResult:
49
- yield Header(show_clock=True)
50
- with Vertical(id="workspace"):
51
- with Vertical(id="main"):
21
+ from fincli.app.tui.components import write_output_entry
22
+ from fincli.app.tui.market_provider_selector import MarketProviderSelectorScreen
23
+ from fincli.app.tui.model_selector import AIModelSelectorScreen
24
+ from fincli.app.tui.theme import APP_CSS
25
+
26
+
27
+ class FinCLIApp(App[None]):
28
+ """Modern terminal dashboard for FinCLI v0.1."""
29
+
30
+ CSS = APP_CSS
31
+ TITLE = f"FinCLI v{__version__}"
32
+ SUB_TITLE = "Financial terminal MVP"
33
+ BINDINGS = [
34
+ ("ctrl+c", "quit", "Quit"),
35
+ ("ctrl+l", "clear_output", "Clear"),
36
+ ("f1", "help", "Help"),
37
+ ]
38
+
39
+ def __init__(self) -> None:
40
+ super().__init__()
41
+ self.registry = CommandRegistry()
42
+ self.autocomplete = SlashAutocomplete(self.registry)
43
+ self.router = CommandRouter(registry=self.registry)
44
+ self._route_lock = Lock()
45
+ self._worker_index = 0
46
+ self._latest_worker_sequence = 0
47
+ self._worker_meta: dict[str, dict[str, str | bool]] = {}
48
+
49
+ def compose(self) -> ComposeResult:
50
+ yield Header(show_clock=True)
51
+ with Vertical(id="workspace"):
52
+ yield Static(
53
+ f"FINCLI v{__version__} | PROFESSIONAL RESEARCH TERMINAL | LIVE WORKSPACE",
54
+ id="top_strip",
55
+ )
56
+ yield Static(
57
+ "MARKET DESK /research /news /macro ANALYSIS /technical /analyze /mtf RISK /profile /portfolio /journal",
58
+ id="market_ribbon",
59
+ )
60
+ yield Static("OUTPUT STREAM | green=bullish/positive | red=bearish/negative | yellow=caution/wait", id="output_header")
61
+ with Vertical(id="output_frame"):
52
62
  yield RichLog(id="output", wrap=True, markup=True, highlight=True)
53
- yield Static("ready | /dashboard | /market AAPL | /provider status", id="status_bar")
63
+ yield Static("ready | /research AAPL --quick | /analyze XAUUSD | /provider status", id="status_bar")
54
64
  with Vertical(id="command_area"):
65
+ yield Static("Type a question for AI chat or use slash commands. Start with / for autocomplete.", id="command_hint")
55
66
  with Horizontal(id="command_line"):
56
- yield Static("> ", id="command_prompt")
57
- yield Input(placeholder="/", id="command_input")
58
- with VerticalScroll(id="command_palette_scroll"):
59
- yield CommandPalette(id="command_palette")
60
-
61
- def on_mount(self) -> None:
62
- palette = self.query_one(CommandPalette)
63
- palette.clear_palette()
64
- self.query_one("#command_palette_scroll", VerticalScroll).styles.display = "none"
65
- output = self.query_one("#output", RichLog)
66
- output.write("Loading dashboard...")
67
- self._submit_route("/dashboard", display_raw="/dashboard", clear_output_before_result=True)
68
- self.query_one("#command_input", Input).focus()
69
-
70
- def on_input_changed(self, event: Input.Changed) -> None:
71
- palette = self.query_one(CommandPalette)
72
- palette_scroll = self.query_one("#command_palette_scroll", VerticalScroll)
73
- value = event.value.strip()
74
- if not value.startswith("/"):
75
- palette.clear_palette()
76
- palette_scroll.styles.display = "none"
77
- return
78
-
79
- suggestions = self.autocomplete.suggestions_for(value)
80
- palette_scroll.styles.display = "block"
81
- palette_scroll.scroll_home(animate=False)
82
- palette.render_commands(suggestions, value)
83
-
84
- def on_input_submitted(self, event: Input.Submitted) -> None:
85
- event.input.value = ""
86
- palette = self.query_one(CommandPalette)
87
- palette.clear_palette()
88
- self.query_one("#command_palette_scroll", VerticalScroll).styles.display = "none"
89
- raw = event.value.strip()
90
- output = self.query_one("#output", RichLog)
91
- status = self.query_one("#status_bar", Static)
92
- if raw.lower() == "/ai_model":
93
- self.push_screen(AIModelSelectorScreen(self.router.config, self._set_ai_model_from_selector))
94
- status.update("selecting ai model | esc to close")
95
- return
96
- if raw.lower() == "/news_model":
97
- self.push_screen(MarketProviderSelectorScreen(self.router.config, self._set_market_provider_from_selector))
98
- status.update("selecting market/news provider | esc to close")
99
- return
100
-
101
- if raw and (not raw.startswith("/") or raw.lower().startswith("/ai ")):
102
- prompt = raw[4:].strip() if raw.lower().startswith("/ai ") else raw
103
- self._handle_ai_chat(prompt)
104
- return
105
-
106
- if raw.lower() == "/clear":
107
- self._invalidate_pending_workers()
108
- output.clear()
109
- status.update("cleared | /help untuk command")
110
- return
111
- if raw.lower() == "/exit":
112
- self.exit()
113
- return
114
-
115
- self._submit_route(raw, display_raw=raw)
116
-
117
- def action_clear_output(self) -> None:
118
- self._invalidate_pending_workers()
119
- self.query_one("#output", RichLog).clear()
120
- self.query_one("#status_bar", Static).update("cleared | /help untuk command")
121
-
122
- def action_help(self) -> None:
123
- output = self.query_one("#output", RichLog)
124
- output.write(self.router.route("/help").renderable)
125
-
126
- def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
127
- """Return a curated command palette for FinCLI."""
128
- yield SystemCommand(
129
- "Keys",
130
- "Show or hide FinCLI keyboard shortcuts.",
131
- self._toggle_help_panel,
132
- )
133
- yield SystemCommand(
134
- "Maximize Panel",
135
- "Maximize the active FinCLI panel.",
136
- screen.action_maximize,
137
- )
138
- yield SystemCommand(
139
- "Save Screenshot",
140
- "Save the current FinCLI screen as an SVG file.",
141
- lambda: self.set_timer(0.1, self.deliver_screenshot),
142
- )
143
- yield SystemCommand(
144
- "Change Theme",
145
- "Open Textual theme selector for the terminal UI.",
146
- self.action_change_theme,
147
- )
148
- yield SystemCommand(
149
- "Clear Output",
150
- "Clear the main output log.",
151
- self.action_clear_output,
152
- )
153
- yield SystemCommand(
154
- "Quit FinCLI",
155
- "Exit FinCLI and return to the terminal.",
156
- self.action_quit,
157
- )
158
-
159
- def _toggle_help_panel(self) -> None:
160
- if self.screen.query("HelpPanel"):
161
- self.action_hide_help_panel()
162
- else:
163
- self.action_show_help_panel()
164
-
165
- def _set_ai_model_from_selector(self, provider: str, model: str) -> None:
166
- self.router.ai_provider = AIProviderManager().create(provider)
167
- output = self.query_one("#output", RichLog)
168
- status = self.query_one("#status_bar", Static)
169
- output.write(f"AI model aktif: {provider} / {model}")
170
- status.update(f"ready | ai model: {provider}/{model}")
171
-
172
- def _set_market_provider_from_selector(self, providers: tuple[str, ...]) -> None:
173
- self.router._refresh_market_service()
174
- self.router.cache.clear()
175
- output = self.query_one("#output", RichLog)
176
- status = self.query_one("#status_bar", Static)
177
- output.write(f"Provider market/news priority aktif: {', '.join(providers)}")
178
- status.update(f"ready | market provider: {providers[0] if providers else 'yfinance'}")
179
-
180
- def _handle_ai_chat(self, prompt: str) -> None:
181
- output = self.query_one("#output", RichLog)
182
- status = self.query_one("#status_bar", Static)
183
- output.write(format_user_message(prompt))
184
- output.write(format_thinking_message("routing prompt to active AI provider..."))
185
- self._submit_route(f"/ai {prompt}", display_raw="/ai", chat=True)
186
-
187
- def _submit_route(
188
- self,
189
- raw: str,
190
- *,
191
- display_raw: str,
192
- chat: bool = False,
193
- clear_output_before_result: bool = False,
194
- ) -> None:
195
- """Run a router command without blocking Textual's UI thread."""
196
- self._worker_index += 1
197
- worker_name = f"route-{self._worker_index}"
198
- self._latest_worker_sequence = self._worker_index
199
- self._worker_meta[worker_name] = {
200
- "raw": raw,
201
- "display_raw": display_raw,
202
- "chat": chat,
203
- "clear_output_before_result": clear_output_before_result,
204
- "sequence": str(self._worker_index),
205
- }
206
- self.query_one("#status_bar", Static).update(f"running | {display_raw}")
207
- self.run_worker(
208
- lambda: self._route_in_worker(raw),
209
- name=worker_name,
210
- group="router",
211
- description=display_raw,
212
- thread=True,
213
- )
214
-
215
- def _invalidate_pending_workers(self) -> None:
216
- self._latest_worker_sequence = max(self._latest_worker_sequence, self._worker_index) + 1
217
-
218
- def _route_in_worker(self, raw: str) -> CommandResult:
219
- with self._route_lock:
220
- return self.router.route(raw)
221
-
222
- def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
223
- worker = event.worker
224
- meta = self._worker_meta.get(worker.name or "")
225
- if meta is None:
226
- return
227
- if event.state not in {WorkerState.SUCCESS, WorkerState.ERROR, WorkerState.CANCELLED}:
228
- return
229
-
230
- self._worker_meta.pop(worker.name or "", None)
231
- sequence = int(str(meta.get("sequence", "0")))
232
- if sequence < self._latest_worker_sequence:
233
- return
234
- try:
235
- output = self.query_one("#output", RichLog)
236
- status = self.query_one("#status_bar", Static)
237
- except NoMatches:
238
- return
239
- display_raw = str(meta["display_raw"])
240
-
241
- if event.state == WorkerState.CANCELLED:
242
- status.update(f"cancelled | {display_raw}")
243
- return
244
- if event.state == WorkerState.ERROR:
245
- output.write(f"Error menjalankan {display_raw}: {worker.error}")
246
- status.update(f"error | {display_raw}")
247
- return
248
-
249
- result = worker.result
250
- if bool(meta.get("clear_output_before_result")):
251
- output.clear()
252
- if result.clear:
253
- output.clear()
254
- elif result.renderable:
255
- output.write(result.renderable)
256
-
257
- if bool(meta.get("chat")):
258
- status.update(f"{result.status} | ai chat")
259
- else:
260
- status.update(f"{result.status} | last: {display_raw or 'empty'}")
261
- if result.should_exit:
262
- self.exit()
67
+ yield Static("F> ", id="command_prompt")
68
+ yield Input(placeholder="Ask FinCLI or type /help", id="command_input")
69
+ with VerticalScroll(id="command_palette_scroll"):
70
+ yield CommandPalette(id="command_palette")
71
+
72
+ def on_mount(self) -> None:
73
+ palette = self.query_one(CommandPalette)
74
+ palette.clear_palette()
75
+ self.query_one("#command_palette_scroll", VerticalScroll).styles.display = "none"
76
+ output = self.query_one("#output", RichLog)
77
+ write_output_entry(output, "Loading dashboard...")
78
+ self._submit_route("/dashboard", display_raw="/dashboard", clear_output_before_result=True)
79
+ self.query_one("#command_input", Input).focus()
80
+
81
+ def on_input_changed(self, event: Input.Changed) -> None:
82
+ palette = self.query_one(CommandPalette)
83
+ palette_scroll = self.query_one("#command_palette_scroll", VerticalScroll)
84
+ value = event.value.strip()
85
+ if not value.startswith("/"):
86
+ palette.clear_palette()
87
+ palette_scroll.styles.display = "none"
88
+ return
89
+
90
+ suggestions = self.autocomplete.suggestions_for(value)
91
+ palette_scroll.styles.display = "block"
92
+ palette_scroll.scroll_home(animate=False)
93
+ palette.render_commands(suggestions, value)
94
+
95
+ def on_input_submitted(self, event: Input.Submitted) -> None:
96
+ event.input.value = ""
97
+ palette = self.query_one(CommandPalette)
98
+ palette.clear_palette()
99
+ self.query_one("#command_palette_scroll", VerticalScroll).styles.display = "none"
100
+ raw = event.value.strip()
101
+ output = self.query_one("#output", RichLog)
102
+ status = self.query_one("#status_bar", Static)
103
+ if raw.lower() == "/ai_model":
104
+ self.push_screen(AIModelSelectorScreen(self.router.config, self._set_ai_model_from_selector))
105
+ status.update("selecting ai model | esc to close")
106
+ return
107
+ if raw.lower() == "/news_model":
108
+ self.push_screen(MarketProviderSelectorScreen(self.router.config, self._set_market_provider_from_selector))
109
+ status.update("selecting market/news provider | esc to close")
110
+ return
111
+
112
+ if raw and (not raw.startswith("/") or raw.lower().startswith("/ai ")):
113
+ prompt = raw[4:].strip() if raw.lower().startswith("/ai ") else raw
114
+ self._handle_ai_chat(prompt)
115
+ return
116
+
117
+ if raw.lower() == "/clear":
118
+ self._invalidate_pending_workers()
119
+ output.clear()
120
+ status.update("cleared | /help untuk command")
121
+ return
122
+ if raw.lower() == "/exit":
123
+ self.exit()
124
+ return
125
+
126
+ self._submit_route(raw, display_raw=raw)
127
+
128
+ def action_clear_output(self) -> None:
129
+ self._invalidate_pending_workers()
130
+ self.query_one("#output", RichLog).clear()
131
+ self.query_one("#status_bar", Static).update("cleared | /help untuk command")
132
+
133
+ def action_help(self) -> None:
134
+ output = self.query_one("#output", RichLog)
135
+ write_output_entry(output, self.router.route("/help").renderable)
136
+
137
+ def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
138
+ """Return a curated command palette for FinCLI."""
139
+ yield SystemCommand(
140
+ "Keys",
141
+ "Show or hide FinCLI keyboard shortcuts.",
142
+ self._toggle_help_panel,
143
+ )
144
+ yield SystemCommand(
145
+ "Maximize Panel",
146
+ "Maximize the active FinCLI panel.",
147
+ screen.action_maximize,
148
+ )
149
+ yield SystemCommand(
150
+ "Save Screenshot",
151
+ "Save the current FinCLI screen as an SVG file.",
152
+ lambda: self.set_timer(0.1, self.deliver_screenshot),
153
+ )
154
+ yield SystemCommand(
155
+ "Change Theme",
156
+ "Open Textual theme selector for the terminal UI.",
157
+ self.action_change_theme,
158
+ )
159
+ yield SystemCommand(
160
+ "Clear Output",
161
+ "Clear the main output log.",
162
+ self.action_clear_output,
163
+ )
164
+ yield SystemCommand(
165
+ "Quit FinCLI",
166
+ "Exit FinCLI and return to the terminal.",
167
+ self.action_quit,
168
+ )
169
+
170
+ def _toggle_help_panel(self) -> None:
171
+ if self.screen.query("HelpPanel"):
172
+ self.action_hide_help_panel()
173
+ else:
174
+ self.action_show_help_panel()
175
+
176
+ def _set_ai_model_from_selector(self, provider: str, model: str) -> None:
177
+ self.router.ai_provider = AIProviderManager().create(provider)
178
+ output = self.query_one("#output", RichLog)
179
+ status = self.query_one("#status_bar", Static)
180
+ write_output_entry(output, f"AI model aktif: {provider} / {model}")
181
+ status.update(f"ready | ai model: {provider}/{model}")
182
+
183
+ def _set_market_provider_from_selector(self, providers: tuple[str, ...]) -> None:
184
+ self.router._refresh_market_service()
185
+ self.router.cache.clear()
186
+ output = self.query_one("#output", RichLog)
187
+ status = self.query_one("#status_bar", Static)
188
+ write_output_entry(output, f"Provider market/news priority aktif: {', '.join(providers)}")
189
+ status.update(f"ready | market provider: {providers[0] if providers else 'yfinance'}")
190
+
191
+ def _handle_ai_chat(self, prompt: str) -> None:
192
+ output = self.query_one("#output", RichLog)
193
+ status = self.query_one("#status_bar", Static)
194
+ write_output_entry(output, format_user_message(prompt))
195
+ write_output_entry(output, format_thinking_message("routing prompt to active AI provider..."))
196
+ self._submit_route(f"/ai {prompt}", display_raw="/ai", chat=True)
197
+
198
+ def _submit_route(
199
+ self,
200
+ raw: str,
201
+ *,
202
+ display_raw: str,
203
+ chat: bool = False,
204
+ clear_output_before_result: bool = False,
205
+ ) -> None:
206
+ """Run a router command without blocking Textual's UI thread."""
207
+ self._worker_index += 1
208
+ worker_name = f"route-{self._worker_index}"
209
+ self._latest_worker_sequence = self._worker_index
210
+ self._worker_meta[worker_name] = {
211
+ "raw": raw,
212
+ "display_raw": display_raw,
213
+ "chat": chat,
214
+ "clear_output_before_result": clear_output_before_result,
215
+ "sequence": str(self._worker_index),
216
+ }
217
+ self.query_one("#status_bar", Static).update(f"running | {display_raw}")
218
+ self.run_worker(
219
+ lambda: self._route_in_worker(raw),
220
+ name=worker_name,
221
+ group="router",
222
+ description=display_raw,
223
+ thread=True,
224
+ )
225
+
226
+ def _invalidate_pending_workers(self) -> None:
227
+ self._latest_worker_sequence = max(self._latest_worker_sequence, self._worker_index) + 1
228
+
229
+ def _route_in_worker(self, raw: str) -> CommandResult:
230
+ with self._route_lock:
231
+ return self.router.route(raw)
232
+
233
+ def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
234
+ worker = event.worker
235
+ meta = self._worker_meta.get(worker.name or "")
236
+ if meta is None:
237
+ return
238
+ if event.state not in {WorkerState.SUCCESS, WorkerState.ERROR, WorkerState.CANCELLED}:
239
+ return
240
+
241
+ self._worker_meta.pop(worker.name or "", None)
242
+ sequence = int(str(meta.get("sequence", "0")))
243
+ if sequence < self._latest_worker_sequence:
244
+ return
245
+ try:
246
+ output = self.query_one("#output", RichLog)
247
+ status = self.query_one("#status_bar", Static)
248
+ except NoMatches:
249
+ return
250
+ display_raw = str(meta["display_raw"])
251
+
252
+ if event.state == WorkerState.CANCELLED:
253
+ status.update(f"cancelled | {display_raw}")
254
+ return
255
+ if event.state == WorkerState.ERROR:
256
+ write_output_entry(output, f"Error menjalankan {display_raw}: {worker.error}")
257
+ status.update(f"error | {display_raw}")
258
+ return
259
+
260
+ result = worker.result
261
+ if bool(meta.get("clear_output_before_result")):
262
+ output.clear()
263
+ if result.clear:
264
+ output.clear()
265
+ elif result.renderable:
266
+ write_output_entry(output, result.renderable)
267
+
268
+ if bool(meta.get("chat")):
269
+ status.update(f"{result.status} | ai chat")
270
+ else:
271
+ status.update(f"{result.status} | last: {display_raw or 'empty'}")
272
+ if result.should_exit:
273
+ self.exit()