nexus-tui 0.1.14__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,26 +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
15
  from nexus.models import Tool
16
+ from nexus.widgets.footer import NexusFooter, KeyBadge
17
+ from nexus.widgets.tool_browser import ToolBrowser
17
18
 
18
19
 
19
20
  class ToolSelector(Screen[None]):
20
21
  """Screen for selecting and launching tools.
21
22
 
22
- Displays a list of tools categorized by type. Allows searching,
23
+ Displays a categorized list of tools separated by type. Allows searching,
23
24
  filtering, and keyboard navigation.
24
-
25
- Attributes:
26
- search_query: The current search filter text.
27
- BINDINGS: Key bindings for the screen.
28
25
  """
29
26
 
30
27
  CSS_PATH = "../style.tcss"
@@ -33,187 +30,88 @@ class ToolSelector(Screen[None]):
33
30
  search_query = reactive("")
34
31
 
35
32
  BINDINGS = [
36
- ("ctrl+t", "show_theme_picker", "Theme"),
37
- ("ctrl+f", "toggle_favorite", "Favorite"),
38
- ("escape", "clear_search", "Clear Search"),
39
- ("down", "cursor_down", "Next Item"),
40
- ("up", "cursor_up", "Previous Item"),
41
- ("right", "cursor_right", "Enter List"),
42
- ("left", "cursor_left", "Back to Categories"),
43
- ("enter", "launch_current", "Launch Tool"),
44
- ("backspace", "delete_char", "Delete Character"),
45
- ("?", "show_help", "Help"),
46
- ("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"),
47
46
  ]
48
47
 
49
- def action_show_help(self) -> None:
50
- """Shows the help screen modal."""
51
- from nexus.screens.help import HelpScreen
52
-
53
- self.app.push_screen(HelpScreen())
54
-
55
48
  def compose(self) -> ComposeResult:
56
- """Composes the screen layout.
57
-
58
- Yields:
59
- The widget tree for the screen.
60
- """
49
+ """Composes the screen layout."""
61
50
  with Horizontal(id="header"):
62
51
  yield Label(
63
- "**********************************\n Nexus Interface \n**********************************",
52
+ "Nexus",
64
53
  id="header-left",
65
54
  )
66
- yield Label("Search tools...", id="tool-search")
67
-
68
- with Horizontal(id="main-container"):
69
- with Vertical(id="left-pane"):
70
- yield Label("Categories", classes="pane-header")
71
- yield ListView(id="category-list")
72
-
73
- with Vertical(id="right-pane"):
74
- yield Horizontal(
75
- Label("Toolbox", classes="pane-header"),
76
- Label("Important Actions", classes="pane-header-right"),
77
- classes="pane-header-container",
78
- )
79
- yield ListView(id="tool-list")
80
- yield Label(
81
- "No tools found", id="tools-empty", classes="empty-state hidden"
82
- )
55
+ yield Label("Type to search tools...", id="tool-search")
56
+
57
+ yield ToolBrowser(id="tool-browser")
83
58
 
84
59
  yield Label("", id="tool-description")
85
60
 
86
- # New Three-Column Footer
87
- with Horizontal(id="footer-container"):
88
- # NAV
89
- with Horizontal(classes="footer-col"):
90
- yield Label("NAV", classes="footer-label")
91
- yield Label("↑↓", classes="key-badge")
92
- yield Label("Select", classes="key-desc")
93
- yield Label("←→", classes="key-badge")
94
- yield Label("Pane", classes="key-desc")
95
- yield Label("Enter", classes="key-badge")
96
- yield Label("Launch", classes="key-desc")
97
- yield Label("^F", classes="key-badge")
98
- yield Label("Fav", classes="key-desc")
99
-
100
- # SEARCH
101
- with Horizontal(classes="footer-col"):
102
- yield Label("SEARCH", classes="footer-label")
103
- yield Label("Type", classes="key-badge")
104
- yield Label("Filter", classes="key-desc")
105
- yield Label("Esc", classes="key-badge")
106
- yield Label("Clear", classes="key-desc")
107
-
108
- # SYSTEM
109
- with Horizontal(classes="footer-col"):
110
- yield Label("SYSTEM", classes="footer-label")
111
- yield Label("^T", classes="key-badge")
112
- yield Label("Theme", classes="key-desc")
113
- yield Label("^Q", classes="key-badge")
114
- yield Label("Exit", classes="key-desc")
115
-
116
- # Theme Management
61
+ yield NexusFooter()
62
+
63
+ # --- Theme Management ---
117
64
  THEMES = ["theme-light", "theme-dark", "theme-storm"]
