nexus-tui 0.1.13__py3-none-any.whl → 0.1.15__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.
- nexus/config.py +9 -1
- nexus/screens/create_project.py +0 -4
- nexus/screens/help.py +5 -4
- nexus/screens/project_picker.py +24 -3
- nexus/screens/tool_selector.py +113 -372
- nexus/state.py +6 -2
- nexus/style.tcss +69 -20
- nexus/widgets/footer.py +108 -0
- nexus/widgets/tool_browser.py +253 -0
- nexus/widgets/tool_list_item.py +4 -8
- nexus_tui-0.1.15.dist-info/METADATA +200 -0
- nexus_tui-0.1.15.dist-info/RECORD +28 -0
- nexus_tui-0.1.13.dist-info/METADATA +0 -150
- nexus_tui-0.1.13.dist-info/RECORD +0 -26
- {nexus_tui-0.1.13.dist-info → nexus_tui-0.1.15.dist-info}/WHEEL +0 -0
- {nexus_tui-0.1.13.dist-info → nexus_tui-0.1.15.dist-info}/entry_points.txt +0 -0
- {nexus_tui-0.1.13.dist-info → nexus_tui-0.1.15.dist-info}/licenses/LICENSE +0 -0
nexus/screens/tool_selector.py
CHANGED
|
@@ -5,27 +5,23 @@ and execution.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from textual.app import ComposeResult
|
|
8
|
-
from textual.
|
|
9
|
-
from textual.
|
|
8
|
+
from textual.binding import Binding
|
|
9
|
+
from textual.containers import Horizontal
|
|
10
|
+
from textual.events import Key, Resize
|
|
10
11
|
from textual.reactive import reactive
|
|
11
12
|
from textual.screen import Screen
|
|
12
|
-
from textual.widgets import Label
|
|
13
|
+
from textual.widgets import Label
|
|
13
14
|
|
|
14
|
-
from nexus.config import get_tools
|
|
15
|
-
from nexus.widgets.tool_list_item import CategoryListItem, ToolListItem
|
|
16
|
-
from nexus.logger import get_logger
|
|
17
15
|
from nexus.models import Tool
|
|
16
|
+
from nexus.widgets.footer import NexusFooter, KeyBadge
|
|
17
|
+
from nexus.widgets.tool_browser import ToolBrowser
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
class ToolSelector(Screen[None]):
|
|
21
21
|
"""Screen for selecting and launching tools.
|
|
22
22
|
|
|
23
|
-
Displays a list of tools
|
|
23
|
+
Displays a categorized list of tools separated by type. Allows searching,
|
|
24
24
|
filtering, and keyboard navigation.
|
|
25
|
-
|
|
26
|
-
Attributes:
|
|
27
|
-
search_query: The current search filter text.
|
|
28
|
-
BINDINGS: Key bindings for the screen.
|
|
29
25
|
"""
|
|
30
26
|
|
|
31
27
|
CSS_PATH = "../style.tcss"
|
|
@@ -34,187 +30,88 @@ class ToolSelector(Screen[None]):
|
|
|
34
30
|
search_query = reactive("")
|
|
35
31
|
|
|
36
32
|
BINDINGS = [
|
|
37
|
-
("ctrl+t", "show_theme_picker", "Theme"),
|
|
38
|
-
("ctrl+f", "toggle_favorite", "Favorite"),
|
|
39
|
-
("escape", "clear_search", "Clear Search"),
|
|
40
|
-
("down", "cursor_down", "Next Item"),
|
|
41
|
-
("up", "cursor_up", "Previous Item"),
|
|
42
|
-
("right", "cursor_right", "Enter List"),
|
|
43
|
-
("left", "cursor_left", "Back to Categories"),
|
|
44
|
-
("enter", "launch_current", "Launch Tool"),
|
|
45
|
-
("backspace", "delete_char", "Delete Character"),
|
|
46
|
-
("?", "show_help", "Help"),
|
|
47
|
-
("
|
|
33
|
+
Binding("ctrl+t", "show_theme_picker", "Theme"),
|
|
34
|
+
Binding("ctrl+f", "toggle_favorite", "Favorite"),
|
|
35
|
+
Binding("escape", "clear_search", "Clear Search"),
|
|
36
|
+
Binding("down", "cursor_down", "Next Item", show=False),
|
|
37
|
+
Binding("up", "cursor_up", "Previous Item", show=False),
|
|
38
|
+
Binding("right", "cursor_right", "Enter List", show=False),
|
|
39
|
+
Binding("left", "cursor_left", "Back to Categories", show=False),
|
|
40
|
+
Binding("enter", "launch_current", "Launch Tool", show=False),
|
|
41
|
+
Binding("backspace", "delete_char", "Delete Character", show=False),
|
|
42
|
+
Binding("?", "show_help", "Help"),
|
|
43
|
+
Binding("ctrl+h", "show_help", "Help", show=False),
|
|
44
|
+
Binding("h", "show_help", "Help", show=False),
|
|
45
|
+
Binding("f1", "show_help", "Help"),
|
|
48
46
|
]
|
|
49
47
|
|
|
50
|
-
def action_show_help(self) -> None:
|
|
51
|
-
"""Shows the help screen modal."""
|
|
52
|
-
from nexus.screens.help import HelpScreen
|
|
53
|
-
|
|
54
|
-
self.app.push_screen(HelpScreen())
|
|
55
|
-
|
|
56
48
|
def compose(self) -> ComposeResult:
|
|
57
|
-
"""Composes the screen layout.
|
|
58
|
-
|
|
59
|
-
Yields:
|
|
60
|
-
The widget tree for the screen.
|
|
61
|
-
"""
|
|
49
|
+
"""Composes the screen layout."""
|
|
62
50
|
with Horizontal(id="header"):
|
|
63
51
|
yield Label(
|
|
64
|
-
"
|
|
52
|
+
"Nexus",
|
|
65
53
|
id="header-left",
|
|
66
54
|
)
|
|
67
|
-
yield Label("
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
with Vertical(id="left-pane"):
|
|
71
|
-
yield Label("Categories", classes="pane-header")
|
|
72
|
-
yield ListView(id="category-list")
|
|
73
|
-
|
|
74
|
-
with Vertical(id="right-pane"):
|
|
75
|
-
yield Horizontal(
|
|
76
|
-
Label("Toolbox", classes="pane-header"),
|
|
77
|
-
Label("Important Actions", classes="pane-header-right"),
|
|
78
|
-
classes="pane-header-container",
|
|
79
|
-
)
|
|
80
|
-
yield ListView(id="tool-list")
|
|
81
|
-
yield Label(
|
|
82
|
-
"No tools found", id="tools-empty", classes="empty-state hidden"
|
|
83
|
-
)
|
|
55
|
+
yield Label("Type to search tools...", id="tool-search")
|
|
56
|
+
|
|
57
|
+
yield ToolBrowser(id="tool-browser")
|
|
84
58
|
|
|
85
59
|
yield Label("", id="tool-description")
|
|
86
60
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
with Horizontal(classes="footer-col"):
|
|
91
|
-
yield Label("NAV", classes="footer-label")
|
|
92
|
-
yield Label("↑↓", classes="key-badge")
|
|
93
|
-
yield Label("Select", classes="key-desc")
|
|
94
|
-
yield Label("←→", classes="key-badge")
|
|
95
|
-
yield Label("Pane", classes="key-desc")
|
|
96
|
-
yield Label("Enter", classes="key-badge")
|
|
97
|
-
yield Label("Launch", classes="key-desc")
|
|
98
|
-
yield Label("^F", classes="key-badge")
|
|
99
|
-
yield Label("Fav", classes="key-desc")
|
|
100
|
-
|
|
101
|
-
# SEARCH
|
|
102
|
-
with Horizontal(classes="footer-col"):
|
|
103
|
-
yield Label("SEARCH", classes="footer-label")
|
|
104
|
-
yield Label("Type", classes="key-badge")
|
|
105
|
-
yield Label("Filter", classes="key-desc")
|
|
106
|
-
yield Label("Esc", classes="key-badge")
|
|
107
|
-
yield Label("Clear", classes="key-desc")
|
|
108
|
-
|
|
109
|
-
# SYSTEM
|
|
110
|
-
with Horizontal(classes="footer-col"):
|
|
111
|
-
yield Label("SYSTEM", classes="footer-label")
|
|
112
|
-
yield Label("^T", classes="key-badge")
|
|
113
|
-
yield Label("Theme", classes="key-desc")
|
|
114
|
-
yield Label("^Q", classes="key-badge")
|
|
115
|
-
yield Label("Exit", classes="key-desc")
|
|
116
|
-
|
|
117
|
-
# Theme Management
|
|
61
|
+
yield NexusFooter()
|
|
62
|
+
|
|
63
|
+
# --- Theme Management ---
|
|
118
64
|
THEMES = ["theme-light", "theme-dark", "theme-storm"]
|
|
119
65
|
current_theme_index = 0
|
|
120
66
|
|
|
121
67
|
def action_show_theme_picker(self) -> None:
|
|
122
68
|
"""Opens the theme picker modal."""
|
|
123
|
-
|
|
124
|
-
# Helper to apply theme temporarily or permanently
|
|
125
69
|
def apply_theme(new_theme: str) -> None:
|
|
126
70
|
self.set_theme(new_theme)
|
|
127
71
|
|
|
128
72
|
from nexus.screens.theme_picker import ThemePicker
|
|
129
|
-
|
|
130
73
|
current_theme = self.THEMES[self.current_theme_index]
|
|
131
74
|
self.app.push_screen(ThemePicker(self.THEMES, current_theme, apply_theme))
|
|
132
75
|
|
|
133
76
|
def set_theme(self, new_theme: str) -> None:
|
|
134
|
-
"""Sets the current theme for the application.
|
|
135
|
-
|
|
136
|
-
Args:
|
|
137
|
-
new_theme: The CSS class name of the theme to apply.
|
|
138
|
-
"""
|
|
139
|
-
# Find current applied theme to remove it
|
|
77
|
+
"""Sets the current theme for the application."""
|
|
140
78
|
for theme in self.THEMES:
|
|
141
79
|
if theme in self.classes:
|
|
142
80
|
self.remove_class(theme)
|
|
143
81
|
|
|
144
82
|
self.add_class(new_theme)
|
|
145
83
|
|
|
146
|
-
# Update index if it's one of ours
|
|
147
84
|
if new_theme in self.THEMES:
|
|
148
85
|
self.current_theme_index = self.THEMES.index(new_theme)
|
|
149
86
|
|
|
150
87
|
suffix = new_theme.replace("theme-", "").title()
|
|
151
|
-
self.notify(f"Theme:
|
|
152
|
-
|
|
153
|
-
def action_next_theme(self) -> None:
|
|
154
|
-
"""Cycles to the next theme (legacy binding)."""
|
|
155
|
-
self.cycle_theme(1)
|
|
156
|
-
|
|
157
|
-
def action_prev_theme(self) -> None:
|
|
158
|
-
"""Cycles to the previous theme."""
|
|
159
|
-
self.cycle_theme(-1)
|
|
160
|
-
|
|
161
|
-
def cycle_theme(self, direction: int) -> None:
|
|
162
|
-
"""Cycles through available themes.
|
|
163
|
-
|
|
164
|
-
Args:
|
|
165
|
-
direction: 1 for next, -1 for previous.
|
|
166
|
-
"""
|
|
167
|
-
new_index = (self.current_theme_index + direction) % len(self.THEMES)
|
|
168
|
-
new_theme = self.THEMES[new_index]
|
|
169
|
-
self.set_theme(new_theme)
|
|
88
|
+
self.notify(f"Theme: {suffix}")
|
|
170
89
|
|
|
171
90
|
def on_mount(self) -> None:
|
|
172
|
-
"""Called when the screen is mounted.
|
|
173
|
-
|
|
174
|
-
Sets initial theme, populates categories, and focuses the category list.
|
|
175
|
-
"""
|
|
91
|
+
"""Called when the screen is mounted."""
|
|
176
92
|
self.add_class(self.THEMES[self.current_theme_index])
|
|
177
|
-
|
|
178
|
-
# Default focus to categories
|
|
179
|
-
self.query_one("#category-list").focus()
|
|
180
|
-
|
|
93
|
+
|
|
181
94
|
# Report any config errors from loading phase
|
|
182
95
|
from nexus.config import CONFIG_ERRORS
|
|
183
96
|
for error in CONFIG_ERRORS:
|
|
184
97
|
self.app.notify(error, title="Config Error", severity="error", timeout=5.0)
|
|
185
98
|
|
|
186
|
-
|
|
187
|
-
"""Selects the 'ALL' category in the list."""
|
|
188
|
-
category_list = self.query_one("#category-list", ListView)
|
|
189
|
-
# Find the index of the ALL category
|
|
190
|
-
for idx, child in enumerate(category_list.children):
|
|
191
|
-
if isinstance(child, CategoryListItem) and child.category_id == "ALL":
|
|
192
|
-
if category_list.index != idx:
|
|
193
|
-
category_list.index = idx
|
|
194
|
-
break
|
|
99
|
+
# --- Search & Filter ---
|
|
195
100
|
|
|
196
101
|
def watch_search_query(self, old_value: str, new_value: str) -> None:
|
|
197
|
-
"""Reacts to changes in the search query.
|
|
198
|
-
|
|
199
|
-
Args:
|
|
200
|
-
old_value: The previous search query.
|
|
201
|
-
new_value: The new search query.
|
|
202
|
-
"""
|
|
102
|
+
"""Reacts to changes in the search query."""
|
|
203
103
|
try:
|
|
204
104
|
feedback = self.query_one("#tool-search", Label)
|
|
105
|
+
browser = self.query_one(ToolBrowser)
|
|
205
106
|
except Exception:
|
|
206
107
|
return
|
|
207
108
|
|
|
109
|
+
browser.search_query = new_value
|
|
110
|
+
|
|
208
111
|
if new_value:
|
|
209
112
|
feedback.update(f"SEARCH: {new_value}_")
|
|
210
|
-
# Switch to ALL category automatically if not already
|
|
211
|
-
self.select_all_category()
|
|
212
|
-
# Populate tools with filter
|
|
213
|
-
self.refresh_tools()
|
|
214
113
|
else:
|
|
215
|
-
feedback.update("
|
|
216
|
-
# Re-populate without filter
|
|
217
|
-
self.refresh_tools()
|
|
114
|
+
feedback.update("Type to search tools...")
|
|
218
115
|
|
|
219
116
|
def action_delete_char(self) -> None:
|
|
220
117
|
"""Deletes the last character from search query."""
|
|
@@ -227,275 +124,119 @@ class ToolSelector(Screen[None]):
|
|
|
227
124
|
self.search_query = ""
|
|
228
125
|
|
|
229
126
|
def on_key(self, event: Key) -> None:
|
|
230
|
-
"""Global key handler for type-to-search and numeric quick launch.
|
|
231
|
-
|
|
232
|
-
Args:
|
|
233
|
-
event: The key event.
|
|
234
|
-
"""
|
|
127
|
+
"""Global key handler for type-to-search and numeric quick launch."""
|
|
235
128
|
# Numeric keys 1-9 for quick launch
|
|
236
129
|
if event.key in "123456789":
|
|
237
130
|
idx = int(event.key) - 1
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
return
|
|
131
|
+
browser = self.query_one(ToolBrowser)
|
|
132
|
+
tool = browser.get_tool_at_index(idx)
|
|
133
|
+
if tool:
|
|
134
|
+
self.launch_tool_flow(tool)
|
|
135
|
+
event.stop()
|
|
136
|
+
return
|
|
245
137
|
|
|
246
138
|
if event.key.isprintable() and len(event.key) == 1:
|
|
247
139
|
# Append char to query
|
|
248
140
|
self.search_query += event.key
|
|
249
141
|
event.stop()
|
|
250
142
|
|
|
251
|
-
|
|
252
|
-
"""Refreshes tool list based on current selection and search text."""
|
|
253
|
-
category_list = self.query_one("#category-list", ListView)
|
|
254
|
-
if (
|
|
255
|
-
hasattr(category_list, "highlighted_child")
|
|
256
|
-
and category_list.highlighted_child
|
|
257
|
-
):
|
|
258
|
-
item = category_list.highlighted_child
|
|
259
|
-
if isinstance(item, CategoryListItem):
|
|
260
|
-
self.populate_tools(item.category_id, filter_text=self.search_query)
|
|
261
|
-
|
|
262
|
-
def populate_categories(self) -> None:
|
|
263
|
-
"""Populates the category list with unique categories from loaded tools."""
|
|
264
|
-
category_list = self.query_one("#category-list", ListView)
|
|
265
|
-
category_list.clear()
|
|
266
|
-
|
|
267
|
-
# Get unique categories
|
|
268
|
-
tools = get_tools()
|
|
269
|
-
categories = sorted(list(set(t.category for t in tools)))
|
|
270
|
-
|
|
271
|
-
# Add FAVORITES and ALL category at the start
|
|
272
|
-
fav_item = CategoryListItem("FAVORITES")
|
|
273
|
-
fav_item.add_class("category-FAVORITES") # Optional styling hook
|
|
274
|
-
category_list.append(fav_item)
|
|
275
|
-
|
|
276
|
-
all_item = CategoryListItem("ALL")
|
|
277
|
-
category_list.append(all_item)
|
|
278
|
-
|
|
279
|
-
for category in categories:
|
|
280
|
-
item = CategoryListItem(category)
|
|
281
|
-
category_list.append(item)
|
|
282
|
-
|
|
283
|
-
# Select "ALL" category by default (index 1, as FAVORITES is 0)
|
|
284
|
-
# Or find index of "ALL"
|
|
285
|
-
if category_list.children:
|
|
286
|
-
all_idx = 1 if len(category_list.children) > 1 else 0
|
|
287
|
-
category_list.index = all_idx
|
|
288
|
-
# Trigger population via index change?
|
|
289
|
-
# Textual might not trigger highlighted event on programmatic set if not focused/mounted fully?
|
|
290
|
-
# Safe to call populate_tools explicitly.
|
|
291
|
-
cat_id = "ALL"
|
|
292
|
-
if 0 <= all_idx < len(category_list.children):
|
|
293
|
-
child = category_list.children[all_idx]
|
|
294
|
-
if isinstance(child, CategoryListItem):
|
|
295
|
-
cat_id = child.category_id
|
|
296
|
-
|
|
297
|
-
self.populate_tools(cat_id)
|
|
298
|
-
|
|
299
|
-
def populate_tools(self, category: str, filter_text: str = "") -> None:
|
|
300
|
-
"""Populates the tool list based on category and filter text.
|
|
301
|
-
|
|
302
|
-
Args:
|
|
303
|
-
category: The category ID to filter by (or "ALL").
|
|
304
|
-
filter_text: Optional text to filter tool label/description.
|
|
305
|
-
"""
|
|
306
|
-
from nexus.app import NexusApp
|
|
307
|
-
|
|
308
|
-
tool_list = self.query_one("#tool-list", ListView)
|
|
309
|
-
tool_list.clear()
|
|
310
|
-
|
|
311
|
-
tools = get_tools()
|
|
312
|
-
|
|
313
|
-
if category == "ALL":
|
|
314
|
-
filtered_tools = tools
|
|
315
|
-
elif category == "FAVORITES":
|
|
316
|
-
if isinstance(self.app, NexusApp):
|
|
317
|
-
favs = self.app.container.state_manager.get_favorites()
|
|
318
|
-
filtered_tools = [t for t in tools if t.command in favs]
|
|
319
|
-
else:
|
|
320
|
-
filtered_tools = []
|
|
321
|
-
else:
|
|
322
|
-
filtered_tools = [t for t in tools if t.category == category]
|
|
323
|
-
|
|
324
|
-
if filter_text:
|
|
325
|
-
filtered_tools = [
|
|
326
|
-
t
|
|
327
|
-
for t in filtered_tools
|
|
328
|
-
if filter_text.lower() in t.label.lower()
|
|
329
|
-
or filter_text.lower() in t.description.lower()
|
|
330
|
-
]
|
|
331
|
-
|
|
332
|
-
if filtered_tools:
|
|
333
|
-
tool_list.index = 0
|
|
334
|
-
tool_list.display = True
|
|
335
|
-
self.query_one("#tools-empty").add_class("hidden")
|
|
336
|
-
else:
|
|
337
|
-
tool_list.display = False
|
|
338
|
-
empty_lbl = self.query_one("#tools-empty", Label)
|
|
339
|
-
empty_lbl.remove_class("hidden")
|
|
340
|
-
if filter_text:
|
|
341
|
-
empty_lbl.update(f"No tools matching '{filter_text}'")
|
|
342
|
-
else:
|
|
343
|
-
empty_lbl.update(f"No tools in category '{category}'")
|
|
344
|
-
|
|
345
|
-
for i, tool in enumerate(filtered_tools):
|
|
346
|
-
hint = str(i + 1) if i < 9 else ""
|
|
347
|
-
is_fav = False
|
|
348
|
-
if isinstance(self.app, NexusApp):
|
|
349
|
-
is_fav = self.app.container.state_manager.is_favorite(tool.command)
|
|
350
|
-
|
|
351
|
-
item = ToolListItem(tool, hint=hint, is_favorite=is_fav)
|
|
352
|
-
tool_list.append(item)
|
|
353
|
-
|
|
354
|
-
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
|
|
355
|
-
"""Called when a list item is highlighted.
|
|
356
|
-
|
|
357
|
-
Args:
|
|
358
|
-
event: The highlight event.
|
|
359
|
-
"""
|
|
360
|
-
if event.list_view.id == "category-list":
|
|
361
|
-
if isinstance(event.item, CategoryListItem):
|
|
362
|
-
self.populate_tools(
|
|
363
|
-
event.item.category_id, filter_text=self.search_query
|
|
364
|
-
)
|
|
365
|
-
|
|
366
|
-
elif event.list_view.id == "tool-list":
|
|
367
|
-
if isinstance(event.item, ToolListItem):
|
|
368
|
-
tool = event.item.tool_info
|
|
369
|
-
self.query_one("#tool-description", Label).update(
|
|
370
|
-
f"{tool.label}: {tool.description}"
|
|
371
|
-
)
|
|
372
|
-
|
|
373
|
-
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
374
|
-
"""Called when a list item is selected (Enter pressed).
|
|
375
|
-
|
|
376
|
-
Args:
|
|
377
|
-
event: The selection event.
|
|
378
|
-
"""
|
|
379
|
-
if event.list_view.id == "category-list":
|
|
380
|
-
# If user selects a category (Enter), move focus to tool list
|
|
381
|
-
self.query_one("#tool-list").focus()
|
|
382
|
-
# force update
|
|
383
|
-
self.action_cursor_right() # Re-use logic
|
|
384
|
-
|
|
385
|
-
elif event.list_view.id == "tool-list":
|
|
386
|
-
# If user selects a tool, launch it
|
|
387
|
-
if isinstance(event.item, ToolListItem):
|
|
388
|
-
tool = event.item.tool_info
|
|
389
|
-
self.launch_tool_flow(tool)
|
|
143
|
+
# --- Navigation delegation ---
|
|
390
144
|
|
|
391
145
|
def action_cursor_down(self) -> None:
|
|
392
|
-
|
|
393
|
-
if self.query_one("#category-list").has_focus:
|
|
394
|
-
category_list = self.query_one("#category-list", ListView)
|
|
395
|
-
if category_list.index is None:
|
|
396
|
-
category_list.index = 0
|
|
397
|
-
else:
|
|
398
|
-
category_list.index = min(
|
|
399
|
-
len(category_list.children) - 1, category_list.index + 1
|
|
400
|
-
)
|
|
401
|
-
|
|
402
|
-
elif self.query_one("#tool-list").has_focus:
|
|
403
|
-
tool_list = self.query_one("#tool-list", ListView)
|
|
404
|
-
if tool_list.index is None:
|
|
405
|
-
tool_list.index = 0
|
|
406
|
-
else:
|
|
407
|
-
tool_list.index = min(len(tool_list.children) - 1, tool_list.index + 1)
|
|
146
|
+
self.query_one(ToolBrowser).focus_next()
|
|
408
147
|
|
|
409
148
|
def action_cursor_up(self) -> None:
|
|
410
|
-
|
|
411
|
-
if self.query_one("#category-list").has_focus:
|
|
412
|
-
lst = self.query_one("#category-list", ListView)
|
|
413
|
-
if lst.index is not None:
|
|
414
|
-
lst.index = max(0, lst.index - 1)
|
|
415
|
-
|
|
416
|
-
elif self.query_one("#tool-list").has_focus:
|
|
417
|
-
lst = self.query_one("#tool-list", ListView)
|
|
418
|
-
if lst.index is not None:
|
|
419
|
-
lst.index = max(0, lst.index - 1)
|
|
149
|
+
self.query_one(ToolBrowser).focus_prev()
|
|
420
150
|
|
|
421
151
|
def action_cursor_right(self) -> None:
|
|
422
|
-
|
|
423
|
-
if self.query_one("#category-list").has_focus:
|
|
424
|
-
tool_list = self.query_one("#tool-list", ListView)
|
|
425
|
-
tool_list.focus()
|
|
426
|
-
|
|
427
|
-
# Ensure index is valid and trigger highlight refresh
|
|
428
|
-
if tool_list.children:
|
|
429
|
-
if tool_list.index is None:
|
|
430
|
-
tool_list.index = 0
|
|
431
|
-
else:
|
|
432
|
-
# Force property update to ensure highlight renders
|
|
433
|
-
idx = tool_list.index
|
|
434
|
-
tool_list.index = None
|
|
435
|
-
tool_list.index = idx
|
|
152
|
+
self.query_one(ToolBrowser).focus_right()
|
|
436
153
|
|
|
437
154
|
def action_cursor_left(self) -> None:
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
155
|
+
self.query_one(ToolBrowser).focus_left()
|
|
156
|
+
|
|
157
|
+
def action_launch_current(self) -> None:
|
|
158
|
+
tool = self.query_one(ToolBrowser).get_current_selection()
|
|
159
|
+
if tool:
|
|
160
|
+
self.launch_tool_flow(tool)
|
|
161
|
+
elif self.search_query:
|
|
162
|
+
# If nothing selected but search is active, maybe launch top hit?
|
|
163
|
+
# For now, do nothing.
|
|
164
|
+
pass
|
|
441
165
|
|
|
442
166
|
def action_toggle_favorite(self) -> None:
|
|
443
|
-
|
|
444
|
-
if
|
|
445
|
-
|
|
446
|
-
if
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
if isinstance(self.app, NexusApp):
|
|
451
|
-
self.app.container.state_manager.toggle_favorite(item.tool_info.command)
|
|
452
|
-
# Refresh to show star or remove from Favorites list if active
|
|
453
|
-
self.refresh_tools()
|
|
454
|
-
self.notify("Toggled Favorite")
|
|
167
|
+
tool = self.query_one(ToolBrowser).get_current_selection()
|
|
168
|
+
if tool:
|
|
169
|
+
from nexus.app import NexusApp
|
|
170
|
+
if isinstance(self.app, NexusApp):
|
|
171
|
+
self.app.container.state_manager.toggle_favorite(tool.command)
|
|
172
|
+
self.query_one(ToolBrowser).refresh_tools()
|
|
173
|
+
self.notify("Toggled Favorite")
|
|
455
174
|
|
|
456
|
-
def
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
# Ensure index is valid
|
|
460
|
-
if tool_list.index is not None and tool_list.index < len(tool_list.children):
|
|
461
|
-
item = tool_list.children[tool_list.index]
|
|
462
|
-
if isinstance(item, ToolListItem):
|
|
463
|
-
self.launch_tool_flow(item.tool_info)
|
|
175
|
+
def action_show_help(self) -> None:
|
|
176
|
+
from nexus.screens.help import HelpScreen
|
|
177
|
+
self.app.push_screen(HelpScreen())
|
|
464
178
|
|
|
465
|
-
|
|
466
|
-
"""Handles the flow for launching a tool.
|
|
179
|
+
# --- Responsive Design ---
|
|
467
180
|
|
|
468
|
-
|
|
469
|
-
|
|
181
|
+
def on_resize(self, event: Resize) -> None:
|
|
182
|
+
"""Called when the screen is resized."""
|
|
183
|
+
# ToolBrowser responsive mode
|
|
184
|
+
browser = self.query_one(ToolBrowser)
|
|
185
|
+
if event.size.width < 100:
|
|
186
|
+
browser.add_class("compact")
|
|
187
|
+
else:
|
|
188
|
+
browser.remove_class("compact")
|
|
189
|
+
|
|
190
|
+
# Footer responsive mode - No longer needed for minimal footer
|
|
191
|
+
# The minimal footer centers automatically and wraps gracefully if needed.
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
# --- Message Handlers from ToolBrowser ---
|
|
195
|
+
|
|
196
|
+
def on_tool_browser_tool_highlighted(self, message: ToolBrowser.ToolHighlighted) -> None:
|
|
197
|
+
"""Update description when a tool is highlighted."""
|
|
198
|
+
self.query_one("#tool-description", Label).update(
|
|
199
|
+
f"{message.tool.label}: {message.tool.description}"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def on_tool_browser_tool_selected(self, message: ToolBrowser.ToolSelected) -> None:
|
|
203
|
+
"""Launch tool when selected."""
|
|
204
|
+
self.launch_tool_flow(message.tool)
|
|
205
|
+
|
|
206
|
+
# --- Footer Interaction ---
|
|
207
|
+
|
|
208
|
+
def on_key_badge_pressed(self, message: KeyBadge.Pressed) -> None:
|
|
209
|
+
"""Handle clicks on footer key badges."""
|
|
210
|
+
action = message.action
|
|
211
|
+
if action == "launch_current":
|
|
212
|
+
self.action_launch_current()
|
|
213
|
+
elif action == "toggle_favorite":
|
|
214
|
+
self.action_toggle_favorite()
|
|
215
|
+
elif action == "show_help":
|
|
216
|
+
self.action_show_help()
|
|
217
|
+
elif action == "app.quit":
|
|
218
|
+
self.app.exit()
|
|
219
|
+
|
|
220
|
+
# --- Launch Logic ---
|
|
470
221
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
"""
|
|
222
|
+
def launch_tool_flow(self, tool: Tool) -> None:
|
|
223
|
+
"""Handles the flow for launching a tool."""
|
|
474
224
|
if tool.requires_project:
|
|
475
225
|
from nexus.screens.project_picker import ProjectPicker
|
|
476
|
-
|
|
477
226
|
self.app.push_screen(ProjectPicker(tool))
|
|
478
227
|
else:
|
|
479
228
|
self.execute_tool_command(tool)
|
|
480
229
|
|
|
481
230
|
def execute_tool_command(self, tool: Tool) -> None:
|
|
482
|
-
"""Executes the tool command with suspend context.
|
|
483
|
-
|
|
484
|
-
Args:
|
|
485
|
-
tool: The Tool object to execute.
|
|
486
|
-
"""
|
|
487
|
-
# Launch directly with suspend
|
|
231
|
+
"""Executes the tool command with suspend context."""
|
|
488
232
|
with self.app.suspend():
|
|
489
233
|
from nexus.app import NexusApp
|
|
490
|
-
|
|
491
234
|
if isinstance(self.app, NexusApp):
|
|
492
235
|
success = self.app.container.executor.launch_tool(tool.command)
|
|
493
236
|
else:
|
|
494
|
-
# Fallback or strict error
|
|
495
237
|
success = False
|
|
496
238
|
|
|
497
239
|
if not success:
|
|
498
|
-
# We can't see this notification until we return, but that's fine
|
|
499
240
|
self.app.notify(f"Failed to launch {tool.label}", severity="error")
|
|
500
241
|
|
|
501
242
|
self.app.refresh()
|
nexus/state.py
CHANGED
|
@@ -36,11 +36,15 @@ class StateManager:
|
|
|
36
36
|
log.error("load_state_failed", error=str(e))
|
|
37
37
|
|
|
38
38
|
def _save(self) -> None:
|
|
39
|
-
"""Saves state to disk."""
|
|
39
|
+
"""Saves state to disk (atomic)."""
|
|
40
40
|
try:
|
|
41
41
|
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
-
|
|
42
|
+
tmp_file = STATE_FILE.with_suffix(".tmp")
|
|
43
|
+
with open(tmp_file, "w") as f:
|
|
43
44
|
json.dump(self._state, f, indent=2)
|
|
45
|
+
|
|
46
|
+
# Atomic replace
|
|
47
|
+
tmp_file.replace(STATE_FILE)
|
|
44
48
|
except Exception as e:
|
|
45
49
|
log.error("save_state_failed", error=str(e))
|
|
46
50
|
|