nexus-tui 0.1.13__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.
@@ -5,27 +5,23 @@ and execution.
5
5
  """
6
6
 
7
7
  from textual.app import ComposeResult
8
- from textual.containers import Horizontal, Vertical
9
- from textual.events import Key
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, ListView
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
- from nexus.logger import get_logger
17
15
  from nexus.models import Tool
16
+ from nexus.widgets.footer import NexusFooter, KeyBadge
17
+ from nexus.widgets.tool_browser import ToolBrowser
18
18
 
19
19
 
20
20
  class ToolSelector(Screen[None]):
21
21
  """Screen for selecting and launching tools.
22
22
 
23
- Displays a list of tools categorized by type. Allows searching,
23
+ Displays a categorized list of tools separated by type. Allows searching,
24
24
  filtering, and keyboard navigation.
25
-
26
- Attributes:
27
- search_query: The current search filter text.
28
- BINDINGS: Key bindings for the screen.
29
25
  """
30
26
 
31
27
  CSS_PATH = "../style.tcss"
@@ -34,187 +30,88 @@ class ToolSelector(Screen[None]):
34
30
  search_query = reactive("")
35
31
 
36
32
  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"),
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"),
48
46
  ]
49
47
 
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
48
  def compose(self) -> ComposeResult:
57
- """Composes the screen layout.
58
-
59
- Yields:
60
- The widget tree for the screen.
61
- """
49
+ """Composes the screen layout."""
62
50
  with Horizontal(id="header"):
63
51
  yield Label(
64
- "**********************************\n Nexus Interface \n**********************************",
52
+ "Nexus",
65
53
  id="header-left",
66
54
  )
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
- )
55
+ yield Label("Type to search tools...", id="tool-search")
56
+
57
+ yield ToolBrowser(id="tool-browser")
84
58
 
85
59
  yield Label("", id="tool-description")
86
60
 
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
61
+ yield NexusFooter()
62
+
63
+ # --- Theme Management ---
118
64
  THEMES = ["theme-light", "theme-dark", "theme-storm"]
119
65
  current_theme_index = 0
120
66
 
121
67
  def action_show_theme_picker(self) -> None:
122
68
  """Opens the theme picker modal."""
123
-
124
- # Helper to apply theme temporarily or permanently
125
69
  def apply_theme(new_theme: str) -> None:
126
70
  self.set_theme(new_theme)
127
71
 
128
72
  from nexus.screens.theme_picker import ThemePicker
129
-
130
73
  current_theme = self.THEMES[self.current_theme_index]
131
74
  self.app.push_screen(ThemePicker(self.THEMES, current_theme, apply_theme))
132
75
 
133
76
  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
77
+ """Sets the current theme for the application."""
140
78
  for theme in self.THEMES:
141
79
  if theme in self.classes:
142
80
  self.remove_class(theme)
143
81
 
144
82
  self.add_class(new_theme)
145
83
 
146
- # Update index if it's one of ours
147
84
  if new_theme in self.THEMES:
148
85
  self.current_theme_index = self.THEMES.index(new_theme)
149
86
 
150
87
  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)
88
+ self.notify(f"Theme: {suffix}")
170
89
 
171
90
  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
- """
91
+ """Called when the screen is mounted."""
176
92
  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
-
93
+
181
94
  # Report any config errors from loading phase
182
95
  from nexus.config import CONFIG_ERRORS
183
96
  for error in CONFIG_ERRORS:
184
97
  self.app.notify(error, title="Config Error", severity="error", timeout=5.0)
185
98
 
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
99
+ # --- Search & Filter ---
195
100
 
196
101
  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
- """
102
+ """Reacts to changes in the search query."""
203
103
  try:
204
104
  feedback = self.query_one("#tool-search", Label)
105
+ browser = self.query_one(ToolBrowser)
205
106
  except Exception:
206
107
  return
207
108
 
109
+ browser.search_query = new_value
110
+
208
111
  if new_value:
209
112
  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
113
  else:
215
- feedback.update("Search tools...")
216
- # Re-populate without filter
217
- self.refresh_tools()
114
+ feedback.update("Type to search tools...")
218
115
 
219
116
  def action_delete_char(self) -> None:
220
117
  """Deletes the last character from search query."""
@@ -227,275 +124,119 @@ class ToolSelector(Screen[None]):
227
124
  self.search_query = ""
228
125
 
229
126
  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
- """
127
+ """Global key handler for type-to-search and numeric quick launch."""
235
128
  # Numeric keys 1-9 for quick launch
236
129
  if event.key in "123456789":
237
130
  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
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
245
137
 
246
138
  if event.key.isprintable() and len(event.key) == 1:
247
139
  # Append char to query
248
140
  self.search_query += event.key
249
141
  event.stop()
250
142
 
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)
143
+ # --- Navigation delegation ---
390
144
 
391
145
  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)
146
+ self.query_one(ToolBrowser).focus_next()
408
147
 
409
148
  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)
149
+ self.query_one(ToolBrowser).focus_prev()
420
150
 
421
151
  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
152
+ self.query_one(ToolBrowser).focus_right()
436
153
 
437
154
  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()
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
441
165
 
442
166
  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")
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")
455
174
 
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)
175
+ def action_show_help(self) -> None:
176
+ from nexus.screens.help import HelpScreen
177
+ self.app.push_screen(HelpScreen())
464
178
 
465
- def launch_tool_flow(self, tool: Tool) -> None:
466
- """Handles the flow for launching a tool.
179
+ # --- Responsive Design ---
467
180
 
468
- If the tool requires a project, opens the ProjectPicker.
469
- Otherwise, executes the tool command directly.
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 ---
470
221
 
471
- Args:
472
- tool: The Tool object to launch.
473
- """
222
+ def launch_tool_flow(self, tool: Tool) -> None:
223
+ """Handles the flow for launching a tool."""
474
224
  if tool.requires_project:
475
225
  from nexus.screens.project_picker import ProjectPicker
476
-
477
226
  self.app.push_screen(ProjectPicker(tool))
478
227
  else:
479
228
  self.execute_tool_command(tool)
480
229
 
481
230
  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
231
+ """Executes the tool command with suspend context."""
488
232
  with self.app.suspend():
489
233
  from nexus.app import NexusApp
490
-
491
234
  if isinstance(self.app, NexusApp):
492
235
  success = self.app.container.executor.launch_tool(tool.command)
493
236
  else:
494
- # Fallback or strict error
495
237
  success = False
496
238
 
497
239
  if not success:
498
- # We can't see this notification until we return, but that's fine
499
240
  self.app.notify(f"Failed to launch {tool.label}", severity="error")
500
241
 
501
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
- with open(STATE_FILE, "w") as f:
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