118
65
  current_theme_index = 0
119
66
 
120
67
  def action_show_theme_picker(self) -> None:
121
68
  """Opens the theme picker modal."""
122
-
123
- # Helper to apply theme temporarily or permanently
124
69
  def apply_theme(new_theme: str) -> None:
125
70
  self.set_theme(new_theme)
126
71
 
127
72
  from nexus.screens.theme_picker import ThemePicker
128
-
129
73
  current_theme = self.THEMES[self.current_theme_index]
130
74
  self.app.push_screen(ThemePicker(self.THEMES, current_theme, apply_theme))
131
75
 
132
76
  def set_theme(self, new_theme: str) -> None:
133
- """Sets the current theme for the application.
134
-
135
- Args:
136
- new_theme: The CSS class name of the theme to apply.
137
- """
138
- # Find current applied theme to remove it
77
+ """Sets the current theme for the application."""
139
78
  for theme in self.THEMES:
140
79
  if theme in self.classes:
141
80
  self.remove_class(theme)
142
81
 
143
82
  self.add_class(new_theme)
144
83
 
145
- # Update index if it's one of ours
146
84
  if new_theme in self.THEMES:
147
85
  self.current_theme_index = self.THEMES.index(new_theme)
148
86
 
149
87
  suffix = new_theme.replace("theme-", "").title()
150
- self.notify(f"Theme: Tokyo Night {suffix}")
151
-
152
- def action_next_theme(self) -> None:
153
- """Cycles to the next theme (legacy binding)."""
154
- self.cycle_theme(1)
155
-
156
- def action_prev_theme(self) -> None:
157
- """Cycles to the previous theme."""
158
- self.cycle_theme(-1)
159
-
160
- def cycle_theme(self, direction: int) -> None:
161
- """Cycles through available themes.
162
-
163
- Args:
164
- direction: 1 for next, -1 for previous.
165
- """
166
- new_index = (self.current_theme_index + direction) % len(self.THEMES)
167
- new_theme = self.THEMES[new_index]
168
- self.set_theme(new_theme)
88
+ self.notify(f"Theme: {suffix}")
169
89
 
170
90
  def on_mount(self) -> None:
171
- """Called when the screen is mounted.
172
-
173
- Sets initial theme, populates categories, and focuses the category list.
174
- """
91
+ """Called when the screen is mounted."""
175
92
  self.add_class(self.THEMES[self.current_theme_index])
176
- self.populate_categories()
177
- # Default focus to categories
178
- self.query_one("#category-list").focus()
179
-
93
+
180
94
  # Report any config errors from loading phase
181
95
  from nexus.config import CONFIG_ERRORS
182
96
  for error in CONFIG_ERRORS:
183
97
  self.app.notify(error, title="Config Error", severity="error", timeout=5.0)
184
98
 
185
- def select_all_category(self) -> None:
186
- """Selects the 'ALL' category in the list."""
187
- category_list = self.query_one("#category-list", ListView)
188
- # Find the index of the ALL category
189
- for idx, child in enumerate(category_list.children):
190
- if isinstance(child, CategoryListItem) and child.category_id == "ALL":
191
- if category_list.index != idx:
192
- category_list.index = idx
193
- break
99
+ # --- Search & Filter ---
194
100
 
195
101
  def watch_search_query(self, old_value: str, new_value: str) -> None:
