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.
@@ -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