lazyopencode 0.1.0__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.
Files changed (37) hide show
  1. lazyopencode/__init__.py +48 -0
  2. lazyopencode/__main__.py +6 -0
  3. lazyopencode/_version.py +34 -0
  4. lazyopencode/app.py +310 -0
  5. lazyopencode/bindings.py +27 -0
  6. lazyopencode/mixins/filtering.py +33 -0
  7. lazyopencode/mixins/help.py +74 -0
  8. lazyopencode/mixins/navigation.py +184 -0
  9. lazyopencode/models/__init__.py +17 -0
  10. lazyopencode/models/customization.py +120 -0
  11. lazyopencode/services/__init__.py +7 -0
  12. lazyopencode/services/discovery.py +350 -0
  13. lazyopencode/services/gitignore_filter.py +123 -0
  14. lazyopencode/services/parsers/__init__.py +152 -0
  15. lazyopencode/services/parsers/agent.py +93 -0
  16. lazyopencode/services/parsers/command.py +94 -0
  17. lazyopencode/services/parsers/mcp.py +67 -0
  18. lazyopencode/services/parsers/plugin.py +127 -0
  19. lazyopencode/services/parsers/rules.py +65 -0
  20. lazyopencode/services/parsers/skill.py +138 -0
  21. lazyopencode/services/parsers/tool.py +67 -0
  22. lazyopencode/styles/app.tcss +173 -0
  23. lazyopencode/themes.py +30 -0
  24. lazyopencode/widgets/__init__.py +17 -0
  25. lazyopencode/widgets/app_footer.py +71 -0
  26. lazyopencode/widgets/combined_panel.py +345 -0
  27. lazyopencode/widgets/detail_pane.py +338 -0
  28. lazyopencode/widgets/filter_input.py +88 -0
  29. lazyopencode/widgets/helpers/__init__.py +5 -0
  30. lazyopencode/widgets/helpers/rendering.py +17 -0
  31. lazyopencode/widgets/status_panel.py +70 -0
  32. lazyopencode/widgets/type_panel.py +501 -0
  33. lazyopencode-0.1.0.dist-info/METADATA +118 -0
  34. lazyopencode-0.1.0.dist-info/RECORD +37 -0
  35. lazyopencode-0.1.0.dist-info/WHEEL +4 -0
  36. lazyopencode-0.1.0.dist-info/entry_points.txt +2 -0
  37. lazyopencode-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,88 @@
