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