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,250 @@
|
|
|
1
|
+
"""Screen for selecting a project directory.
|
|
2
|
+
|
|
3
|
+
Allows users to browse potential project directories and choose one as the
|
|
4
|
+
context for a tool execution. Also supports creating new projects.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
from nexus.config import get_project_root
|
|
10
|
+
from nexus.models import Project, Tool
|
|
11
|
+
from typing import Any
|
|
12
|
+
from nexus.widgets.tool_list_item import ProjectListItem
|
|
13
|
+
from textual.app import ComposeResult
|
|
14
|
+
from textual.containers import Container
|
|
15
|
+
from textual.screen import Screen
|
|
16
|
+
from textual.widgets import Footer, Header, Input, Label, ListItem, ListView, LoadingIndicator
|
|
17
|
+
from thefuzz import process
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ProjectPicker(Screen[None]):
|
|
21
|
+
"""Screen for selecting a project directory.
|
|
22
|
+
|
|
23
|
+
Displays a searchable list of projects found in the configured root directory.
|
|
24
|
+
Allows creating new projects or selecting an existing one.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
selected_tool: The tool that was selected and requires a project context.
|
|
28
|
+
projects: List of discovered projects.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, selected_tool: Tool, **kwargs: Any):
|
|
32
|
+
"""Initializes the ProjectPicker.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
selected_tool: The tool that was selected and requires a project context.
|
|
36
|
+
**kwargs: Additional arguments passed to the Screen.
|
|
37
|
+
"""
|
|
38
|
+
super().__init__(**kwargs)
|
|
39
|
+
self.selected_tool = selected_tool
|
|
40
|
+
self.projects: list[Project] = []
|
|
41
|
+
|
|
42
|
+
BINDINGS = [
|
|
43
|
+
("down", "cursor_down", "Next Item"),
|
|
44
|
+
("up", "cursor_up", "Previous Item"),
|
|
45
|
+
("enter", "select_current", "Select"),
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
def compose(self) -> ComposeResult:
|
|
49
|
+
"""Composes the screen layout.
|
|
50
|
+
|
|
51
|
+
Yields:
|
|
52
|
+
The widget tree for the screen.
|
|
53
|
+
"""
|
|
54
|
+
yield Header()
|
|
55
|
+
yield Container(
|
|
56
|
+
Label(f"Select Project for {self.selected_tool.label}", id="title"),
|
|
57
|
+
id="title-container",
|
|
58
|
+
)
|
|
59
|
+
yield Input(placeholder="Search projects...", id="project-search")
|
|
60
|
+
with Container(id="list-container"):
|
|
61
|
+
yield LoadingIndicator(id="loading-spinner")
|
|
62
|
+
yield ListView(id="project-list")
|
|
63
|
+
yield Label(
|
|
64
|
+
"No projects found", id="projects-empty", classes="empty-state hidden"
|
|
65
|
+
)
|
|
66
|
+
yield Footer()
|
|
67
|
+
|
|
68
|
+
async def on_mount(self) -> None:
|
|
69
|
+
"""Called when the screen is mounted.
|
|
70
|
+
|
|
71
|
+
Initiates an asynchronous scan of the project root directory.
|
|
72
|
+
"""
|
|
73
|
+
self.query_one("#project-list").display = False
|
|
74
|
+
self.query_one("#project-search").focus()
|
|
75
|
+
|
|
76
|
+
root = get_project_root()
|
|
77
|
+
from nexus.app import NexusApp
|
|
78
|
+
|
|
79
|
+
if isinstance(self.app, NexusApp):
|
|
80
|
+
self.projects = await self.app.container.scanner.scan_projects(root)
|
|
81
|
+
|
|
82
|
+
self.populate_list()
|
|
83
|
+
self.query_one("#loading-spinner").display = False
|
|
84
|
+
self.query_one("#project-list").display = True
|
|
85
|
+
|
|
86
|
+
def populate_list(self, filter_text: str = "") -> None:
|
|
87
|
+
"""Populates the project list, processing the filter text.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
filter_text: Text to filter project names by.
|
|
91
|
+
"""
|
|
92
|
+
project_list = self.query_one("#project-list", ListView)
|
|
93
|
+
project_list.clear()
|
|
94
|
+
|
|
95
|
+
# Add "Create New Project" option
|
|
96
|
+
if not filter_text:
|
|
97
|
+
new_item = ProjectListItem(is_create_new=True)
|
|
98
|
+
project_list.append(new_item)
|
|
99
|
+
|
|
100
|
+
# --- Recents Section ---
|
|
101
|
+
from nexus.app import NexusApp
|
|
102
|
+
if isinstance(self.app, NexusApp):
|
|
103
|
+
recents = self.app.container.state_manager.get_recents()
|
|
104
|
+
if recents:
|
|
105
|
+
# Find project objects for recent paths
|
|
106
|
+
recent_projects = []
|
|
107
|
+
for path_str in recents:
|
|
108
|
+
# Find matching project in discovered list
|
|
109
|
+
match = next((p for p in self.projects if str(p.path) == path_str), None)
|
|
110
|
+
if match:
|
|
111
|
+
recent_projects.append(match)
|
|
112
|
+
|
|
113
|
+
if recent_projects:
|
|
114
|
+
project_list.append(ListItem(Label("[bold yellow]Recent Projects[/]"), classes="list-item"))
|
|
115
|
+
for proj in recent_projects:
|
|
116
|
+
project_list.append(ProjectListItem(project_data=proj))
|
|
117
|
+
|
|
118
|
+
project_list.append(ListItem(Label("[bold blue]All Projects[/]"), classes="list-item"))
|
|
119
|
+
|
|
120
|
+
# Filter out recents from main list to avoid duplication
|
|
121
|
+
recent_paths = {str(p.path) for p in recent_projects}
|
|
122
|
+
for project in self.projects:
|
|
123
|
+
if str(project.path) not in recent_paths:
|
|
124
|
+
project_list.append(ProjectListItem(project_data=project))
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
# --- Fuzzy Search or Default List ---
|
|
128
|
+
if filter_text:
|
|
129
|
+
# Prepare data for fuzzy matching
|
|
130
|
+
choices = {p.name: p for p in self.projects}
|
|
131
|
+
# extract returns list of (choice, score, key)
|
|
132
|
+
results = process.extract(filter_text, choices.keys(), limit=20)
|
|
133
|
+
|
|
134
|
+
for name, score in results:
|
|
135
|
+
if score > 40: # Threshold
|
|
136
|
+
project = choices[name]
|
|
137
|
+
project_list.append(ProjectListItem(project_data=project))
|
|
138
|
+
else:
|
|
139
|
+
# Default lexical sort
|
|
140
|
+
for project in self.projects:
|
|
141
|
+
project_list.append(ProjectListItem(project_data=project))
|
|
142
|
+
|
|
143
|
+
# Check if list is effectively empty
|
|
144
|
+
if not project_list.children:
|
|
145
|
+
project_list.display = False
|
|
146
|
+
empty_lbl = self.query_one("#projects-empty", Label)
|
|
147
|
+
empty_lbl.remove_class("hidden")
|
|
148
|
+
if filter_text:
|
|
149
|
+
empty_lbl.update(f"No projects matching '{filter_text}'")
|
|
150
|
+
else:
|
|
151
|
+
empty_lbl.update("No projects found in root directory.")
|
|
152
|
+
else:
|
|
153
|
+
project_list.display = True
|
|
154
|
+
self.query_one("#projects-empty").add_class("hidden")
|
|
155
|
+
|
|
156
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
157
|
+
"""Called when the project search input changes.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
event: The input changed event.
|
|
161
|
+
"""
|
|
162
|
+
if event.input.id == "project-search":
|
|
163
|
+
self.populate_list(event.value)
|
|
164
|
+
|
|
165
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
166
|
+
"""Called when Enter is pressed in the search input.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
event: The input submitted event.
|
|
170
|
+
"""
|
|
171
|
+
self.action_select_current()
|
|
172
|
+
|
|
173
|
+
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
174
|
+
"""Called when a project is selected from the list.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
event: The selection event.
|
|
178
|
+
"""
|
|
179
|
+
self._select_item(event.item)
|
|
180
|
+
|
|
181
|
+
def _select_item(self, item: Any) -> None:
|
|
182
|
+
"""Internal method to handle item selection logic.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
item: The selected ListItem.
|
|
186
|
+
"""
|
|
187
|
+
if not isinstance(item, ProjectListItem):
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
if item.is_create_new:
|
|
191
|
+
from nexus.screens.create_project import CreateProject
|
|
192
|
+
|
|
193
|
+
def on_created(new_project_name: str) -> None:
|
|
194
|
+
self.app.notify(f"Created project: {new_project_name}")
|
|
195
|
+
# Refresh list and try to find the new project
|
|
196
|
+
import asyncio
|
|
197
|
+
|
|
198
|
+
async def refresh() -> None:
|
|
199
|
+
if isinstance(self.app, NexusApp):
|
|
200
|
+
root = get_project_root()
|
|
201
|
+
self.projects = await self.app.container.scanner.scan_projects(root)
|
|
202
|
+
self.populate_list(filter_text=new_project_name)
|
|
203
|
+
|
|
204
|
+
asyncio.create_task(refresh())
|
|
205
|
+
|
|
206
|
+
self.app.push_screen(CreateProject(on_created))
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
project = item.project_data
|
|
210
|
+
if project:
|
|
211
|
+
with self.app.suspend():
|
|
212
|
+
from nexus.app import NexusApp
|
|
213
|
+
|
|
214
|
+
if isinstance(self.app, NexusApp):
|
|
215
|
+
if self.app.container.executor.launch_tool(
|
|
216
|
+
self.selected_tool.command, project.path
|
|
217
|
+
):
|
|
218
|
+
# Save to Recents
|
|
219
|
+
self.app.container.state_manager.add_recent(str(project.path))
|
|
220
|
+
pass
|
|
221
|
+
else:
|
|
222
|
+
self.app.notify(
|
|
223
|
+
f"Failed to launch {self.selected_tool.label}", severity="error"
|
|
224
|
+
)
|
|
225
|
+
self.app.refresh()
|
|
226
|
+
self.app.pop_screen()
|
|
227
|
+
|
|
228
|
+
def action_cursor_down(self) -> None:
|
|
229
|
+
"""Moves selection down in the project list."""
|
|
230
|
+
project_list = self.query_one("#project-list", ListView)
|
|
231
|
+
if project_list.index is None:
|
|
232
|
+
project_list.index = 0
|
|
233
|
+
else:
|
|
234
|
+
project_list.index = min(
|
|
235
|
+
len(project_list.children) - 1, project_list.index + 1
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
def action_cursor_up(self) -> None:
|
|
239
|
+
"""Moves selection up in the project list."""
|
|
240
|
+
project_list = self.query_one("#project-list", ListView)
|
|
241
|
+
if project_list.index is None:
|
|
242
|
+
project_list.index = 0
|
|
243
|
+
else:
|
|
244
|
+
project_list.index = max(0, project_list.index - 1)
|
|
245
|
+
|
|
246
|
+
def action_select_current(self) -> None:
|
|
247
|
+
"""Selects the currently highlighted item."""
|
|
248
|
+
project_list = self.query_one("#project-list", ListView)
|
|
249
|
+
if project_list.index is not None:
|
|
250
|
+
self._select_item(project_list.children[project_list.index])
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Screen for selecting the application theme.
|
|
2
|
+
|
|
3
|
+
Provides a modal list of available themes with live preview capability.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.binding import Binding
|
|
9
|
+
from textual.containers import Container
|
|
10
|
+
from textual.screen import ModalScreen
|
|
11
|
+
from textual.widgets import Label, ListItem, ListView
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ThemePicker(ModalScreen[None]):
|
|
15
|
+
"""A modal screen for selecting a theme with live preview.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
themes: List of available theme CSS classes.
|
|
19
|
+
original_theme: The theme active when the picker was opened.
|
|
20
|
+
on_preview_callback: Callback function to apply a preview theme.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
CSS_PATH = "../style.tcss"
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self, themes: list[str], current_theme: str, on_preview: Callable[[str], None], **kwargs: Any
|
|
27
|
+
):
|
|
28
|
+
"""Initializes the ThemePicker.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
themes: List of available theme CSS class names.
|
|
32
|
+
current_theme: The currently active theme class name.
|
|
33
|
+
on_preview: Callback to receive the selected theme string for preview.
|
|
34
|
+
**kwargs: Additional arguments passed to ModalScreen.
|
|
35
|
+
"""
|
|
36
|
+
super().__init__(**kwargs)
|
|
37
|
+
self.themes = themes
|
|
38
|
+
self.original_theme = current_theme
|
|
39
|
+
self.on_preview_callback = on_preview
|
|
40
|
+
|
|
41
|
+
BINDINGS = [
|
|
42
|
+
Binding("escape", "cancel", "Cancel"),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
def compose(self) -> ComposeResult:
|
|
46
|
+
"""Composes the screen layout.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
A ComposeResult containing the widget tree.
|
|
50
|
+
"""
|
|
51
|
+
with Container(id="theme-picker-dialog"):
|
|
52
|
+
yield Label("Select Theme", id="theme-picker-title")
|
|
53
|
+
yield ListView(id="theme-list")
|
|
54
|
+
yield Label("Esc: Cancel • Enter: Confirm", classes="modal-footer")
|
|
55
|
+
|
|
56
|
+
def on_mount(self) -> None:
|
|
57
|
+
"""Called when the screen is mounted.
|
|
58
|
+
|
|
59
|
+
Populates the list of themes.
|
|
60
|
+
"""
|
|
61
|
+
list_view = self.query_one("#theme-list", ListView)
|
|
62
|
+
for theme in self.themes:
|
|
63
|
+
# Clean up theme name for display (e.g., "theme-dark" -> "Tokyo Night Dark")
|
|
64
|
+
suffix = theme.replace("theme-", "").title()
|
|
65
|
+
display_name = f"Tokyo Night {suffix}"
|
|
66
|
+
item = ListItem(Label(display_name))
|
|
67
|
+
list_view.append(item)
|
|
68
|
+
|
|
69
|
+
# Select current theme
|
|
70
|
+
try:
|
|
71
|
+
current_index = self.themes.index(self.original_theme)
|
|
72
|
+
list_view.index = current_index
|
|
73
|
+
except ValueError:
|
|
74
|
+
list_view.index = 0
|
|
75
|
+
|
|
76
|
+
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
|
|
77
|
+
"""Previews the confirmed theme when highlighted.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
event: The highlight event.
|
|
81
|
+
"""
|
|
82
|
+
if event.list_view.index is not None:
|
|
83
|
+
# Prevent index out of bounds if list changes (unlikely)
|
|
84
|
+
if 0 <= event.list_view.index < len(self.themes):
|
|
85
|
+
new_theme = self.themes[event.list_view.index]
|
|
86
|
+
self.on_preview_callback(new_theme)
|
|
87
|
+
|
|
88
|
+
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
89
|
+
"""Confirms the selected theme.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
event: The selection event.
|
|
93
|
+
"""
|
|
94
|
+
# Theme is already applied by highlight, just close
|
|
95
|
+
self.dismiss()
|
|
96
|
+
|
|
97
|
+
def action_cancel(self) -> None:
|
|
98
|
+
"""Reverts the theme choice and closes the picker."""
|
|
99
|
+
self.on_preview_callback(self.original_theme)
|
|
100
|
+
self.dismiss()
|
|
101
|
+
|