nexus-tui 0.1.4__py3-none-any.whl → 0.1.13__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/__init__.py +1 -0
- nexus/app.py +106 -0
- nexus/config.py +197 -0
- nexus/container.py +45 -0
- nexus/logger.py +48 -0
- nexus/models.py +43 -0
- nexus/screens/__init__.py +0 -0
- nexus/screens/create_project.py +115 -0
- nexus/screens/error.py +57 -0
- nexus/screens/help.py +62 -0
- nexus/screens/project_picker.py +250 -0
- nexus/screens/theme_picker.py +101 -0
- nexus/screens/tool_selector.py +501 -0
- nexus/services/__init__.py +0 -0
- nexus/services/executor.py +46 -0
- nexus/services/scanner.py +46 -0
- nexus/state.py +84 -0
- nexus/style.tcss +500 -0
- nexus/widgets/__init__.py +0 -0
- nexus/widgets/tool_list_item.py +194 -0
- {nexus_tui-0.1.4.dist-info → nexus_tui-0.1.13.dist-info}/METADATA +4 -1
- nexus_tui-0.1.13.dist-info/RECORD +26 -0
- nexus_tui-0.1.4.dist-info/RECORD +0 -6
- {nexus_tui-0.1.4.dist-info → nexus_tui-0.1.13.dist-info}/WHEEL +0 -0
- {nexus_tui-0.1.4.dist-info → nexus_tui-0.1.13.dist-info}/entry_points.txt +0 -0
- {nexus_tui-0.1.4.dist-info → nexus_tui-0.1.13.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
"""Main screen for tool selection and launching.
|
|
2
|
+
|
|
3
|
+
Displays a categorized list of tools and handles user navigation, searching,
|
|
4
|
+
and execution.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.containers import Horizontal, Vertical
|
|
9
|
+
from textual.events import Key
|
|
10
|
+
from textual.reactive import reactive
|
|
11
|
+
from textual.screen import Screen
|
|
12
|
+
from textual.widgets import Label, ListView
|
|
13
|
+
|
|
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
|
+
from nexus.models import Tool
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ToolSelector(Screen[None]):
|
|
21
|
+
"""Screen for selecting and launching tools.
|
|
22
|
+
|
|
23
|
+
Displays a list of tools categorized by type. Allows searching,
|
|
24
|
+
filtering, and keyboard navigation.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
search_query: The current search filter text.
|
|
28
|
+
BINDINGS: Key bindings for the screen.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
CSS_PATH = "../style.tcss"
|
|
32
|
+
|
|
33
|
+
# Reactive search query
|
|
34
|
+
search_query = reactive("")
|
|
35
|
+
|
|
36
|
+
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
|
+
("f1", "show_help", "Help"),
|
|
48
|
+
]
|
|
49
|
+
|
|
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
|
+
def compose(self) -> ComposeResult:
|
|
57
|
+
"""Composes the screen layout.
|
|
58
|
+
|
|
59
|
+
Yields:
|
|
60
|
+
The widget tree for the screen.
|
|
61
|
+
"""
|
|
62
|
+
with Horizontal(id="header"):
|
|
63
|
+
yield Label(
|
|
64
|
+
"**********************************\n Nexus Interface \n**********************************",
|
|
65
|
+
id="header-left",
|
|
66
|
+
)
|
|
67
|
+
yield Label("Search tools...", id="tool-search")
|
|
68
|
+
|
|
69
|
+
with Horizontal(id="main-container"):
|
|
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
|
+
)
|
|
84
|
+
|
|
85
|
+
yield Label("", id="tool-description")
|
|
86
|
+
|
|
87
|
+
# New Three-Column Footer
|
|
88
|
+
with Horizontal(id="footer-container"):
|
|
89
|
+
# NAV
|
|
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
|
|
118
|
+
THEMES = ["theme-light", "theme-dark", "theme-storm"]
|
|
119
|
+
current_theme_index = 0
|
|
120
|
+
|
|
121
|
+
def action_show_theme_picker(self) -> None:
|
|
122
|
+
"""Opens the theme picker modal."""
|
|
123
|
+
|
|
124
|
+
# Helper to apply theme temporarily or permanently
|
|
125
|
+
def apply_theme(new_theme: str) -> None:
|
|
126
|
+
self.set_theme(new_theme)
|
|
127
|
+
|
|
128
|
+
from nexus.screens.theme_picker import ThemePicker
|
|
129
|
+
|
|
130
|
+
current_theme = self.THEMES[self.current_theme_index]
|
|
131
|
+
self.app.push_screen(ThemePicker(self.THEMES, current_theme, apply_theme))
|
|
132
|
+
|
|
133
|
+
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
|
|
140
|
+
for theme in self.THEMES:
|
|
141
|
+
if theme in self.classes:
|
|
142
|
+
self.remove_class(theme)
|
|
143
|
+
|
|
144
|
+
self.add_class(new_theme)
|
|
145
|
+
|
|
146
|
+
# Update index if it's one of ours
|
|
147
|
+
if new_theme in self.THEMES:
|
|
148
|
+
self.current_theme_index = self.THEMES.index(new_theme)
|
|
149
|
+
|
|
150
|
+
suffix = new_theme.replace("theme-", "").title()
|
|
151
|
+
self.notify(f"Theme: Tokyo Night {suffix}")
|
|
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)
|
|
170
|
+
|
|
171
|
+
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
|
+
"""
|
|
176
|
+
self.add_class(self.THEMES[self.current_theme_index])
|
|
177
|
+
self.populate_categories()
|
|
178
|
+
# Default focus to categories
|
|
179
|
+
self.query_one("#category-list").focus()
|
|
180
|
+
|
|
181
|
+
# Report any config errors from loading phase
|
|
182
|
+
from nexus.config import CONFIG_ERRORS
|
|
183
|
+
for error in CONFIG_ERRORS:
|
|
184
|
+
self.app.notify(error, title="Config Error", severity="error", timeout=5.0)
|
|
185
|
+
|
|
186
|
+
def select_all_category(self) -> None:
|
|
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
|
|
195
|
+
|
|
196
|
+
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
|
+
"""
|
|
203
|
+
try:
|
|
204
|
+
feedback = self.query_one("#tool-search", Label)
|
|
205
|
+
except Exception:
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
if new_value:
|
|
209
|
+
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
|
+
else:
|
|
215
|
+
feedback.update("Search tools...")
|
|
216
|
+
# Re-populate without filter
|
|
217
|
+
self.refresh_tools()
|
|
218
|
+
|
|
219
|
+
def action_delete_char(self) -> None:
|
|
220
|
+
"""Deletes the last character from search query."""
|
|
221
|
+
if self.search_query:
|
|
222
|
+
self.search_query = self.search_query[:-1]
|
|
223
|
+
|
|
224
|
+
def action_clear_search(self) -> None:
|
|
225
|
+
"""Clears the search input."""
|
|
226
|
+
if self.search_query:
|
|
227
|
+
self.search_query = ""
|
|
228
|
+
|
|
229
|
+
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
|
+
"""
|
|
235
|
+
# Numeric keys 1-9 for quick launch
|
|
236
|
+
if event.key in "123456789":
|
|
237
|
+
idx = int(event.key) - 1
|
|
238
|
+
tool_list = self.query_one("#tool-list", ListView)
|
|
239
|
+
if idx < len(tool_list.children):
|
|
240
|
+
item = tool_list.children[idx]
|
|
241
|
+
if isinstance(item, ToolListItem):
|
|
242
|
+
self.launch_tool_flow(item.tool_info)
|
|
243
|
+
event.stop()
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
if event.key.isprintable() and len(event.key) == 1:
|
|
247
|
+
# Append char to query
|
|
248
|
+
self.search_query += event.key
|
|
249
|
+
event.stop()
|
|
250
|
+
|
|
251
|
+
def refresh_tools(self) -> None:
|
|
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)
|
|
390
|
+
|
|
391
|
+
def action_cursor_down(self) -> None:
|
|
392
|
+
"""Moves selection down in the active list."""
|
|
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)
|
|
408
|
+
|
|
409
|
+
def action_cursor_up(self) -> None:
|
|
410
|
+
"""Moves selection up in the active list."""
|
|
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)
|
|
420
|
+
|
|
421
|
+
def action_cursor_right(self) -> None:
|
|
422
|
+
"""Moves focus from categories to tools."""
|
|
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
|
|
436
|
+
|
|
437
|
+
def action_cursor_left(self) -> None:
|
|
438
|
+
"""Moves focus from tools back to categories."""
|
|
439
|
+
if self.query_one("#tool-list").has_focus:
|
|
440
|
+
self.query_one("#category-list").focus()
|
|
441
|
+
|
|
442
|
+
def action_toggle_favorite(self) -> None:
|
|
443
|
+
"""Toggles the favorite status of the selected tool."""
|
|
444
|
+
if self.query_one("#tool-list").has_focus:
|
|
445
|
+
tool_list = self.query_one("#tool-list", ListView)
|
|
446
|
+
if tool_list.index is not None and tool_list.index < len(tool_list.children):
|
|
447
|
+
item = tool_list.children[tool_list.index]
|
|
448
|
+
if isinstance(item, ToolListItem):
|
|
449
|
+
from nexus.app import NexusApp
|
|
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")
|
|
455
|
+
|
|
456
|
+
def action_launch_current(self) -> None:
|
|
457
|
+
"""Launches the currently selected tool."""
|
|
458
|
+
tool_list = self.query_one("#tool-list", ListView)
|
|
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)
|
|
464
|
+
|
|
465
|
+
def launch_tool_flow(self, tool: Tool) -> None:
|
|
466
|
+
"""Handles the flow for launching a tool.
|
|
467
|
+
|
|
468
|
+
If the tool requires a project, opens the ProjectPicker.
|
|
469
|
+
Otherwise, executes the tool command directly.
|
|
470
|
+
|
|
471
|
+
Args:
|
|
472
|
+
tool: The Tool object to launch.
|
|
473
|
+
"""
|
|
474
|
+
if tool.requires_project:
|
|
475
|
+
from nexus.screens.project_picker import ProjectPicker
|
|
476
|
+
|
|
477
|
+
self.app.push_screen(ProjectPicker(tool))
|
|
478
|
+
else:
|
|
479
|
+
self.execute_tool_command(tool)
|
|
480
|
+
|
|
481
|
+
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
|
|
488
|
+
with self.app.suspend():
|
|
489
|
+
from nexus.app import NexusApp
|
|
490
|
+
|
|
491
|
+
if isinstance(self.app, NexusApp):
|
|
492
|
+
success = self.app.container.executor.launch_tool(tool.command)
|
|
493
|
+
else:
|
|
494
|
+
# Fallback or strict error
|
|
495
|
+
success = False
|
|
496
|
+
|
|
497
|
+
if not success:
|
|
498
|
+
# We can't see this notification until we return, but that's fine
|
|
499
|
+
self.app.notify(f"Failed to launch {tool.label}", severity="error")
|
|
500
|
+
|
|
501
|
+
self.app.refresh()
|
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Service for executing tool commands.
|
|
2
|
+
|
|
3
|
+
Handles the subprocess execution logic for running external tools within the terminal.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import shlex
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def launch_tool(command: str, project_path: Path | None = None) -> bool:
|
|
13
|
+
"""Launches a tool in the current terminal window.
|
|
14
|
+
|
|
15
|
+
This function blocks execution until the tool completes. It should typically
|
|
16
|
+
be called within a suspended TUI context.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
command: The shell command to execute.
|
|
20
|
+
project_path: Optional working directory for the command.
|
|
21
|
+
if provided, the command path is appended to the command arguments,
|
|
22
|
+
and the command is executed in this directory.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
True if the process started and exited with return code 0, False otherwise.
|
|
26
|
+
"""
|
|
27
|
+
if not command:
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
# On Windows, shlex should be in non-POSIX mode to handle backslashes correctly
|
|
31
|
+
is_windows = os.name == "nt"
|
|
32
|
+
cmd_parts = shlex.split(command, posix=not is_windows)
|
|
33
|
+
|
|
34
|
+
if project_path:
|
|
35
|
+
cmd_parts.append(str(project_path))
|
|
36
|
+
|
|
37
|
+
cwd = project_path if project_path and project_path.exists() else None
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
# Run tool in the current terminal (blocking execution).
|
|
41
|
+
# This allows the tool to take over the TUI's terminal IO.
|
|
42
|
+
result = subprocess.run(cmd_parts, cwd=cwd, check=False)
|
|
43
|
+
return result.returncode == 0
|
|
44
|
+
except (FileNotFoundError, OSError):
|
|
45
|
+
return False
|
|
46
|
+
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Service for scanning the filesystem.
|
|
2
|
+
|
|
3
|
+
Provides asynchronous methods to discover projects and git repositories
|
|
4
|
+
within the configured project root.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from nexus.models import Project
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def scan_projects(root_path: Path) -> list[Project]:
|
|
14
|
+
"""Scans the root path for project directories asynchronously.
|
|
15
|
+
|
|
16
|
+
Identifies directories and checks for the presence of a `.git` folder to
|
|
17
|
+
mark them as git repositories. The I/O operation runs in a separate thread.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
root_path: The root directory to scan for subdirectories.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
A list of Project objects representing the found directories, sorted
|
|
24
|
+
alphabetically by name.
|
|
25
|
+
"""
|
|
26
|
+
if not root_path.exists():
|
|
27
|
+
return []
|
|
28
|
+
|
|
29
|
+
projects = []
|
|
30
|
+
|
|
31
|
+
# Run directory listing in a thread to avoid blocking the event loop
|
|
32
|
+
loop = asyncio.get_running_loop()
|
|
33
|
+
|
|
34
|
+
def get_dirs() -> list[Path]:
|
|
35
|
+
try:
|
|
36
|
+
return [d for d in root_path.iterdir() if d.is_dir()]
|
|
37
|
+
except PermissionError:
|
|
38
|
+
return []
|
|
39
|
+
|
|
40
|
+
dirs = await loop.run_in_executor(None, get_dirs)
|
|
41
|
+
|
|
42
|
+
for d in sorted(dirs, key=lambda x: x.name.lower()):
|
|
43
|
+
is_git = (d / ".git").exists()
|
|
44
|
+
projects.append(Project(name=d.name, path=d, is_git=is_git))
|
|
45
|
+
|
|
46
|
+
return projects
|
nexus/state.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""State management for Nexus.
|
|
2
|
+
|
|
3
|
+
Handles persistence of user data such as recent projects and favorite tools.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
import platformdirs
|
|
11
|
+
from nexus.logger import get_logger
|
|
12
|
+
|
|
13
|
+
log = get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
STATE_FILE = Path(platformdirs.user_data_dir("nexus", roaming=True)) / "state.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StateManager:
|
|
19
|
+
"""Manages persistent application state."""
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
"""Initialize the state manager."""
|
|
23
|
+
self._state: dict[str, Any] = {
|
|
24
|
+
"recents": [],
|
|
25
|
+
"favorites": [],
|
|
26
|
+
}
|
|
27
|
+
self._load()
|
|
28
|
+
|
|
29
|
+
def _load(self) -> None:
|
|
30
|
+
"""Loads state from disk."""
|
|
31
|
+
if STATE_FILE.exists():
|
|
32
|
+
try:
|
|
33
|
+
with open(STATE_FILE, "r") as f:
|
|
34
|
+
self._state.update(json.load(f))
|
|
35
|
+
except Exception as e:
|
|
36
|
+
log.error("load_state_failed", error=str(e))
|
|
37
|
+
|
|
38
|
+
def _save(self) -> None:
|
|
39
|
+
"""Saves state to disk."""
|
|
40
|
+
try:
|
|
41
|
+
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
with open(STATE_FILE, "w") as f:
|
|
43
|
+
json.dump(self._state, f, indent=2)
|
|
44
|
+
except Exception as e:
|
|
45
|
+
log.error("save_state_failed", error=str(e))
|
|
46
|
+
|
|
47
|
+
def get_recents(self) -> list[str]:
|
|
48
|
+
"""Returns the list of recent project paths."""
|
|
49
|
+
return cast(list[str], self._state.get("recents", []))
|
|
50
|
+
|
|
51
|
+
def add_recent(self, path: str) -> None:
|
|
52
|
+
"""Adds a path to recent projects."""
|
|
53
|
+
recents = self.get_recents()
|
|
54
|
+
if path in recents:
|
|
55
|
+
recents.remove(path)
|
|
56
|
+
recents.insert(0, path)
|
|
57
|
+
self._state["recents"] = recents[:10] # Keep last 10
|
|
58
|
+
self._save()
|
|
59
|
+
|
|
60
|
+
def get_favorites(self) -> list[str]:
|
|
61
|
+
"""Returns the list of favorite tool IDs/labels."""
|
|
62
|
+
return cast(list[str], self._state.get("favorites", []))
|
|
63
|
+
|
|
64
|
+
def toggle_favorite(self, tool_id: str) -> None:
|
|
65
|
+
"""Toggles a tool as favorite."""
|
|
66
|
+
favorites = self.get_favorites()
|
|
67
|
+
if tool_id in favorites:
|
|
68
|
+
favorites.remove(tool_id)
|
|
69
|
+
else:
|
|
70
|
+
favorites.append(tool_id)
|
|
71
|
+
self._state["favorites"] = favorites
|
|
72
|
+
self._save()
|
|
73
|
+
|
|
74
|
+
def is_favorite(self, tool_id: str) -> bool:
|
|
75
|
+
"""Checks if a tool is a favorite."""
|
|
76
|
+
return tool_id in self.get_favorites()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# Global instance
|
|
80
|
+
_state_manager = StateManager()
|
|
81
|
+
|
|
82
|
+
def get_state_manager() -> StateManager:
|
|
83
|
+
"""Returns the global state manager."""
|
|
84
|
+
return _state_manager
|