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.
- aipa_cli-0.1.0.dist-info/METADATA +69 -0
- aipa_cli-0.1.0.dist-info/RECORD +28 -0
- aipa_cli-0.1.0.dist-info/WHEEL +4 -0
- aipa_cli-0.1.0.dist-info/entry_points.txt +2 -0
- aipa_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- aipriceaction_terminal/__init__.py +3 -0
- aipriceaction_terminal/__main__.py +3 -0
- aipriceaction_terminal/actions.py +73 -0
- aipriceaction_terminal/agents/__init__.py +20 -0
- aipriceaction_terminal/agents/agent.py +175 -0
- aipriceaction_terminal/agents/callbacks.py +202 -0
- aipriceaction_terminal/agents/config.py +35 -0
- aipriceaction_terminal/agents/personas.py +175 -0
- aipriceaction_terminal/agents/tools.py +152 -0
- aipriceaction_terminal/app.py +97 -0
- aipriceaction_terminal/bindings.py +25 -0
- aipriceaction_terminal/chat.py +345 -0
- aipriceaction_terminal/cli.py +54 -0
- aipriceaction_terminal/cli_commands.py +51 -0
- aipriceaction_terminal/settings_tab.py +76 -0
- aipriceaction_terminal/theme.py +29 -0
- aipriceaction_terminal/ticker_data.py +33 -0
- aipriceaction_terminal/user_settings.py +27 -0
- aipriceaction_terminal/utils.py +29 -0
- aipriceaction_terminal/widgets/__init__.py +6 -0
- aipriceaction_terminal/widgets/chat_input.py +179 -0
- aipriceaction_terminal/widgets/ticker_select.py +168 -0
- aipriceaction_terminal/workflows.py +172 -0
|
@@ -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()
|