196
- """Reacts to changes in the search query.
197
-
198
- Args:
199
- old_value: The previous search query.
200
- new_value: The new search query.
201
- """
102
+ """Reacts to changes in the search query."""
202
103
  try:
203
104
  feedback = self.query_one("#tool-search", Label)
105
+ browser = self.query_one(ToolBrowser)
204
106
  except Exception:
205
107
  return
206
108
 
109
+ browser.search_query = new_value
110
+
207
111
  if new_value:
208
112
  feedback.update(f"SEARCH: {new_value}_")
209
- # Switch to ALL category automatically if not already
210
- self.select_all_category()
211
- # Populate tools with filter
212
- self.refresh_tools()
213
113
  else:
214
- feedback.update("Search tools...")
215
- # Re-populate without filter
216
- self.refresh_tools()
114
+ feedback.update("Type to search tools...")
217
115
 
218
116
  def action_delete_char(self) -> None:
219
117
  """Deletes the last character from search query."""
@@ -226,275 +124,119 @@ class ToolSelector(Screen[None]):
226
124
  self.search_query = ""
227
125
 
228
126
  def on_key(self, event: Key) -> None:
229
- """Global key handler for type-to-search and numeric quick launch.
230
-
231
- Args:
232
- event: The key event.
233
- """
127
+ """Global key handler for type-to-search and numeric quick launch."""
234
128
  # Numeric keys 1-9 for quick launch
235
129
  if event.key in "123456789":
236
130
  idx = int(event.key) - 1
237
- tool_list = self.query_one("#tool-list", ListView)
238
- if idx < len(tool_list.children):
239
- item = tool_list.children[idx]
240
- if isinstance(item, ToolListItem):
241
- self.launch_tool_flow(item.tool_info)
242
- event.stop()
243
- 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
244
137
 
245
138
  if event.key.isprintable() and len(event.key) == 1:
246
139
  # Append char to query
247
140
  self.search_query += event.key
248
141
  event.stop()
249
142
 
