@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.
- package/LICENSE +21 -0
- package/README.md +909 -718
- package/fincli/__init__.py +3 -3
- package/fincli/app/agents/__init__.py +5 -0
- package/fincli/app/agents/registry.py +76 -0
- package/fincli/app/analysis/ai_prompts.py +23 -16
- package/fincli/app/analysis/analyzer.py +107 -100
- package/fincli/app/analysis/assistant_context.py +187 -186
- package/fincli/app/analysis/backtest.py +179 -0
- package/fincli/app/analysis/gameplay_plan.py +79 -0
- package/fincli/app/analysis/multi_timeframe.py +180 -0
- package/fincli/app/analysis/trading_methods.py +144 -0
- package/fincli/app/cli/commands.py +105 -83
- package/fincli/app/cli/router.py +2123 -1294
- package/fincli/app/connectors/__init__.py +5 -0
- package/fincli/app/connectors/catalog.py +148 -0
- package/fincli/app/connectors/news_connectors.py +412 -0
- package/fincli/app/modules/alerts.py +80 -0
- package/fincli/app/modules/economic_calendar.py +374 -1
- package/fincli/app/modules/reports.py +151 -0
- package/fincli/app/modules/scanner.py +111 -93
- package/fincli/app/modules/transactions.py +84 -84
- package/fincli/app/modules/user_profile.py +84 -0
- package/fincli/app/plugins/loader.py +72 -0
- package/fincli/app/providers/ai/manager.py +60 -60
- package/fincli/app/providers/market/alphavantage_provider.py +194 -0
- package/fincli/app/providers/market/base.py +98 -77
- package/fincli/app/providers/market/custom_provider.py +186 -169
- package/fincli/app/providers/market/manager.py +84 -1
- package/fincli/app/providers/market/symbols.py +143 -0
- package/fincli/app/providers/market/twelvedata_provider.py +167 -167
- package/fincli/app/research/__init__.py +7 -0
- package/fincli/app/research/engine.py +75 -0
- package/fincli/app/research/formatter.py +22 -0
- package/fincli/app/research/models.py +18 -0
- package/fincli/app/research/prompt_builder.py +47 -0
- package/fincli/app/services/macro_data.py +50 -0
- package/fincli/app/services/market_data.py +203 -203
- package/fincli/app/services/news_aggregator.py +90 -0
- package/fincli/app/services/web_research.py +267 -267
- package/fincli/app/storage/config.py +122 -88
- package/fincli/app/storage/database.py +200 -101
- package/fincli/app/storage/secrets.py +8 -2
- package/fincli/app/tui/components.py +68 -50
- package/fincli/app/tui/layout.py +269 -258
- package/fincli/app/tui/market_provider_selector.py +3 -1
- package/fincli/app/tui/theme.py +134 -74
- package/fincli/app/utils/formatting.py +123 -60
- package/package.json +23 -23
- 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
|
|
48
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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)
|
package/fincli/app/tui/layout.py
CHANGED
|
@@ -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.
|
|
22
|
-
from fincli.app.tui.
|
|
23
|
-
from fincli.app.tui.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
("ctrl+
|
|
35
|
-
("
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
self.
|
|
42
|
-
self.
|
|
43
|
-
self.
|
|
44
|
-
self.
|
|
45
|
-
self.
|
|
46
|
-
self.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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 | /
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
184
|
-
output
|
|
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
|
|
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
|
|
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()
|