aipa-cli 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,179 @@
1
+ """Chat input with slash command autocomplete and message history."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.containers import Vertical
6
+ from textual.widgets import Input
7
+ from textual_autocomplete import AutoComplete, DropdownItem
8
+
9
+
10
+ _COMMANDS: list[tuple[str, str]] = [
11
+ ("/analyze <ticker> [interval]", "/analyze"),
12
+ ("/clear", "/clear"),
13
+ ("/deep-research [question]", "/deep-research"),
14
+ ("/exit", "/exit"),
15
+ ("/export <ticker> [ticker...] [--interval] [--path]", "/export"),
16
+ ("/help", "/help"),
17
+ ]
18
+
19
+ # Keys that should trigger app-level actions (tab switch, help) even when
20
+ # an Input is focused. Textual filters these from the binding chain, so we
21
+ # handle them manually in the Input's on_key.
22
+ _GLOBAL_KEYS = frozenset(("1", "2", "3", "4", "5", "6", "question_mark"))
23
+ _TAB_MAP: dict[str, str] = {
24
+ "1": "chat",
25
+ "2": "tickers-vn",
26
+ "3": "tickers-crypto",
27
+ "4": "tickers-global",
28
+ "5": "workflows",
29
+ "6": "settings",
30
+ }
31
+
32
+
33
+ class _GlobalKeyInput(Input):
34
+ """Input that forwards global shortcut keys (1-6, ?) to the App.
35
+
36
+ Textual removes typeable-key bindings from the binding chain when an
37
+ Input is focused. This subclass overrides _on_key to intercept global
38
+ keys before Input processes them as typed characters.
39
+ """
40
+
41
+ async def _on_key(self, event) -> None:
42
+ if event.key in _GLOBAL_KEYS:
43
+ event.stop()
44
+ if event.key in _TAB_MAP:
45
+ self.app.action_switch_tab(_TAB_MAP[event.key])
46
+ elif event.key == "question_mark":
47
+ self.app.action_show_help()
48
+ else:
49
+ await super()._on_key(event)
50
+
51
+
52
+ class _CommandAutoComplete(AutoComplete):
53
+ """AutoComplete that only activates for slash commands."""
54
+
55
+ DEFAULT_CSS = """
56
+ _CommandAutoComplete AutoCompleteList {
57
+ max-height: 10;
58
+ }
59
+ """
60
+
61
+ def __init__(self, **kwargs):
62
+ super().__init__(prevent_default_enter=True, **kwargs)
63
+
64
+ def should_show_dropdown(self, search_string: str) -> bool:
65
+ """Only show dropdown when input starts with /."""
66
+ return search_string.startswith("/") and self.option_list.option_count > 0
67
+
68
+ def _complete(self, option_index: int) -> None:
69
+ """Fill input with command name + trailing space."""
70
+ if not self.display or self.option_list.option_count == 0:
71
+ return
72
+ option_list = self.option_list
73
+ option = option_list.get_option_at_index(option_index)
74
+ completion_value = getattr(option, "id", None) or option.value
75
+ # Append trailing space so user can immediately type args
76
+ completion_value += " "
77
+ with self.prevent(Input.Changed):
78
+ self.apply_completion(completion_value, self._get_target_state())
79
+ self.post_completion()
80
+
81
+
82
+ class ChatInput(Vertical):
83
+ """Chat input with slash command autocomplete and message history."""
84
+
85
+ DEFAULT_CSS = """
86
+ ChatInput {
87
+ height: auto;
88
+ }
89
+ ChatInput Input {
90
+ width: 1fr;
91
+ }
92
+ """
93
+
94
+ def __init__(self, **kwargs):
95
+ super().__init__(**kwargs)
96
+ self._history: list[str] = []
97
+ self._history_index: int = -1
98
+
99
+ def compose(self):
100
+ yield _GlobalKeyInput(placeholder="Type a message or /analyze <ticker>...", id="chat-input-field")
101
+ yield _CommandAutoComplete(
102
+ target="#chat-input-field",
103
+ candidates=self._get_candidates,
104
+ )
105
+
106
+ # --- value property -------------------------------------------------------
107
+
108
+ @property
109
+ def value(self) -> str:
110
+ return self.query_one("#chat-input-field", Input).value
111
+
112
+ @value.setter
113
+ def value(self, val: str) -> None:
114
+ self.query_one("#chat-input-field", Input).value = val
115
+
116
+ # --- Candidate provider for AutoComplete -----------------------------------
117
+
118
+ def _get_candidates(self, state) -> list[DropdownItem]:
119
+ """Return matching slash commands based on input text."""
120
+ text = state.text.strip()
121
+ if not text.startswith("/"):
122
+ return []
123
+ # Extract the command word (before any space / args)
124
+ cmd_word = text.split()[0]
125
+ query = cmd_word[1:].lower()
126
+ # If the command is already a complete match, no need to suggest
127
+ for _, cmd in _COMMANDS:
128
+ if cmd[1:].lower() == query:
129
+ return []
130
+ matches = []
131
+ for label, cmd in _COMMANDS:
132
+ if query in cmd[1:].lower():
133
+ matches.append(DropdownItem(label, id=cmd))
134
+ return matches
135
+
136
+ # --- History management ---------------------------------------------------
137
+
138
+ def push_history(self, text: str) -> None:
139
+ """Add a submitted message to history (call from ChatTab on submit)."""
140
+ if text and (not self._history or self._history[-1] != text):
141
+ self._history.append(text)
142
+ self._history_index = -1
143
+
144
+ def _history_up(self) -> None:
145
+ if not self._history:
146
+ return
147
+ if self._history_index == -1:
148
+ self._draft = self.query_one("#chat-input-field", Input).value
149
+ self._history_index = len(self._history) - 1
150
+ elif self._history_index > 0:
151
+ self._history_index -= 1
152
+ else:
153
+ return
154
+ self.query_one("#chat-input-field", Input).value = self._history[self._history_index]
155
+
156
+ def _history_down(self) -> None:
157
+ if self._history_index == -1:
158
+ return
159
+ self._history_index += 1
160
+ if self._history_index >= len(self._history):
161
+ self._history_index = -1
162
+ self.query_one("#chat-input-field", Input).value = getattr(self, "_draft", "")
163
+ else:
164
+ self.query_one("#chat-input-field", Input).value = self._history[self._history_index]
165
+
166
+ def on_key(self, event) -> None:
167
+ """Arrow up/down for history (only when autocomplete dropdown is closed)."""
168
+ if self.app.focused != self.query_one("#chat-input-field", Input):
169
+ return
170
+ autocomplete = self.query_one(_CommandAutoComplete)
171
+ # Let autocomplete handle arrow keys when dropdown is visible
172
+ if autocomplete.display:
173
+ return
174
+ if event.key == "up":
175
+ self._history_up()
176
+ event.stop()
177
+ elif event.key == "down":
178
+ self._history_down()
179
+ event.stop()
@@ -0,0 +1,168 @@
1
+ """Ticker autocomplete widget using textual-autocomplete."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.containers import Vertical
6
+ from textual.widgets import Input
7
+ from textual_autocomplete import AutoComplete, DropdownItem
8
+
9
+
10
+ class _TickerAutoComplete(AutoComplete):
11
+ """AutoComplete that fills the input with the ticker symbol (option.id) on selection."""
12
+
13
+ DEFAULT_CSS = """
14
+ _TickerAutoComplete AutoCompleteList {
15
+ max-height: 20;
16
+ }
17
+ """
18
+
19
+ def _complete(self, option_index: int) -> None:
20
+ """Override to use option.id (ticker symbol) instead of option.value (label)."""
21
+ if not self.display or self.option_list.option_count == 0:
22
+ return
23
+ option_list = self.option_list
24
+ option = option_list.get_option_at_index(option_index)
25
+ # Use option.id (the ticker symbol) if available, fall back to option.value (label)
26
+ completion_value = getattr(option, "id", None) or option.value
27
+ with self.prevent(Input.Changed):
28
+ self.apply_completion(completion_value, self._get_target_state())
29
+ self.post_completion()
30
+
31
+ def should_show_dropdown(self, search_string: str) -> bool:
32
+ """Show dropdown even when search string is empty (show all on focus)."""
33
+ return self.option_list.option_count > 0
34
+
35
+ def get_matches(
36
+ self,
37
+ target_state,
38
+ candidates: list[DropdownItem],
39
+ search_string: str,
40
+ ) -> list[DropdownItem]:
41
+ """Override to boost ticker symbol matches above company name matches.
42
+
43
+ The default fuzzy matcher only sees the label (e.g. '[VN] VIC - Tập đoàn
44
+ VINGROUP') and can rank VICEM companies above the actual VIC ticker.
45
+ We add a score boost when the query matches the ticker symbol (option.id).
46
+ """
47
+ if not search_string:
48
+ return list(candidates)
49
+
50
+ query_lower = search_string.lower()
51
+ boosted: list[tuple[DropdownItem, float]] = []
52
+ match = self._fuzzy_search.match
53
+
54
+ for candidate in candidates:
55
+ label_score, offsets = match(query_lower, candidate.value)
56
+ # Check if query matches the ticker symbol (option.id)
57
+ ticker_id = getattr(candidate, "id", None) or ""
58
+ id_match = query_lower in ticker_id.lower() if ticker_id else False
59
+
60
+ if id_match and label_score > 0:
61
+ # Ticker symbol match + label match → highest priority
62
+ final_score = 2.0 + label_score
63
+ elif id_match:
64
+ # Ticker symbol matches but label doesn't (e.g. short ticker)
65
+ final_score = 1.5
66
+ elif label_score > 0:
67
+ final_score = label_score
68
+ else:
69
+ continue
70
+
71
+ highlighted = self.apply_highlights(candidate.main, offsets)
72
+ item = type(candidate)(
73
+ main=highlighted,
74
+ prefix=candidate.prefix,
75
+ id=candidate.id,
76
+ disabled=candidate.disabled,
77
+ )
78
+ boosted.append((item, final_score))
79
+
80
+ boosted.sort(key=lambda x: x[1], reverse=True)
81
+ return [item for item, _ in boosted]
82
+
83
+
84
+ class TickerSelect(Vertical):
85
+ """Autocomplete ticker input with filtered dropdown."""
86
+
87
+ DEFAULT_CSS = """
88
+ TickerSelect {
89
+ width: 1fr;
90
+ height: auto;
91
+ }
92
+ TickerSelect Input {
93
+ width: 1fr;
94
+ }
95
+ """
96
+
97
+ def __init__(self, value: str = "VNINDEX", **kwargs):
98
+ super().__init__(**kwargs)
99
+ self._desired_value = value
100
+ self._all_options: list[tuple[str, str]] = [(value, value)]
101
+
102
+ def compose(self):
103
+ yield Input(value=self._desired_value, id="ticker-input")
104
+ yield _TickerAutoComplete(
105
+ target="#ticker-input",
106
+ candidates=self._get_candidates,
107
+ id="ticker-autocomplete",
108
+ )
109
+
110
+ def on_mount(self) -> None:
111
+ self.watch(self.app, "ticker_options", self._on_ticker_options, init=False)
112
+
113
+ _GLOBAL_KEYS = frozenset(("1", "2", "3", "4", "5", "6", "question_mark"))
114
+ _TAB_MAP: dict[str, str] = {
115
+ "1": "chat",
116
+ "2": "tickers-vn",
117
+ "3": "tickers-crypto",
118
+ "4": "tickers-global",
119
+ "5": "workflows",
120
+ "6": "settings",
121
+ }
122
+
123
+ def on_key(self, event) -> None:
124
+ """Intercept global shortcut keys when ticker Input is focused.
125
+
126
+ Textual filters typeable-key bindings when an Input is focused,
127
+ so we handle tab-switch and help keys manually.
128
+ """
129
+ if self.app.focused != self.query_one("#ticker-input", Input):
130
+ return
131
+ autocomplete = self.query_one(_TickerAutoComplete)
132
+ if autocomplete.display:
133
+ return
134
+ if event.key in self._GLOBAL_KEYS:
135
+ input_widget = self.query_one("#ticker-input", Input)
136
+ if input_widget.value:
137
+ input_widget.value = input_widget.value[:-1]
138
+ if event.key in self._TAB_MAP:
139
+ self.app.action_switch_tab(self._TAB_MAP[event.key])
140
+ elif event.key == "question_mark":
141
+ self.app.action_show_help()
142
+ event.stop()
143
+
144
+ # --- value property -------------------------------------------------------
145
+
146
+ @property
147
+ def value(self) -> str:
148
+ return self.query_one("#ticker-input", Input).value.strip()
149
+
150
+ @value.setter
151
+ def value(self, val: str) -> None:
152
+ self.query_one("#ticker-input", Input).value = val
153
+
154
+ # --- Options loading ------------------------------------------------------
155
+
156
+ def _on_ticker_options(self, old_value, new_value: list[tuple[str, str]]) -> None:
157
+ """React when the app loads ticker options."""
158
+ self._all_options = new_value
159
+ if self._desired_value and any(v == self._desired_value for _, v in new_value):
160
+ self.value = self._desired_value
161
+ else:
162
+ self.value = new_value[0][1] if new_value else ""
163
+
164
+ # --- Candidate provider for AutoComplete -----------------------------------
165
+
166
+ def _get_candidates(self, state) -> list[DropdownItem]:
167
+ """Return all options; the library's fuzzy matcher handles filtering."""
168
+ return [DropdownItem(label, id=val) for label, val in self._all_options]
@@ -0,0 +1,172 @@
1
+ """Workflows tab: nested tabs for different workflow types."""
2
+
3
+ import asyncio
4
+
5
+ from textual import work
6
+ from textual.containers import Vertical, Horizontal
7
+ from textual.widgets import (
8
+ Static, RichLog, Input, Button, Select, TabbedContent, TabPane,
9
+ )
10
+
11
+ from .utils import write_context_result, write_error
12
+ from .widgets import TickerSelect
13
+
14
+
15
+ class AnalyzePane(Vertical):
16
+ """Single-ticker context analysis workflow."""
17
+
18
+ DEFAULT_CSS = """
19
+ AnalyzePane {
20
+ padding: 1 2;
21
+ }
22
+ .wf-row {
23
+ height: auto;
24
+ margin-bottom: 1;
25
+ }
26
+ #wf-output {
27
+ height: 1fr;
28
+ border: solid $accent;
29
+ padding: 1;
30
+ }
31
+ .wf-label {
32
+ width: 10;
33
+ height: auto;
34
+ }
35
+ """
36
+
37
+
38
+ def compose(self):
39
+ with Horizontal(classes="wf-row"):
40
+ yield Static("Ticker:", classes="wf-label")
41
+ yield TickerSelect(value="VNINDEX", id="wf-ticker")
42
+ yield Static("Interval:", classes="wf-label")
43
+ yield Select(
44
+ [("1m", "1m"), ("1h", "1h"), ("1D", "1D")],
45
+ value="1D",
46
+ allow_blank=False,
47
+ id="wf-interval",
48
+ )
49
+ with Horizontal(classes="wf-row"):
50
+ yield Button("Analyze", id="wf-analyze-btn", variant="primary")
51
+ yield RichLog(id="wf-output", highlight=True, markup=True)
52
+
53
+ def on_mount(self) -> None:
54
+ self.query_one("#wf-interval", Select).value = self.app.interval
55
+ self.query_one("#wf-output", RichLog).write(
56
+ "[dim italic]Select a ticker and click Analyze to build AI context.[/dim italic]\n"
57
+ )
58
+
59
+ def on_button_pressed(self, event: Button.Pressed) -> None:
60
+ if event.button.id != "wf-analyze-btn":
61
+ return
62
+ self._do_analyze()
63
+
64
+ def _do_analyze(self) -> None:
65
+ ticker = self.query_one("#wf-ticker", TickerSelect).value
66
+ interval = self.query_one("#wf-interval", Select).value
67
+ log = self.query_one("#wf-output", RichLog)
68
+ log.write(f"[bold cyan]Analyze:[/bold cyan] {ticker} ({interval})")
69
+ log.write("[dim]Building context...[/dim]")
70
+ self._run_analyze(ticker, interval)
71
+
72
+ @work(exclusive=True)
73
+ async def _run_analyze(self, ticker: str, interval: str) -> None:
74
+ try:
75
+ builder = self.app.builder
76
+ context = await asyncio.to_thread(
77
+ builder.build, ticker=ticker, interval=interval
78
+ )
79
+ log = self.query_one("#wf-output", RichLog)
80
+ write_context_result(log, ticker, interval, context)
81
+ except Exception as e:
82
+ log = self.query_one("#wf-output", RichLog)
83
+ write_error(log, e)
84
+
85
+
86
+ class DeepResearchPane(Vertical):
87
+ """Multi-agent deep research workflow."""
88
+
89
+ DEFAULT_CSS = """
90
+ DeepResearchPane {
91
+ padding: 1 2;
92
+ }
93
+ .dr-row {
94
+ height: auto;
95
+ margin-bottom: 1;
96
+ }
97
+ .dr-label {
98
+ width: 10;
99
+ height: auto;
100
+ }
101
+ #dr-question {
102
+ width: 1fr;
103
+ }
104
+ #dr-output {
105
+ height: 1fr;
106
+ border: solid $accent;
107
+ padding: 1;
108
+ }
109
+ #dr-btn {
110
+ margin-left: 1;
111
+ }
112
+ """
113
+
114
+ def compose(self):
115
+ with Horizontal(classes="dr-row"):
116
+ yield Static("Question:", classes="dr-label")
117
+ yield Input(
118
+ value="",
119
+ placeholder="Enter research question...",
120
+ id="dr-question",
121
+ )
122
+ yield Button("Deep Research", id="dr-btn", variant="success")
123
+ yield RichLog(id="dr-output", highlight=True, markup=True)
124
+
125
+ def on_mount(self) -> None:
126
+ self.query_one("#dr-output", RichLog).write(
127
+ "[bold yellow]Deep research is not yet implemented.[/bold yellow]\n\n"
128
+ "[dim]This will run the multi-agent LangGraph pipeline:\n"
129
+ " supervisor -> parallel workers -> aggregator -> reviewer\n\n"
130
+ "Enter a question and click Deep Research when ready.[/dim]\n"
131
+ )
132
+
133
+ def on_button_pressed(self, event: Button.Pressed) -> None:
134
+ if event.button.id != "dr-btn":
135
+ return
136
+ self._do_deep_research()
137
+
138
+ def on_input_submitted(self, event: Input.Submitted) -> None:
139
+ if event.input.id == "dr-question":
140
+ self._do_deep_research()
141
+
142
+ def _do_deep_research(self) -> None:
143
+ question = self.query_one("#dr-question", Input).value.strip()
144
+ log = self.query_one("#dr-output", RichLog)
145
+ log.write(
146
+ f"[bold cyan]Deep Research:[/bold cyan]"
147
+ + (f" {question}" if question else "")
148
+ )
149
+ log.write(
150
+ "[bold yellow]Not yet implemented.[/bold yellow]\n"
151
+ "[dim]Will trigger multi_agent.main() in a future update.[/dim]\n"
152
+ )
153
+
154
+
155
+ class WorkflowsTab(Vertical):
156
+ """Workflows container with nested tabs for each workflow type."""
157
+
158
+ DEFAULT_CSS = """
159
+ WorkflowsTab {
160
+ height: 100%;
161
+ }
162
+ WorkflowsTab TabbedContent {
163
+ height: 1fr;
164
+ }
165
+ """
166
+
167
+ def compose(self):
168
+ with TabbedContent(initial="wf-analyze"):
169
+ with TabPane("Analyze", id="wf-analyze"):
170
+ yield AnalyzePane()
171
+ with TabPane("Deep Research", id="wf-deep-research"):
172
+ yield DeepResearchPane()