250
- def refresh_tools(self) -> None:
251
- """Refreshes tool list based on current selection and search text."""
252
- category_list = self.query_one("#category-list", ListView)
253
- if (
254
- hasattr(category_list, "highlighted_child")
255
- and category_list.highlighted_child
256
- ):
257
- item = category_list.highlighted_child
258
- if isinstance(item, CategoryListItem):
259
- self.populate_tools(item.category_id, filter_text=self.search_query)
260
-
261
- def populate_categories(self) -> None:
262
- """Populates the category list with unique categories from loaded tools."""
263
- category_list = self.query_one("#category-list", ListView)
264
- category_list.clear()
265
-
266
- # Get unique categories
267
- tools = get_tools()
268
- categories = sorted(list(set(t.category for t in tools)))
269
-
270
- # Add FAVORITES and ALL category at the start
271
- fav_item = CategoryListItem("FAVORITES")
272
- fav_item.add_class("category-FAVORITES") # Optional styling hook
273
- category_list.append(fav_item)
274
-
275
- all_item = CategoryListItem("ALL")
276
- category_list.append(all_item)
277
-
278
- for category in categories:
279
- item = CategoryListItem(category)
280
- category_list.append(item)
281
-
282
- # Select "ALL" category by default (index 1, as FAVORITES is 0)
283
- # Or find index of "ALL"
284
- if category_list.children:
285
- all_idx = 1 if len(category_list.children) > 1 else 0
286
- category_list.index = all_idx
287
- # Trigger population via index change?
288
- # Textual might not trigger highlighted event on programmatic set if not focused/mounted fully?
289
- # Safe to call populate_tools explicitly.
290
- cat_id = "ALL"
291
- if 0 <= all_idx < len(category_list.children):
292
- child = category_list.children[all_idx]
293
- if isinstance(child, CategoryListItem):
294
- cat_id = child.category_id
295
-
296
- self.populate_tools(cat_id)
297
-
298
- def populate_tools(self, category: str, filter_text: str = "") -> None:
299
- """Populates the tool list based on category and filter text.
300
-
301
- Args:
302
- category: The category ID to filter by (or "ALL").
303
- filter_text: Optional text to filter tool label/description.
304
- """
305
- from nexus.app import NexusApp
306
-
307
- tool_list = self.query_one("#tool-list", ListView)
308
- tool_list.clear()
309
-
310
- tools = get_tools()
311
-
312
- if category == "ALL":
313
- filtered_tools = tools
314
- elif category == "FAVORITES":
315
- if isinstance(self.app, NexusApp):
316
- favs = self.app.container.state_manager.get_favorites()
317
- filtered_tools = [t for t in tools if t.command in favs]
318
- else:
319
- filtered_tools = []
320
- else:
321
- filtered_tools = [t for t in tools if t.category == category]
322
-
323
- if filter_text:
324
- filtered_tools = [
325
- t
326
- for t in filtered_tools
327
- if filter_text.lower() in t.label.lower()
328
- or filter_text.lower() in t.description.lower()
329
- ]
330
-
331
- if filtered_tools:
332
- tool_list.index = 0
333
- tool_list.display = True
334
- self.query_one("#tools-empty").add_class("hidden")
335
- else:
336
- tool_list.display = False
337
- empty_lbl = self.query_one("#tools-empty", Label)
338
- empty_lbl.remove_class("hidden")
339
- if filter_text:
340
- empty_lbl.update(f"No tools matching '{filter_text}'")
341
- else:
342
- empty_lbl.update(f"No tools in category '{category}'")
343
-
344
- for i, tool in enumerate(filtered_tools):
345
- hint = str(i + 1) if i < 9 else ""
346
- is_fav = False
347
- if isinstance(self.app, NexusApp):
348
- is_fav = self.app.container.state_manager.is_favorite(tool.command)
349
-
350
- item = ToolListItem(tool, hint=hint, is_favorite=is_fav)
351
- tool_list.append(item)
352
-
353
- def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
354
- """Called when a list item is highlighted.
355
-
356
- Args:
357
- event: The highlight event.
358
- """
359
- if event.list_view.id == "category-list":
360
- if isinstance(event.item, CategoryListItem):
361
- self.populate_tools(
362
- event.item.category_id, filter_text=self.search_query
363
- )
364
-
365
- elif event.list_view.id == "tool-list":
366
- if isinstance(event.item, ToolListItem):
367
- tool = event.item.tool_info
368
- self.query_one("#tool-description", Label).update(
369
- f"{tool.label}: {tool.description}"
370
- )
371
-
372
- def on_list_view_selected(self, event: ListView.Selected) -> None:
373
- """Called when a list item is selected (Enter pressed).
374
-
375
- Args:
376
- event: The selection event.
377
- """
378
- if event.list_view.id == "category-list":
379
- # If user selects a category (Enter), move focus to tool list
380
- self.query_one("#tool-list").focus()
381
- # force update
382
- self.action_cursor_right() # Re-use logic
383
-
384
- elif event.list_view.id == "tool-list":
385
- # If user selects a tool, launch it
386
- if isinstance(event.item, ToolListItem):
387
- tool = event.item.tool_info
388
- self.launch_tool_flow(tool)
143
+ # --- Navigation delegation ---
389
144
 
390
145
  def action_cursor_down(self) -> None:
391
- """Moves selection down in the active list."""
392
- if self.query_one("#category-list").has_focus:
393
- category_list = self.query_one("#category-list", ListView)
394
- if category_list.index is None:
395
- category_list.index = 0
396
- else:
397
- category_list.index = min(
398
- len(category_list.children) - 1, category_list.index + 1
399
- )
400
-
401
- elif self.query_one("#tool-list").has_focus:
402
- tool_list = self.query_one("#tool-list", ListView)
403
- if tool_list.index is None:
404
- tool_list.index = 0
405
- else:
406
- tool_list.index = min(len(tool_list.children) - 1, tool_list.index + 1)
146
+ self.query_one(ToolBrowser).focus_next()
407
147
 
