nexus-tui 0.1.4__py3-none-any.whl → 0.1.5__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 ADDED
@@ -0,0 +1 @@
1
+ """Nexus TUI Application package."""
nexus/app.py ADDED
@@ -0,0 +1,106 @@
1
+ """Main application entry point for Nexus.
2
+
3
+ Configures the Textual application class, global bindings, and initial screen loading.
4
+ """
5
+
6
+ from typing import Any
7
+ from textual.app import App
8
+ from textual.notifications import SeverityLevel
9
+ from nexus.container import get_container
10
+ from nexus.screens.tool_selector import ToolSelector
11
+
12
+
13
+ class NexusApp(App[None]):
14
+ """The main Nexus application class.
15
+
16
+ Manages the application lifecycle, global bindings, and screen navigation.
17
+ """
18
+
19
+ CSS_PATH = "style.tcss"
20
+ BINDINGS = [
21
+ ("ctrl+q", "quit", "Quit"),
22
+ ("ctrl+c", "quit", "Quit"),
23
+ ("ctrl+b", "back", "Back"),
24
+ ]
25
+
26
+ def on_mount(self) -> None:
27
+ """Called when the application is mounted.
28
+
29
+ Push the ToolSelector screen to the stack on startup.
30
+ """
31
+ # Initialize services
32
+ self.container = get_container()
33
+
34
+ # Apply keybindings from config
35
+ self._apply_bindings()
36
+
37
+ self.push_screen(ToolSelector())
38
+
39
+ def _apply_bindings(self) -> None:
40
+ """Applies configurable keybindings."""
41
+ from nexus.config import get_keybindings
42
+ bindings = get_keybindings()
43
+
44
+ if "quit" in bindings:
45
+ self.bind(keys=bindings["quit"], action="quit", description="Quit")
46
+ if "force_quit" in bindings:
47
+ self.bind(keys=bindings["force_quit"], action="quit", description="Quit")
48
+ if "back" in bindings:
49
+ self.bind(keys=bindings["back"], action="back", description="Back")
50
+
51
+ async def action_back(self) -> None:
52
+ """Navigates back to the previous screen.
53
+
54
+ Removes the current screen from the stack if there is more than one
55
+ screen present.
56
+ """
57
+ if len(self.screen_stack) > 1:
58
+ self.pop_screen()
59
+
60
+ def notify(
61
+ self,
62
+ message: str,
63
+ *,
64
+ title: str = "",
65
+ severity: SeverityLevel = "information",
66
+ timeout: float | None = 1.0,
67
+ **kwargs: Any,
68
+ ) -> None:
69
+ """Override notify to use a shorter default timeout.
70
+
71
+ Args:
72
+ message: The message to display.
73
+ title: The title of the notification.
74
+ severity: The severity of the notification (e.g., 'information', 'error').
75
+ timeout: Duration in seconds to show the notification.
76
+ **kwargs: Additional keyword arguments passed to the parent notify method.
77
+ """
78
+ super().notify(
79
+ message, title=title, severity=severity, timeout=timeout, **kwargs
80
+ )
81
+
82
+ def show_error(self, title: str, message: str, details: str = "") -> None:
83
+ """Displays a modal error screen.
84
+
85
+ Args:
86
+ title: The title of the error.
87
+ message: The user-friendly error message.
88
+ details: Optional technical details.
89
+ """
90
+ from nexus.screens.error import ErrorScreen
91
+
92
+ self.push_screen(ErrorScreen(title, message, details))
93
+
94
+
95
+ def main() -> None:
96
+ """Entry point for the application."""
97
+ from nexus.logger import configure_logging
98
+
99
+ configure_logging()
100
+ app = NexusApp()
101
+ app.run()
102
+
103
+
104
+ if __name__ == "__main__":
105
+ main()
106
+
nexus/config.py ADDED
@@ -0,0 +1,197 @@
1
+ """Configuration management for the Nexus application.
2
+
3
+ Handles loading tool definitions from TOML, determining terminal preferences,
4
+ and defining visual assets like colors and icons.
5
+ """
6
+
7
+ import os
8
+ import shutil
9
+ import tomllib
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import platformdirs
14
+ from nexus.models import Tool
15
+
16
+ # Configuration Paths in priority order (lowest to highest)
17
+ CWD_NEXUS_CONFIG = Path.cwd() / "nexus" / "tools.local.toml"
18
+ CWD_CONFIG = Path.cwd() / "tools.local.toml"
19
+ USER_CONFIG_PATH = Path(platformdirs.user_config_dir("nexus", roaming=True)) / "tools.toml"
20
+ LOCAL_CONFIG_PATH = Path(__file__).parent / "tools.local.toml"
21
+ DEFAULT_CONFIG_PATH = Path(__file__).parent / "tools.toml"
22
+
23
+ CONFIG_PATHS = [
24
+ DEFAULT_CONFIG_PATH,
25
+ LOCAL_CONFIG_PATH,
26
+ USER_CONFIG_PATH,
27
+ CWD_NEXUS_CONFIG,
28
+ CWD_CONFIG,
29
+ ]
30
+
31
+
32
+ # Global error tracking for configuration loading
33
+ CONFIG_ERRORS: list[str] = []
34
+ _CONFIG_CACHE: dict[str, Any] | None = None
35
+
36
+
37
+ def _load_config_data() -> dict[str, Any]:
38
+ """Loads and merges configuration data from all sources (Lazy).
39
+
40
+ Iterates through configuration paths in priority order and merges meaningful
41
+ data (tools, project root) into a single dictionary.
42
+
43
+ Returns:
44
+ A dictionary containing the merged configuration data.
45
+ """
46
+ global _CONFIG_CACHE
47
+ if _CONFIG_CACHE is not None:
48
+ return _CONFIG_CACHE
49
+
50
+ merged_data: dict[str, Any] = {"tool": [], "project_root": None}
51
+
52
+ def merge_from_file(path: Path) -> None:
53
+ if path.exists():
54
+ try:
55
+ with open(path, "rb") as f:
56
+ data = tomllib.load(f)
57
+
58
+ # Replacement strategy: If a config file defines tools,
59
+ # it replaces the set of tools from lower-priority configs.
60
+ if "tool" in data and data["tool"]:
61
+ merged_data["tool"] = data["tool"]
62
+
63
+ if "project_root" in data:
64
+ merged_data["project_root"] = data["project_root"]
65
+
66
+ except Exception as e:
67
+ CONFIG_ERRORS.append(f"Error in {path.name}: {e}")
68
+
69
+ for path in CONFIG_PATHS:
70
+ merge_from_file(path)
71
+
72
+ _CONFIG_CACHE = merged_data
73
+ return merged_data
74
+
75
+
76
+ def get_project_root() -> Path:
77
+ """Returns the project root directory.
78
+
79
+ Priority:
80
+ 1. NEXUS_PROJECT_ROOT environment variable
81
+ 2. 'project_root' in configuration files
82
+ 3. Default: ~/Projects
83
+
84
+ Returns:
85
+ The defined project root path.
86
+ """
87
+ # 1. Environment Variable
88
+ env_root = os.environ.get("NEXUS_PROJECT_ROOT")
89
+ if env_root:
90
+ return Path(env_root).expanduser()
91
+
92
+ # 2. Configuration File
93
+ config = _load_config_data()
94
+ if config_root := config.get("project_root"):
95
+ path_str = str(config_root)
96
+ if path_str.startswith("~"):
97
+ return Path(path_str).expanduser()
98
+ return Path(path_str)
99
+
100
+ # 3. Default
101
+ return Path.home() / "Projects"
102
+
103
+
104
+ def get_tools() -> list[Tool]:
105
+ """Returns the list of configured tools.
106
+
107
+ Parses the configuration data into Tool models, skipping any invalid entries.
108
+
109
+ Returns:
110
+ A list of Tool objects.
111
+ """
112
+ tools = []
113
+ config = _load_config_data()
114
+ for t in config.get("tool", []):
115
+ try:
116
+ tools.append(Tool(**t))
117
+ except Exception as e:
118
+ CONFIG_ERRORS.append(f"Invalid tool definition: {e}")
119
+ continue
120
+ return tools
121
+
122
+
123
+ def get_keybindings() -> dict[str, str]:
124
+ """Returns the keybinding configuration.
125
+
126
+ Merges default bindings with user overrides from configuration files.
127
+
128
+ Returns:
129
+ A dictionary mapping action names to key sequences.
130
+ """
131
+ defaults = {
132
+ "quit": "q",
133
+ "force_quit": "ctrl+c",
134
+ "back": "escape",
135
+ "theme": "ctrl+t",
136
+ "help": "?",
137
+ "fuzzy_search": "ctrl+f",
138
+ "toggle_favorite": "f",
139
+ }
140
+
141
+ config = _load_config_data()
142
+ user_bindings = config.get("keybindings", {})
143
+
144
+ # Merge defaults with user bindings
145
+ return {**defaults, **user_bindings}
146
+
147
+
148
+ CATEGORY_COLORS = {
149
+ "DEV": "blue",
150
+ "AI": "purple",
151
+ "MEDIA": "green",
152
+ "UTIL": "orange",
153
+ }
154
+
155
+ USE_NERD_FONTS = True
156
+
157
+ CATEGORY_ICONS = {
158
+ "DEV": "", # fh-fa-code_fork
159
+ "AI": "", # fh-fa-microchip
160
+ "MEDIA": "", # fh-fa-video_camera
161
+ "UTIL": "", # fh-fa-wrench
162
+ "ALL": "", # fh-fa-list
163
+ }
164
+
165
+
166
+ def get_preferred_terminal() -> str | None:
167
+ """Determines the available terminal emulator based on a priority list.
168
+
169
+ Checks `pyproject.toml` for [tool.nexus.priority_terminals] and falls back
170
+ to a default list if configuration is missing.
171
+
172
+ Returns:
173
+ The command string for the first found terminal, or None if no supported
174
+ terminal is found.
175
+ """
176
+ pyproject_path = Path(__file__).parent.parent / "pyproject.toml"
177
+
178
+ terminals = ["kitty", "ghostty", "gnome-terminal", "xterm"]
179
+
180
+ if pyproject_path.exists():
181
+ try:
182
+ with open(pyproject_path, "rb") as f:
183
+ data = tomllib.load(f)
184
+ config_terminals = (
185
+ data.get("tool", {}).get("nexus", {}).get("priority_terminals")
186
+ )
187
+ if config_terminals:
188
+ terminals = config_terminals
189
+ except Exception:
190
+ pass
191
+
192
+ for term in terminals:
193
+ path = shutil.which(term)
194
+ if path:
195
+ return path
196
+
197
+ return None
nexus/container.py ADDED
@@ -0,0 +1,45 @@
1
+ """Dependency Injection Container for Nexus.
2
+
3
+ Manages the lifecycle and resolution of application services.
4
+ """
5
+
6
+ from typing import Any
7
+ from nexus.state import get_state_manager, StateManager
8
+
9
+ from nexus.services import executor, scanner
10
+
11
+
12
+ class Container:
13
+ """Simple service container."""
14
+
15
+ def __init__(self) -> None:
16
+ """Initialize the container."""
17
+ # In a more complex app, we might use a proper DI library.
18
+ # For now, simplistic service location is sufficient.
19
+ pass
20
+
21
+ @property
22
+ def executor(self) -> Any:
23
+ """Returns the executor service module."""
24
+ # Currently the executor is a module with functions.
25
+ # Future refactor could make it a class.
26
+ return executor
27
+
28
+ @property
29
+ def scanner(self) -> Any:
30
+ """Returns the scanner service module."""
31
+ return scanner
32
+
33
+ @property
34
+ def state_manager(self) -> StateManager:
35
+ """Returns the state manager service."""
36
+ return get_state_manager()
37
+
38
+
39
+ # Global instance
40
+ _container = Container()
41
+
42
+
43
+ def get_container() -> Container:
44
+ """Returns the global service container."""
45
+ return _container
nexus/logger.py ADDED
@@ -0,0 +1,48 @@
1
+ """Logging configuration for Nexus.
2
+
3
+ Configures structlog to write asynchronous logs to a rotating file in the user's
4
+ cache directory, ensuring it does not interfere with the TUI.
5
+ """
6
+
7
+ import logging
8
+
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import structlog
13
+
14
+ # Define log path
15
+ LOG_DIR = Path.home() / ".cache" / "nexus"
16
+ LOG_FILE = LOG_DIR / "nexus.log"
17
+
18
+
19
+ def configure_logging() -> None:
20
+ """Configures structural logging."""
21
+ if not LOG_DIR.exists():
22
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
23
+
24
+ # Configure standard logging to file
25
+ logging.basicConfig(
26
+ filename=str(LOG_FILE),
27
+ level=logging.INFO,
28
+ format="%(message)s",
29
+ )
30
+
31
+ structlog.configure(
32
+ processors=[
33
+ structlog.contextvars.merge_contextvars,
34
+ structlog.processors.add_log_level,
35
+ structlog.processors.StackInfoRenderer(),
36
+ structlog.processors.format_exc_info,
37
+ structlog.processors.TimeStamper(fmt="iso"),
38
+ structlog.processors.JSONRenderer(),
39
+ ],
40
+ logger_factory=structlog.stdlib.LoggerFactory(),
41
+ wrapper_class=structlog.stdlib.BoundLogger,
42
+ cache_logger_on_first_use=True,
43
+ )
44
+
45
+
46
+ def get_logger(name: str = "nexus") -> Any:
47
+ """Returns a structured logger instance."""
48
+ return structlog.get_logger(name)
nexus/models.py ADDED
@@ -0,0 +1,43 @@
1
+ """Data models for the Nexus application.
2
+
3
+ Defines the Pydantic models used for validation and typehinting of
4
+ tools and projects within the application.
5
+ """
6
+
7
+ from pathlib import Path
8
+ from typing import Literal
9
+
10
+ from pydantic import BaseModel
11
+
12
+
13
+ class Tool(BaseModel):
14
+ """Represents a command-line tool available in Nexus.
15
+
16
+ Attributes:
17
+ label: The display name of the tool.
18
+ category: The category the tool belongs to (DEV, AI, MEDIA, UTIL).
19
+ description: A brief description of the tool's purpose.
20
+ command: The shell command to execute.
21
+ requires_project: True if the tool needs a project directory to run.
22
+ """
23
+
24
+ label: str
25
+ category: Literal["DEV", "AI", "MEDIA", "UTIL"]
26
+ description: str
27
+ command: str
28
+ requires_project: bool
29
+
30
+
31
+ class Project(BaseModel):
32
+ """Represents a local project directory.
33
+
34
+ Attributes:
35
+ name: The name of the project folder.
36
+ path: The absolute path to the project directory.
37
+ is_git: True if the directory is a git repository.
38
+ """
39
+
40
+ name: str
41
+ path: Path
42
+ is_git: bool
43
+
File without changes
@@ -0,0 +1,115 @@
1
+ """Screen for creating a new project.
2
+
3
+ Collects user input for a new project directory name and creates the directory.
4
+ """
5
+
6
+ import os
7
+ from typing import Any, Callable
8
+
9
+ from nexus.config import get_project_root
10
+ from textual.app import ComposeResult
11
+ from textual.binding import Binding
12
+ from textual.containers import Container, Horizontal
13
+ from textual.screen import ModalScreen
14
+ from textual.widgets import Button, Input, Label
15
+
16
+
17
+ class CreateProject(ModalScreen[None]):
18
+ """A modal screen for creating a new project.
19
+
20
+ Attributes:
21
+ on_created_callback: Callback function called with the new project name upon success.
22
+ """
23
+
24
+ CSS_PATH = "../style.tcss"
25
+
26
+ def __init__(self, on_created: Callable[[str], None], **kwargs: Any):
27
+ """Initializes the CreateProject screen.
28
+
29
+ Args:
30
+ on_created: Callback function (str) -> None.
31
+ **kwargs: Additional arguments passed to ModalScreen.
32
+ """
33
+ super().__init__(**kwargs)
34
+ self.on_created_callback = on_created
35
+
36
+ BINDINGS = [
37
+ Binding("escape", "cancel", "Cancel"),
38
+ ]
39
+
40
+ def compose(self) -> ComposeResult:
41
+ """Composes the screen layout.
42
+
43
+ Returns:
44
+ A ComposeResult containing the widget tree.
45
+ """
46
+ with Container(id="create-project-dialog"):
47
+ yield Label("Create New Project", id="create-project-title")
48
+ yield Input(placeholder="Project Name", id="project-name-input")
49
+ yield Label("", id="create-error", classes="error-label hidden")
50
+
51
+ with Horizontal(id="create-project-buttons"):
52
+ yield Button("Cancel", variant="default", id="btn-cancel")
53
+ yield Button("Create", variant="primary", id="btn-create")
54
+
55
+ def on_mount(self) -> None:
56
+ """Called when the screen is mounted."""
57
+ self.query_one("#project-name-input").focus()
58
+
59
+ def on_button_pressed(self, event: Button.Pressed) -> None:
60
+ """Handles button press events.
61
+
62
+ Args:
63
+ event: The button pressed event.
64
+ """
65
+ if event.button.id == "btn-cancel":
66
+ self.action_cancel()
67
+ elif event.button.id == "btn-create":
68
+ self.create_project()
69
+
70
+ def on_input_submitted(self, event: Input.Submitted) -> None:
71
+ """Handles enter key in input field.
72
+
73
+ Args:
74
+ event: The input submitted event.
75
+ """
76
+ self.create_project()
77
+
78
+ def create_project(self) -> None:
79
+ """Validates input and attempts to create the project directory."""
80
+ name_input = self.query_one("#project-name-input", Input)
81
+ name = name_input.value.strip()
82
+ if not name:
83
+ self.show_error("Project name cannot be empty.")
84
+ return
85
+
86
+ project_path = get_project_root() / name
87
+
88
+ if project_path.exists():
89
+ self.show_error("Project already exists.")
90
+ return
91
+
92
+ try:
93
+ os.makedirs(project_path)
94
+ self.dismiss()
95
+ self.on_created_callback(name)
96
+ except Exception as e:
97
+ self.show_error(f"Error: {e}")
98
+
99
+ def show_error(self, message: str) -> None:
100
+ """Displays an error message to the user.
101
+
102
+ Args:
103
+ message: The error description.
104
+ """
105
+ lbl = self.query_one("#create-error", Label)
106
+ lbl.update(message)
107
+ lbl.remove_class("hidden")
108
+
109
+ def action_cancel(self) -> None:
110
+ """Cancels the action and closes the modal."""
111
+ self.dismiss()
112
+
113
+ # Summary:
114
+ # Formatted docstrings to strict Google Style.
115
+ # Added module docstring.
nexus/screens/error.py ADDED
@@ -0,0 +1,57 @@
1
+ """Generic Error Screen for Nexus.
2
+
3
+ Displays an error message and functionality to dismiss or copy details.
4
+ """
5
+
6
+ from typing import Any
7
+
8
+ from textual.app import ComposeResult
9
+ from textual.containers import Container, Horizontal, Vertical
10
+ from textual.screen import ModalScreen
11
+ from textual.widgets import Button, Label, Static
12
+
13
+
14
+ class ErrorScreen(ModalScreen[None]):
15
+ """A modal screen that displays an error message."""
16
+
17
+ CSS_PATH = "../style.tcss"
18
+
19
+ def __init__(
20
+ self,
21
+ title: str,
22
+ message: str,
23
+ details: str = "",
24
+ **kwargs: Any,
25
+ ):
26
+ """Initializes the ErrorScreen.
27
+
28
+ Args:
29
+ title: The title of the error.
30
+ message: The main error message.
31
+ details: Optional technical details.
32
+ **kwargs: Additional arguments.
33
+ """
34
+ super().__init__(**kwargs)
35
+ self.error_title = title
36
+ self.error_message = message
37
+ self.error_details = details
38
+
39
+ def compose(self) -> ComposeResult:
40
+ """Composes the screen layout."""
41
+ with Container(id="error-dialog"):
42
+ with Horizontal(id="error-header"):
43
+ yield Label("Error", classes="error-icon")
44
+ yield Label(self.error_title, id="error-title")
45
+
46
+ with Vertical(id="error-body"):
47
+ yield Label(self.error_message, id="error-message")
48
+ if self.error_details:
49
+ yield Static(self.error_details, id="error-details")
50
+
51
+ with Horizontal(id="error-footer"):
52
+ yield Button("Close", variant="error", id="btn-error-close")
53
+
54
+ def on_button_pressed(self, event: Button.Pressed) -> None:
55
+ """Handles button presses."""
56
+ if event.button.id == "btn-error-close":
57
+ self.dismiss()
nexus/screens/help.py ADDED
@@ -0,0 +1,62 @@
1
+ """Help screen for the Nexus application.
2
+
3
+ Displays information about key bindings and usage.
4
+ """
5
+
6
+ from textual.app import ComposeResult
7
+ from textual.containers import Horizontal, Vertical
8
+ from textual.screen import ModalScreen
9
+ from textual.widgets import Button, Label, Markdown
10
+
11
+
12
+ class HelpScreen(ModalScreen[None]):
13
+ """A modal screen that displays help and key bindings."""
14
+
15
+ CSS_PATH = "../style.tcss"
16
+
17
+ BINDINGS = [
18
+ ("escape", "dismiss", "Close"),
19
+ ("q", "dismiss", "Close"),
20
+ ]
21
+
22
+ def compose(self) -> ComposeResult:
23
+ """Composes the screen layout.
24
+
25
+ Returns:
26
+ A ComposeResult containing the widget tree.
27
+ """
28
+ with Vertical(id="help-dialog"):
29
+ with Horizontal(id="help-title-container"):
30
+ yield Label("Nexus Help", id="help-title")
31
+
32
+ with Vertical(id="help-content"):
33
+ yield Markdown(
34
+ """
35
+ **Navigation**
36
+ - `↑ / ↓` : Navigate lists
37
+ - `← / →` : Switch between Categories and Tool List
38
+ - `Enter` : Select Category or Launch Tool
39
+
40
+ **Search**
41
+ - Type any character to start searching tools.
42
+ - `Esc` : Clear search or Go Back.
43
+
44
+ **System**
45
+ - `Ctrl+t` : Open Theme Picker
46
+ - `Ctrl+c` : Quit Application
47
+ - `?` or `F1`: Show this Help Screen
48
+ """
49
+ )
50
+
51
+ with Horizontal(id="help-footer"):
52
+ yield Button("Close", variant="primary", id="btn-close")
53
+
54
+ def on_button_pressed(self, event: Button.Pressed) -> None:
55
+ """Handles button press events.
56
+
57
+ Args:
58
+ event: The button pressed event.
59
+ """
60
+ if event.button.id == "btn-close":
61
+ self.dismiss()
62
+