1
+ """FilterInput widget for searching customizations."""
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.binding import Binding
5
+ from textual.message import Message
6
+ from textual.reactive import reactive
7
+ from textual.widget import Widget
8
+ from textual.widgets import Input
9
+
10
+
11
+ class FilterInput(Widget):
12
+ """Search/filter input field."""
13
+
14
+ BINDINGS = [
15
+ Binding("escape", "cancel", "Cancel", show=False),
16
+ ]
17
+
18
+ filter_query: reactive[str] = reactive("")
19
+
20
+ class FilterChanged(Message):
21
+ """Emitted when filter query changes."""
22
+
23
+ def __init__(self, query: str) -> None:
24
+ self.query = query
25
+ super().__init__()
26
+
27
+ class FilterCancelled(Message):
28
+ """Emitted when filter is cancelled."""
29
+
30
+ pass
31
+
32
+ class FilterApplied(Message):
33
+ """Emitted when filter is applied."""
34
+
35
+ def __init__(self, query: str) -> None:
36
+ self.query = query
37
+ super().__init__()
38
+
39
+ def __init__(
40
+ self,
41
+ name: str | None = None,
42
+ id: str | None = None,
43
+ classes: str | None = None,
44
+ ) -> None:
45
+ """Initialize FilterInput."""
46
+ super().__init__(name=name, id=id, classes=classes)
47
+ self._input: Input | None = None
48
+
49
+ def compose(self) -> ComposeResult:
50
+ """Compose the filter input."""
51
+ self._input = Input(placeholder="Filter by name...")
52
+ yield self._input
53
+
54
+ def on_input_changed(self, event: Input.Changed) -> None:
55
+ """Handle input changes."""
56
+ self.filter_query = event.value
57
+ self.post_message(self.FilterChanged(event.value))
58
+
59
+ def on_input_submitted(self, event: Input.Submitted) -> None:
60
+ """Handle input submission (Enter key)."""
61
+ self.post_message(self.FilterApplied(event.value))
62
+
63
+ def action_cancel(self) -> None:
64
+ """Cancel filtering."""
65
+ self.clear()
66
+ self.hide()
67
+ self.post_message(self.FilterCancelled())
68
+
69
+ def show(self) -> None:
70
+ """Show the filter input and focus it."""
71
+ self.add_class("visible")
72
+ if self._input:
73
+ self._input.focus()
74
+
75
+ def hide(self) -> None:
76
+ """Hide the filter input."""
77
+ self.remove_class("visible")
78
+
79
+ def clear(self) -> None:
80
+ """Clear the filter query."""
81
+ if self._input:
82
+ self._input.value = ""
83
+ self.filter_query = ""
84
+
85
+ @property
86
+ def is_visible(self) -> bool:
87
+ """Check if the filter input is visible."""
88
+ return self.has_class("visible")
@@ -0,0 +1,5 @@
1
+ """Widget rendering helpers."""
2
+
3
+ from lazyopencode.widgets.helpers.rendering import format_keybinding
4
+
5
+ __all__ = ["format_keybinding"]
@@ -0,0 +1,17 @@
1
+ """Rendering helpers for widget items."""
2
+
3
+
4
+ def format_keybinding(key: str, label: str, *, active: bool = False) -> str:
5
+ """Format a keybinding with optional highlight for active state.
6
+
7
+ Args:
8
+ key: The keyboard key (e.g., "a", "P", "/").
9
+ label: The action label (e.g., "All", "Search").
10
+ active: Whether the action is currently active.
11
+
12
+ Returns:
13
+ Formatted string like "[bold]a[/] All" or "[bold]a[/] [$primary]All[/]".
14
+ """
15
+ if active:
16
+ return f"[bold]{key}[/] [$primary]{label}[/]"
17
+ return f"[bold]{key}[/] {label}"
@@ -0,0 +1,70 @@
1
+ """StatusPanel widget for displaying current configuration status."""
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.reactive import reactive
5
+ from textual.widget import Widget
6
+ from textual.widgets import Static
7
+
8
+
9
+ class StatusPanel(Widget):
10
+ """Panel displaying current configuration folder status."""
11
+
12
+ DEFAULT_CSS = """
13
+ StatusPanel {
14
+ height: 3;
15
+ border: solid $primary;
16
+ padding: 0 1;
17
+ border-title-align: left;
18
+ }
19
+
20
+ StatusPanel:focus {
21
+ border: double $accent;
22
+ }
23
+
24
+ StatusPanel .status-content {
25
+ height: 1;
26
+ }
27
+ """
28
+
29
+ config_path: reactive[str] = reactive("")
30
+ filter_level: reactive[str] = reactive("All")
31
+ search_active: reactive[bool] = reactive(False)
32
+
33
+ def compose(self) -> ComposeResult:
34
+ """Compose the panel content."""
35
+ yield Static(self._get_status_text(), classes="status-content")
36
+
37
+ def _get_status_text(self) -> str:
38
+ """Render the status content with path and filter level."""
39
+ level_display = (
40
+ f"[$primary]{self.filter_level}[/]"
41
+ if self.filter_level != "All"
42
+ else self.filter_level
43
+ )
44
+ search_display = " | [$primary]Search[/]" if self.search_active else ""
45
+ return f"{self.config_path} | {level_display}{search_display}"
46
+
47
+ def on_mount(self) -> None:
48
+ """Handle mount event."""
49
+ self.border_title = "Status"
50
+
51
+ def _update_content(self) -> None:
52
+ """Update the status content display."""
53
+ if self.is_mounted:
54
+ try:
55
+ content = self.query_one(".status-content", Static)
56
+ content.update(self._get_status_text())
57
+ except Exception:
58
+ pass
59
+
60
+ def watch_config_path(self, _path: str) -> None:
61
+ """React to config path changes."""
62
+ self._update_content()
63
+
64
+ def watch_filter_level(self, _level: str) -> None:
65
+ """React to filter level changes."""
66
+ self._update_content()
67
+
68
+ def watch_search_active(self, _active: bool) -> None:
69
+ """React to search active changes."""
70
+ self._update_content()
@@ -0,0 +1,501 @@
1
+ """TypePanel widget for displaying customizations of a single type."""
2
+
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING, cast
5
+
6
+ from textual.app import ComposeResult
7
+ from textual.binding import Binding
8
+ from textual.containers import VerticalScroll
9
+ from textual.events import Click
10
+ from textual.message import Message
11
+ from textual.reactive import reactive
12
+ from textual.widget import Widget
13
+ from textual.widgets import Static
14
+
15
+ if TYPE_CHECKING:
16
+ from lazyopencode.app import LazyOpenCode
17
+
18
+ from lazyopencode.models.customization import (
19
+ Customization,
20
+ CustomizationType,
21
+ SkillFile,
22
+ )
23
+
24
+
25
+ class TypePanel(Widget):
26
+ """Panel displaying customizations of a single type."""
27
+
28
+ BINDINGS = [
29
+ Binding("tab", "focus_next_panel", "Next Panel", show=False),
30
+ Binding("shift+tab", "focus_previous_panel", "Prev Panel", show=False),
31
+ Binding("j", "cursor_down", "Down", show=False),
32
+ Binding("k", "cursor_up", "Up", show=False),
33
+ Binding("down", "cursor_down", "Down", show=False),
34
+ Binding("up", "cursor_up", "Up", show=False),
35
+ Binding("home", "cursor_top", "Top", show=False),
36
+ Binding("G", "cursor_bottom", "Bottom", show=False, key_display="shift+g"),
37
+ Binding("enter", "select", "Select", show=False),
38
+ Binding("right", "expand", "Expand", show=False),
39
+ Binding("left", "collapse", "Collapse", show=False),
40
+ Binding("l", "expand", "Expand", show=False),
41
+ Binding("h", "collapse", "Collapse", show=False),
42
+ ]
43
+
44
+ DEFAULT_CSS = """
45
+ TypePanel {
46
+ height: 1fr;
47
+ min-height: 3;
48
+ border: solid $primary;
49
+ padding: 0 1;
50
+ border-title-align: left;
51
+ border-subtitle-align: right;
52
+ }
53
+
54
+ TypePanel:focus {
55
+ border: double $accent;
56
+ }
57
+
58
+ TypePanel .items-container {
59
+ height: auto;
60
+ }
61
+
62
+ TypePanel .item {
63
+ height: 1;
64
+ width: 100%;
65
+ text-wrap: nowrap;
66
+ text-overflow: ellipsis;
67
+ }
68
+
69
+ TypePanel .item-selected {
70
+ background: $accent;
71
+ text-style: bold;
72
+ }
73
+
74
+ TypePanel .item-error {
75
+ color: $error;
76
+ }
77
+
78
+ TypePanel .empty-message {
79
+ color: $text-muted;
80
+ text-style: italic;
81
+ }
82
+
83
+ TypePanel.empty {
84
+ height: 3;
85
+ min-height: 3;
86
+ max-height: 3;
87
+ }
88
+ """
89
+
90
+ customization_type: reactive[CustomizationType] = reactive(
91
+ CustomizationType.COMMAND
92
+ )
93
+ customizations: reactive[list[Customization]] = reactive(list, always_update=True)
94
+ selected_index: reactive[int] = reactive(0)
95
+ panel_number: reactive[int] = reactive(1)
96
+ is_active: reactive[bool] = reactive(False)
97
+ expanded_skills: reactive[set[str]] = reactive(set, always_update=True)
98
+
99
+ class SelectionChanged(Message):
100
+ """Emitted when selected customization changes."""
101
+
102
+ def __init__(self, customization: Customization | None) -> None:
103
+ self.customization = customization
104
+ super().__init__()
105
+
106
+ class DrillDown(Message):
107
+ """Emitted when user drills into a customization."""
108
+
109
+ def __init__(self, customization: Customization) -> None:
110
+ self.customization = customization
111
+ super().__init__()
112
+
113
+ class SkillFileSelected(Message):
114
+ """Emitted when a file within a skill is selected."""
115
+
116
+ def __init__(
117
+ self, customization: Customization, file_path: Path | None
118
+ ) -> None:
119
+ self.customization = customization
120
+ self.file_path = file_path
121
+ super().__init__()
122
+
123
+ def __init__(
124
+ self,
125
+ customization_type: CustomizationType,
126
+ name: str | None = None,
127
+ id: str | None = None,
128
+ classes: str | None = None,
129
+ ) -> None:
130
+ """Initialize TypePanel with a customization type."""
131
+ super().__init__(name=name, id=id, classes=classes)
132
+ self.customization_type = customization_type
133
+ self.can_focus = True
134
+ self._flat_items: list[tuple[Customization, Path | None]] = []
135
+
136
+ @property
137
+ def _is_skills_panel(self) -> bool:
138
+ """Check if this panel is for skills."""
139
+ return self.customization_type == CustomizationType.SKILL
140
+
141
+ @property
142
+ def type_label(self) -> str:
143
+ """Get human-readable type label."""
144
+ return {
145
+ CustomizationType.COMMAND: "Commands",
146
+ CustomizationType.AGENT: "Agents",
147
+ CustomizationType.SKILL: "Skills",
148
+ CustomizationType.RULES: "Agent Memory",
149
+ CustomizationType.MCP: "MCPs",
150
+ CustomizationType.TOOL: "Tools",
151
+ CustomizationType.PLUGIN: "Plugins",
152
+ }.get(self.customization_type, self.customization_type.value)
153
+
154
+ @property
155
+ def selected_customization(self) -> Customization | None:
156
+ """Get the currently selected customization."""
157
+ if self._is_skills_panel:
158
+ if self._flat_items and 0 <= self.selected_index < len(self._flat_items):
159
+ return self._flat_items[self.selected_index][0]
160
+ return None
161
+ if self.customizations and 0 <= self.selected_index < len(self.customizations):
162
+ return self.customizations[self.selected_index]
163
+ return None
164
+
165
+ def compose(self) -> ComposeResult:
166
+ """Compose the panel content."""
167
+ with VerticalScroll(classes="items-container"):
168
+ if self._is_skills_panel:
169
+ if not self._flat_items:
170
+ yield Static("[dim italic]No items[/]", classes="empty-message")
171
+ else:
172
+ for i, (skill, file_path) in enumerate(self._flat_items):
173
+ yield Static(
174
+ self._render_skill_item(i, skill, file_path),
175
+ classes="item",
176
+ id=f"item-{i}",
177
+ )
178
+ elif not self.customizations:
179
+ yield Static("[dim italic]No items[/]", classes="empty-message")
180
+ else:
181
+ for i, item in enumerate(self.customizations):
182
+ yield Static(
183
+ self._render_item(i, item), classes="item", id=f"item-{i}"
184
+ )
185
+
186
+ def _render_header(self) -> str:
187
+ """Render the panel header with type label."""
188
+ return f"[{self.panel_number}]-{self.type_label}-"
189
+
190
+ def _render_footer(self) -> str:
191
+ """Render the panel footer with selection position."""
192
+ count = self._item_count()
193
+ if count == 0:
194
+ return "0 of 0"
195
+ return f"{self.selected_index + 1} of {count}"
196
+
197
+ def _item_count(self) -> int:
198
+ """Get the number of items in the panel."""
199
+ if self._is_skills_panel:
200
+ return len(self._flat_items)
201
+ return len(self.customizations)
202
+
203
+ def _render_item(self, index: int, item: Customization) -> str:
204
+ """Render a single item."""
205
+ is_selected = index == self.selected_index and self.is_active
206
+ prefix = ">" if is_selected else " "
207
+ error_marker = " [red]![/]" if item.has_error else ""
208
+ return f"{prefix} {item.display_name}{error_marker}"
209
+
210
+ def _render_skill_item(
211
+ self, index: int, skill: Customization, file_path: Path | None
212
+ ) -> str:
213
+ """Render a skill item (skill root or file)."""
214
+ is_selected = index == self.selected_index and self.is_active
215
+ prefix = ">" if is_selected else " "
216
+
217
+ if file_path is None:
218
+ # Root skill item
219
+ is_expanded = skill.name in self.expanded_skills
220
+ has_files = bool(skill.metadata.get("files", []))
221
+ expand_char = ("▼" if is_expanded else "▶") if has_files else " "
222
+ error_marker = " [red]![/]" if skill.has_error else ""
223
+ return f"{prefix} {expand_char} {skill.display_name}{error_marker}"
224
+ else:
225
+ # Nested file item
226
+ indent = self._get_item_indent(file_path, skill)
227
+ indent_str = " " * indent
228
+ name = file_path.name
229
+ if file_path.is_dir():
230
+ name = f"{name}/"
231
+ return f"{prefix} {indent_str}{name}"
232
+
233
+ def _get_item_indent(self, file_path: Path | None, skill: Customization) -> int:
234
+ """Get indentation level for a file path within a skill."""
235
+ if not file_path:
236
+ return 0
237
+ skill_dir = skill.path.parent
238
+ try:
239
+ rel = file_path.relative_to(skill_dir)
240
+ return len(rel.parts)
241
+ except ValueError:
242
+ return 1
243
+
244
+ def _rebuild_flat_items(self) -> None:
245
+ """Build flat list of items for skills panel (with expanded files)."""
246
+ self._flat_items = []
247
+ for skill in self.customizations:
248
+ self._flat_items.append((skill, None))
249
+ if skill.name in self.expanded_skills:
250
+ files: list[SkillFile] = skill.metadata.get("files", [])
251
+ self._add_files_to_flat_list(skill, files)
252
+
253
+ def _add_files_to_flat_list(
254
+ self, skill: Customization, files: list[SkillFile]
255
+ ) -> None:
256
+ """Add files to flat list recursively."""
257
+ for file in files:
258
+ self._flat_items.append((skill, file.path))
259
+ if file.is_directory and file.children:
260
+ self._add_files_to_flat_list(skill, file.children)
261
+
262
+ def watch_customizations(self, customizations: list[Customization]) -> None:
263
+ """React to customizations list changes."""
264
+ if self._is_skills_panel:
265
+ self._rebuild_flat_items()
266
+ if self.selected_index >= len(self._flat_items):
267
+ self.selected_index = max(0, len(self._flat_items) - 1)
268
+ elif self.selected_index >= len(customizations):
269
+ self.selected_index = max(0, len(customizations) - 1)
270
+
271
+ if self.is_mounted:
272
+ self.border_title = self._render_header()
273
+ self.border_subtitle = self._render_footer()
274
+ self.call_later(self._rebuild_items)
275
+ if self.is_active:
276
+ self._emit_selection_message()
277
+
278
+ def watch_selected_index(self, _index: int) -> None:
279
+ """React to selected index changes."""
280
+ if self.is_mounted:
281
+ self.border_subtitle = self._render_footer()
282
+ self._refresh_display()
283
+ self._scroll_to_selection()
284
+ self._emit_selection_message()
285
+
286
+ async def _rebuild_items(self, *, scroll_to_selection: bool = False) -> None:
287
+ """Rebuild item widgets when customizations change."""
288
+ if not self.is_mounted:
289
+ return
290
+ container = self.query_one(".items-container", VerticalScroll)
291
+ await container.remove_children()
292
+
293
+ if self._is_skills_panel:
294
+ if not self._flat_items:
295
+ await container.mount(
296
+ Static("[dim italic]No items[/]", classes="empty-message")
297
+ )
298
+ else:
299
+ for i, (skill, file_path) in enumerate(self._flat_items):
300
+ is_selected = i == self.selected_index and self.is_active
301
+ classes = "item item-selected" if is_selected else "item"
302
+ await container.mount(
303
+ Static(
304
+ self._render_skill_item(i, skill, file_path),
305
+ classes=classes,
306
+ id=f"item-{i}",
307
+ )
308
+ )
309
+ elif not self.customizations:
310
+ await container.mount(
311
+ Static("[dim italic]No items[/]", classes="empty-message")
312
+ )
313
+ else:
314
+ for i, item in enumerate(self.customizations):
315
+ is_selected = i == self.selected_index and self.is_active
316
+ classes = "item item-selected" if is_selected else "item"
317
+ await container.mount(
318
+ Static(self._render_item(i, item), classes=classes, id=f"item-{i}")
319
+ )
320
+
321
+ if scroll_to_selection:
322
+ self._scroll_selection_to_top()
323
+ else:
324
+ container.scroll_home(animate=False)
325
+
326
+ def on_mount(self) -> None:
327
+ """Handle mount event."""
328
+ self.border_title = self._render_header()
329
+ self.border_subtitle = self._render_footer()
330
+ if self.customizations:
331
+ self.call_later(self._rebuild_items)
332
+
333
+ def _refresh_display(self) -> None:
334
+ """Refresh the panel display (updates existing widgets)."""
335
+ try:
336
+ items = list(self.query("Static.item"))
337
+ if self._is_skills_panel:
338
+ for i, (item_widget, (skill, file_path)) in enumerate(
339
+ zip(items, self._flat_items, strict=False)
340
+ ):
341
+ if isinstance(item_widget, Static):
342
+ item_widget.update(self._render_skill_item(i, skill, file_path))
343
+ is_selected = i == self.selected_index and self.is_active
344
+ item_widget.set_class(is_selected, "item-selected")
345
+ else:
346
+ for i, (item_widget, item) in enumerate(
347
+ zip(items, self.customizations, strict=False)
348
+ ):
349
+ if isinstance(item_widget, Static):
350
+ item_widget.update(self._render_item(i, item))
351
+ is_selected = i == self.selected_index and self.is_active
352
+ item_widget.set_class(is_selected, "item-selected")
353
+ except Exception:
354
+ pass
355
+
356
+ def _scroll_to_selection(self) -> None:
357
+ """Scroll to keep the selected item visible."""
358
+ if self._item_count() == 0:
359
+ return
360
+ try:
361
+ items = list(self.query(".item"))
362
+ if 0 <= self.selected_index < len(items):
363
+ items[self.selected_index].scroll_visible(animate=False)
364
+ except Exception:
365
+ pass
366
+
367
+ def _scroll_selection_to_top(self) -> None:
368
+ """Scroll so the selected item is at the top of the container."""
369
+ try:
370
+ container = self.query_one(".items-container", VerticalScroll)
371
+ container.scroll_to(y=self.selected_index, animate=False)
372
+ except Exception:
373
+ pass
374
+
375
+ def on_click(self, _event: Click) -> None:
376
+ """Handle click - select clicked item and focus panel."""
377
+ self.focus()
378
+
379
+ def on_focus(self) -> None:
380
+ """Handle focus event."""
381
+ self.is_active = True
382
+ self._refresh_display()
383
+ self._emit_selection_message()
384
+
385
+ def on_blur(self) -> None:
386
+ """Handle blur event."""
387
+ self.is_active = False
388
+ self._refresh_display()
389
+
390
+ def action_cursor_down(self) -> None:
391
+ """Move selection down."""
392
+ count = self._item_count()
393
+ if count > 0 and self.selected_index < count - 1:
394
+ self.selected_index += 1
395
+
396
+ def action_cursor_up(self) -> None:
397
+ """Move selection up."""
398
+ if self._item_count() > 0 and self.selected_index > 0:
399
+ self.selected_index -= 1
400
+
401
+ def action_cursor_top(self) -> None:
402
+ """Move selection to top."""
403
+ if self._item_count() > 0:
404
+ self.selected_index = 0
405
+
406
+ def action_cursor_bottom(self) -> None:
407
+ """Move selection to bottom."""
408
+ count = self._item_count()
409
+ if count > 0:
410
+ self.selected_index = count - 1
411
+
412
+ def action_select(self) -> None:
413
+ """Drill down into selected customization."""
414
+ if self._is_skills_panel:
415
+ if not self._flat_items or not (
416
+ 0 <= self.selected_index < len(self._flat_items)
417
+ ):
418
+ return
419
+ skill, file_path = self._flat_items[self.selected_index]
420
+ # Don't drill down into directories
421
+ if file_path is not None and file_path.is_dir():
422
+ return
423
+ self.post_message(self.DrillDown(skill))
424
+ elif self.selected_customization:
425
+ self.post_message(self.DrillDown(self.selected_customization))
426
+
427
+ def action_expand(self) -> None:
428
+ """Expand the currently selected skill."""
429
+ if not self._is_skills_panel or not self._flat_items:
430
+ return
431
+ if not (0 <= self.selected_index < len(self._flat_items)):
432
+ return
433
+
434
+ skill, file_path = self._flat_items[self.selected_index]
435
+ # Only expand root skill items (not nested files)
436
+ if file_path is None and skill.name not in self.expanded_skills:
437
+ new_expanded = self.expanded_skills.copy()
438
+ new_expanded.add(skill.name)
439
+ self.expanded_skills = new_expanded
440
+ self._rebuild_flat_items()
441
+ self.call_later(self._rebuild_items_and_scroll)
442
+
443
+ def action_collapse(self) -> None:
444
+ """Collapse the currently selected skill."""
445
+ if not self._is_skills_panel or not self._flat_items:
446
+ return
447
+ if not (0 <= self.selected_index < len(self._flat_items)):
448
+ return
449
+
450
+ skill, _ = self._flat_items[self.selected_index]
451
+ if skill.name in self.expanded_skills:
452
+ new_expanded = self.expanded_skills.copy()
453
+ new_expanded.discard(skill.name)
454
+ self.expanded_skills = new_expanded
455
+ self._rebuild_flat_items()
456
+ self._adjust_selection_after_collapse(skill)
457
+ self.call_later(self._rebuild_items)
458
+
459
+ def _adjust_selection_after_collapse(self, collapsed_skill: Customization) -> None:
460
+ """Adjust selection after collapsing a skill."""
461
+ for i, (skill, file_path) in enumerate(self._flat_items):
462
+ if skill == collapsed_skill and file_path is None:
463
+ self.selected_index = i
464
+ break
465
+
466
+ async def _rebuild_items_and_scroll(self) -> None:
467
+ """Rebuild items and scroll selection to top."""
468
+ await self._rebuild_items(scroll_to_selection=True)
469
+
470
+ def action_focus_next_panel(self) -> None:
471
+ """Delegate to app's focus_next_panel action."""
472
+ cast("LazyOpenCode", self.app).action_focus_next_panel()
473
+
474
+ def action_focus_previous_panel(self) -> None:
475
+ """Delegate to app's focus_previous_panel action."""
476
+ cast("LazyOpenCode", self.app).action_focus_previous_panel()
477
+
478
+ def set_customizations(self, customizations: list[Customization]) -> None:
479
+ """Set the customizations for this panel (filtered by type)."""
480
+ filtered = [c for c in customizations if c.type == self.customization_type]
481
+ self.customizations = filtered
482
+ if self._is_skills_panel:
483
+ self._rebuild_flat_items()
484
+ self._update_empty_state()
485
+
486
+ def _update_empty_state(self) -> None:
487
+ """Toggle empty class based on item count."""
488
+ if self._item_count() == 0:
489
+ self.add_class("empty")
490
+ else:
491
+ self.remove_class("empty")
492
+
493
+ def _emit_selection_message(self) -> None:
494
+ """Emit selection message based on current selection."""
495
+ if self._is_skills_panel and self._flat_items:
496
+ if 0 <= self.selected_index < len(self._flat_items):
497
+ skill, file_path = self._flat_items[self.selected_index]
498
+ self.post_message(self.SelectionChanged(skill))
499
+ self.post_message(self.SkillFileSelected(skill, file_path))
500
+ else:
501
+ self.post_message(self.SelectionChanged(self.selected_customization))