408
148
  def action_cursor_up(self) -> None:
409
- """Moves selection up in the active list."""
410
- if self.query_one("#category-list").has_focus:
411
- lst = self.query_one("#category-list", ListView)
412
- if lst.index is not None:
413
- lst.index = max(0, lst.index - 1)
414
-
415
- elif self.query_one("#tool-list").has_focus:
416
- lst = self.query_one("#tool-list", ListView)
417
- if lst.index is not None:
418
- lst.index = max(0, lst.index - 1)
149
+ self.query_one(ToolBrowser).focus_prev()
419
150
 
420
151
  def action_cursor_right(self) -> None:
421
- """Moves focus from categories to tools."""
422
- if self.query_one("#category-list").has_focus:
423
- tool_list = self.query_one("#tool-list", ListView)
424
- tool_list.focus()
425
-
426
- # Ensure index is valid and trigger highlight refresh
427
- if tool_list.children:
428
- if tool_list.index is None:
429
- tool_list.index = 0
430
- else:
431
- # Force property update to ensure highlight renders
432
- idx = tool_list.index
433
- tool_list.index = None
434
- tool_list.index = idx
152
+ self.query_one(ToolBrowser).focus_right()
435
153
 
436
154
  def action_cursor_left(self) -> None:
437
- """Moves focus from tools back to categories."""
438
- if self.query_one("#tool-list").has_focus:
439
- 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
440
165
 
441
166
  def action_toggle_favorite(self) -> None:
442
- """Toggles the favorite status of the selected tool."""
443
- if self.query_one("#tool-list").has_focus:
444
- tool_list = self.query_one("#tool-list", ListView)
445
- if tool_list.index is not None and tool_list.index < len(tool_list.children):
446
- item = tool_list.children[tool_list.index]
447
- if isinstance(item, ToolListItem):
448
- from nexus.app import NexusApp
449
- if isinstance(self.app, NexusApp):
450
- self.app.container.state_manager.toggle_favorite(item.tool_info.command)
451
- # Refresh to show star or remove from Favorites list if active
452
- self.refresh_tools()
453
- 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")
454
174
 
455
- def action_launch_current(self) -> None:
456
- """Launches the currently selected tool."""
457
- tool_list = self.query_one("#tool-list", ListView)
458
- # Ensure index is valid
459
- if tool_list.index is not None and tool_list.index < len(tool_list.children):
460
- item = tool_list.children[tool_list.index]
461
- if isinstance(item, ToolListItem):
462
- 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())
463
178
 
464
- def launch_tool_flow(self, tool: Tool) -> None:
465
- """Handles the flow for launching a tool.
179
+ # --- Responsive Design ---
466
180
 
467
- If the tool requires a project, opens the ProjectPicker.
468
- 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 ---
469
221
 
470
- Args:
471
- tool: The Tool object to launch.
472
- """
222
+ def launch_tool_flow(self, tool: Tool) -> None:
223
+ """Handles the flow for launching a tool."""
473
224
  if tool.requires_project:
474
225
  from nexus.screens.project_picker import ProjectPicker
475
-
476
226
  self.app.push_screen(ProjectPicker(tool))
477
227
  else:
478
228
  self.execute_tool_command(tool)
479
229
 
480
230
  def execute_tool_command(self, tool: Tool) -> None:
481
- """Executes the tool command with suspend context.
482
-
483
- Args:
484
- tool: The Tool object to execute.
485
- """
486
- # Launch directly with suspend
231
+ """Executes the tool command with suspend context."""
487
232
  with self.app.suspend():
488
233
  from nexus.app import NexusApp
489
-
490
234
  if isinstance(self.app, NexusApp):
491
235
  success = self.app.container.executor.launch_tool(tool.command)
492
236
  else:
493
- # Fallback or strict error
494
237
  success = False
495
238
 
496
239
  if not success:
497
- # We can't see this notification until we return, but that's fine
498
240
  self.app.notify(f"Failed to launch {tool.label}", severity="error")
499
241
 
500
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