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.
- lazyopencode/__init__.py +48 -0
- lazyopencode/__main__.py +6 -0
- lazyopencode/_version.py +34 -0
- lazyopencode/app.py +310 -0
- lazyopencode/bindings.py +27 -0
- lazyopencode/mixins/filtering.py +33 -0
- lazyopencode/mixins/help.py +74 -0
- lazyopencode/mixins/navigation.py +184 -0
- lazyopencode/models/__init__.py +17 -0
- lazyopencode/models/customization.py +120 -0
- lazyopencode/services/__init__.py +7 -0
- lazyopencode/services/discovery.py +350 -0
- lazyopencode/services/gitignore_filter.py +123 -0
- lazyopencode/services/parsers/__init__.py +152 -0
- lazyopencode/services/parsers/agent.py +93 -0
- lazyopencode/services/parsers/command.py +94 -0
- lazyopencode/services/parsers/mcp.py +67 -0
- lazyopencode/services/parsers/plugin.py +127 -0
- lazyopencode/services/parsers/rules.py +65 -0
- lazyopencode/services/parsers/skill.py +138 -0
- lazyopencode/services/parsers/tool.py +67 -0
- lazyopencode/styles/app.tcss +173 -0
- lazyopencode/themes.py +30 -0
- lazyopencode/widgets/__init__.py +17 -0
- lazyopencode/widgets/app_footer.py +71 -0
- lazyopencode/widgets/combined_panel.py +345 -0
- lazyopencode/widgets/detail_pane.py +338 -0
- lazyopencode/widgets/filter_input.py +88 -0
- lazyopencode/widgets/helpers/__init__.py +5 -0
- lazyopencode/widgets/helpers/rendering.py +17 -0
- lazyopencode/widgets/status_panel.py +70 -0
- lazyopencode/widgets/type_panel.py +501 -0
- lazyopencode-0.1.0.dist-info/METADATA +118 -0
- lazyopencode-0.1.0.dist-info/RECORD +37 -0
- lazyopencode-0.1.0.dist-info/WHEEL +4 -0
- lazyopencode-0.1.0.dist-info/entry_points.txt +2 -0
- 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,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))
|