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,345 @@
|
|
|
1
|
+
"""CombinedPanel widget for Rules/MCPs tabs."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, cast
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.binding import Binding
|
|
7
|
+
from textual.containers import VerticalScroll
|
|
8
|
+
from textual.message import Message
|
|
9
|
+
from textual.reactive import reactive
|
|
10
|
+
from textual.widget import Widget
|
|
11
|
+
from textual.widgets import Static
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from lazyopencode.app import LazyOpenCode
|
|
15
|
+
|
|
16
|
+
from lazyopencode.models.customization import (
|
|
17
|
+
Customization,
|
|
18
|
+
CustomizationType,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CombinedPanel(Widget):
|
|
23
|
+
"""Panel with tabs for Rules and MCPs."""
|
|
24
|
+
|
|
25
|
+
BINDINGS = [
|
|
26
|
+
Binding("tab", "focus_next_panel", "Next Panel", show=False),
|
|
27
|
+
Binding("shift+tab", "focus_previous_panel", "Prev Panel", show=False),
|
|
28
|
+
Binding("j", "cursor_down", "Down", show=False),
|
|
29
|
+
Binding("k", "cursor_up", "Up", show=False),
|
|
30
|
+
Binding("down", "cursor_down", "Down", show=False),
|
|
31
|
+
Binding("up", "cursor_up", "Up", show=False),
|
|
32
|
+
Binding("home", "cursor_top", "Top", show=False),
|
|
33
|
+
Binding("G", "cursor_bottom", "Bottom", show=False, key_display="shift+g"),
|
|
34
|
+
Binding("enter", "select", "Select", show=False),
|
|
35
|
+
Binding("left", "prev_tab", "Prev Tab", show=False),
|
|
36
|
+
Binding("right", "next_tab", "Next Tab", show=False),
|
|
37
|
+
Binding("h", "prev_tab", "Prev Tab", show=False),
|
|
38
|
+
Binding("l", "next_tab", "Next Tab", show=False),
|
|
39
|
+
Binding("[", "prev_tab", "Prev Tab", show=False),
|
|
40
|
+
Binding("]", "next_tab", "Next Tab", show=False),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
DEFAULT_CSS = """
|
|
44
|
+
CombinedPanel {
|
|
45
|
+
height: 1fr;
|
|
46
|
+
min-height: 3;
|
|
47
|
+
border: solid $primary;
|
|
48
|
+
padding: 0 1;
|
|
49
|
+
border-title-align: left;
|
|
50
|
+
border-subtitle-align: right;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
CombinedPanel:focus {
|
|
54
|
+
border: double $accent;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
CombinedPanel .items-container {
|
|
58
|
+
height: auto;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
CombinedPanel .item {
|
|
62
|
+
height: 1;
|
|
63
|
+
width: 100%;
|
|
64
|
+
text-wrap: nowrap;
|
|
65
|
+
text-overflow: ellipsis;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
CombinedPanel .item-selected {
|
|
69
|
+
background: $accent;
|
|
70
|
+
text-style: bold;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
CombinedPanel .empty-message {
|
|
74
|
+
color: $text-muted;
|
|
75
|
+
text-style: italic;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
CombinedPanel.empty {
|
|
79
|
+
height: 3;
|
|
80
|
+
min-height: 3;
|
|
81
|
+
max-height: 3;
|
|
82
|
+
}
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
# Default tab configuration (can be overridden in __init__)
|
|
86
|
+
DEFAULT_TABS = [
|
|
87
|
+
(CustomizationType.RULES, 4, "Memory"),
|
|
88
|
+
(CustomizationType.MCP, 5, "MCPs"),
|
|
89
|
+
(CustomizationType.TOOL, 6, "Tools"),
|
|
90
|
+
(CustomizationType.PLUGIN, 7, "Plugins"),
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
current_tab: reactive[int] = reactive(0)
|
|
94
|
+
selected_index: reactive[int] = reactive(0)
|
|
95
|
+
is_active: reactive[bool] = reactive(False)
|
|
96
|
+
|
|
97
|
+
class SelectionChanged(Message):
|
|
98
|
+
"""Emitted when selected customization changes."""
|
|
99
|
+
|
|
100
|
+
def __init__(self, customization: Customization | None) -> None:
|
|
101
|
+
self.customization = customization
|
|
102
|
+
super().__init__()
|
|
103
|
+
|
|
104
|
+
class DrillDown(Message):
|
|
105
|
+
"""Emitted when user drills into a customization."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, customization: Customization) -> None:
|
|
108
|
+
self.customization = customization
|
|
109
|
+
super().__init__()
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
tabs: list[tuple[CustomizationType, int, str]] | None = None,
|
|
114
|
+
name: str | None = None,
|
|
115
|
+
id: str | None = None,
|
|
116
|
+
classes: str | None = None,
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Initialize CombinedPanel.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
tabs: List of (type, number, label) tuples.
|
|
122
|
+
name: The name of the widget.
|
|
123
|
+
id: The ID of the widget in the DOM.
|
|
124
|
+
classes: The CSS classes of the widget.
|
|
125
|
+
"""
|
|
126
|
+
super().__init__(name=name, id=id, classes=classes)
|
|
127
|
+
self.tabs = tabs or self.DEFAULT_TABS
|
|
128
|
+
self.can_focus = True
|
|
129
|
+
self._customizations_by_type: dict[CustomizationType, list[Customization]] = {
|
|
130
|
+
t: [] for t, _, _ in self.tabs
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def current_type(self) -> CustomizationType:
|
|
135
|
+
"""Get the current tab's customization type."""
|
|
136
|
+
return self.tabs[self.current_tab][0]
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def current_items(self) -> list[Customization]:
|
|
140
|
+
"""Get items for the current tab."""
|
|
141
|
+
return self._customizations_by_type.get(self.current_type, [])
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def selected_customization(self) -> Customization | None:
|
|
145
|
+
"""Get the currently selected customization."""
|
|
146
|
+
items = self.current_items
|
|
147
|
+
if items and 0 <= self.selected_index < len(items):
|
|
148
|
+
return items[self.selected_index]
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
def compose(self) -> ComposeResult:
|
|
152
|
+
"""Compose the panel content."""
|
|
153
|
+
with VerticalScroll(classes="items-container"):
|
|
154
|
+
items = self.current_items
|
|
155
|
+
if not items:
|
|
156
|
+
yield Static("[dim italic]No items[/]", classes="empty-message")
|
|
157
|
+
else:
|
|
158
|
+
for i, item in enumerate(items):
|
|
159
|
+
yield Static(
|
|
160
|
+
self._render_item(i, item), classes="item", id=f"item-{i}"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def _render_header(self) -> str:
|
|
164
|
+
"""Render the panel header with tabs."""
|
|
165
|
+
parts = []
|
|
166
|
+
for i, (_, num, label) in enumerate(self.tabs):
|
|
167
|
+
if i == self.current_tab:
|
|
168
|
+
parts.append(f"[b][{num}]-{label}[/b]")
|
|
169
|
+
else:
|
|
170
|
+
parts.append(f"[{num}]-{label}")
|
|
171
|
+
return " | ".join(parts) + "-"
|
|
172
|
+
|
|
173
|
+
def _render_footer(self) -> str:
|
|
174
|
+
"""Render the panel footer with selection position."""
|
|
175
|
+
count = len(self.current_items)
|
|
176
|
+
if count == 0:
|
|
177
|
+
return "0 of 0"
|
|
178
|
+
return f"{self.selected_index + 1} of {count}"
|
|
179
|
+
|
|
180
|
+
def _render_item(self, index: int, item: Customization) -> str:
|
|
181
|
+
"""Render a single item."""
|
|
182
|
+
is_selected = index == self.selected_index and self.is_active
|
|
183
|
+
prefix = ">" if is_selected else " "
|
|
184
|
+
error_marker = " [red]![/]" if item.has_error else ""
|
|
185
|
+
return f"{prefix} {item.display_name}{error_marker}"
|
|
186
|
+
|
|
187
|
+
def on_mount(self) -> None:
|
|
188
|
+
"""Handle mount event."""
|
|
189
|
+
self.border_title = self._render_header()
|
|
190
|
+
self.border_subtitle = self._render_footer()
|
|
191
|
+
|
|
192
|
+
def watch_current_tab(self, _tab: int) -> None:
|
|
193
|
+
"""React to tab changes."""
|
|
194
|
+
self.selected_index = 0
|
|
195
|
+
if self.is_mounted:
|
|
196
|
+
self.border_title = self._render_header()
|
|
197
|
+
self.border_subtitle = self._render_footer()
|
|
198
|
+
self.call_later(self._rebuild_items)
|
|
199
|
+
if self.is_active:
|
|
200
|
+
self._emit_selection_message()
|
|
201
|
+
|
|
202
|
+
def watch_selected_index(self, _index: int) -> None:
|
|
203
|
+
"""React to selected index changes."""
|
|
204
|
+
if self.is_mounted:
|
|
205
|
+
self.border_subtitle = self._render_footer()
|
|
206
|
+
self._refresh_display()
|
|
207
|
+
self._scroll_to_selection()
|
|
208
|
+
self._emit_selection_message()
|
|
209
|
+
|
|
210
|
+
async def _rebuild_items(self) -> None:
|
|
211
|
+
"""Rebuild item widgets."""
|
|
212
|
+
if not self.is_mounted:
|
|
213
|
+
return
|
|
214
|
+
container = self.query_one(".items-container", VerticalScroll)
|
|
215
|
+
await container.remove_children()
|
|
216
|
+
|
|
217
|
+
items = self.current_items
|
|
218
|
+
if not items:
|
|
219
|
+
await container.mount(
|
|
220
|
+
Static("[dim italic]No items[/]", classes="empty-message")
|
|
221
|
+
)
|
|
222
|
+
else:
|
|
223
|
+
for i, item in enumerate(items):
|
|
224
|
+
is_selected = i == self.selected_index and self.is_active
|
|
225
|
+
classes = "item item-selected" if is_selected else "item"
|
|
226
|
+
await container.mount(
|
|
227
|
+
Static(self._render_item(i, item), classes=classes, id=f"item-{i}")
|
|
228
|
+
)
|
|
229
|
+
container.scroll_home(animate=False)
|
|
230
|
+
|
|
231
|
+
def _refresh_display(self) -> None:
|
|
232
|
+
"""Refresh the panel display."""
|
|
233
|
+
try:
|
|
234
|
+
items_widgets = list(self.query("Static.item"))
|
|
235
|
+
items = self.current_items
|
|
236
|
+
for i, (item_widget, item) in enumerate(
|
|
237
|
+
zip(items_widgets, items, strict=False)
|
|
238
|
+
):
|
|
239
|
+
if isinstance(item_widget, Static):
|
|
240
|
+
item_widget.update(self._render_item(i, item))
|
|
241
|
+
is_selected = i == self.selected_index and self.is_active
|
|
242
|
+
item_widget.set_class(is_selected, "item-selected")
|
|
243
|
+
except Exception:
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
def _scroll_to_selection(self) -> None:
|
|
247
|
+
"""Scroll to keep the selected item visible."""
|
|
248
|
+
if len(self.current_items) == 0:
|
|
249
|
+
return
|
|
250
|
+
try:
|
|
251
|
+
items = list(self.query(".item"))
|
|
252
|
+
if 0 <= self.selected_index < len(items):
|
|
253
|
+
items[self.selected_index].scroll_visible(animate=False)
|
|
254
|
+
except Exception:
|
|
255
|
+
pass
|
|
256
|
+
|
|
257
|
+
def on_focus(self) -> None:
|
|
258
|
+
"""Handle focus event."""
|
|
259
|
+
self.is_active = True
|
|
260
|
+
self._refresh_display()
|
|
261
|
+
self._emit_selection_message()
|
|
262
|
+
|
|
263
|
+
def on_blur(self) -> None:
|
|
264
|
+
"""Handle blur event."""
|
|
265
|
+
self.is_active = False
|
|
266
|
+
self._refresh_display()
|
|
267
|
+
|
|
268
|
+
def action_cursor_down(self) -> None:
|
|
269
|
+
"""Move selection down."""
|
|
270
|
+
count = len(self.current_items)
|
|
271
|
+
if count > 0 and self.selected_index < count - 1:
|
|
272
|
+
self.selected_index += 1
|
|
273
|
+
|
|
274
|
+
def action_cursor_up(self) -> None:
|
|
275
|
+
"""Move selection up."""
|
|
276
|
+
if len(self.current_items) > 0 and self.selected_index > 0:
|
|
277
|
+
self.selected_index -= 1
|
|
278
|
+
|
|
279
|
+
def action_cursor_top(self) -> None:
|
|
280
|
+
"""Move selection to top."""
|
|
281
|
+
if len(self.current_items) > 0:
|
|
282
|
+
self.selected_index = 0
|
|
283
|
+
|
|
284
|
+
def action_cursor_bottom(self) -> None:
|
|
285
|
+
"""Move selection to bottom."""
|
|
286
|
+
count = len(self.current_items)
|
|
287
|
+
if count > 0:
|
|
288
|
+
self.selected_index = count - 1
|
|
289
|
+
|
|
290
|
+
def action_next_tab(self) -> None:
|
|
291
|
+
"""Switch to next tab."""
|
|
292
|
+
self.current_tab = (self.current_tab + 1) % len(self.tabs)
|
|
293
|
+
|
|
294
|
+
def action_prev_tab(self) -> None:
|
|
295
|
+
"""Switch to previous tab."""
|
|
296
|
+
self.current_tab = (self.current_tab - 1) % len(self.tabs)
|
|
297
|
+
|
|
298
|
+
def action_select(self) -> None:
|
|
299
|
+
"""Drill down into selected customization."""
|
|
300
|
+
if self.selected_customization:
|
|
301
|
+
self.post_message(self.DrillDown(self.selected_customization))
|
|
302
|
+
|
|
303
|
+
def action_focus_next_panel(self) -> None:
|
|
304
|
+
"""Cycle through tabs, then delegate to app when on last tab."""
|
|
305
|
+
if self.current_tab < len(self.tabs) - 1:
|
|
306
|
+
self.current_tab += 1
|
|
307
|
+
else:
|
|
308
|
+
cast("LazyOpenCode", self.app).action_focus_next_panel()
|
|
309
|
+
|
|
310
|
+
def action_focus_previous_panel(self) -> None:
|
|
311
|
+
"""Cycle through tabs backward, then delegate to app when on first tab."""
|
|
312
|
+
if self.current_tab > 0:
|
|
313
|
+
self.current_tab -= 1
|
|
314
|
+
else:
|
|
315
|
+
cast("LazyOpenCode", self.app).action_focus_previous_panel()
|
|
316
|
+
|
|
317
|
+
def set_customizations(self, customizations: list[Customization]) -> None:
|
|
318
|
+
"""Set the customizations for all tabs."""
|
|
319
|
+
for ctype, _, _ in self.tabs:
|
|
320
|
+
self._customizations_by_type[ctype] = [
|
|
321
|
+
c for c in customizations if c.type == ctype
|
|
322
|
+
]
|
|
323
|
+
if self.selected_index >= len(self.current_items):
|
|
324
|
+
self.selected_index = max(0, len(self.current_items) - 1)
|
|
325
|
+
if self.is_mounted:
|
|
326
|
+
self.border_subtitle = self._render_footer()
|
|
327
|
+
self.call_later(self._rebuild_items)
|
|
328
|
+
self._update_empty_state()
|
|
329
|
+
|
|
330
|
+
def _update_empty_state(self) -> None:
|
|
331
|
+
"""Toggle empty class based on total item count."""
|
|
332
|
+
total = sum(len(items) for items in self._customizations_by_type.values())
|
|
333
|
+
if total == 0:
|
|
334
|
+
self.add_class("empty")
|
|
335
|
+
else:
|
|
336
|
+
self.remove_class("empty")
|
|
337
|
+
|
|
338
|
+
def _emit_selection_message(self) -> None:
|
|
339
|
+
"""Emit selection message."""
|
|
340
|
+
self.post_message(self.SelectionChanged(self.selected_customization))
|
|
341
|
+
|
|
342
|
+
def switch_to_tab(self, tab_index: int) -> None:
|
|
343
|
+
"""Switch to a specific tab by index."""
|
|
344
|
+
if 0 <= tab_index < len(self.tabs):
|
|
345
|
+
self.current_tab = tab_index
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""MainPane widget for displaying customization details."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING, cast
|
|
6
|
+
|
|
7
|
+
from rich.console import Group, RenderableType
|
|
8
|
+
from rich.syntax import Syntax
|
|
9
|
+
from textual.app import ComposeResult
|
|
10
|
+
from textual.binding import Binding
|
|
11
|
+
from textual.reactive import reactive
|
|
12
|
+
from textual.widget import Widget
|
|
13
|
+
from textual.widgets import Static
|
|
14
|
+
|
|
15
|
+
from lazyopencode.models.customization import Customization
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from lazyopencode.app import LazyOpenCode
|
|
19
|
+
|
|
20
|
+
TEXTUAL_TO_PYGMENTS_THEME: dict[str, str] = {
|
|
21
|
+
"catppuccin-latte": "default",
|
|
22
|
+
"catppuccin-mocha": "monokai",
|
|
23
|
+
"dracula": "dracula",
|
|
24
|
+
"gruvbox": "gruvbox-dark",
|
|
25
|
+
"monokai": "monokai",
|
|
26
|
+
"nord": "nord",
|
|
27
|
+
"solarized-light": "solarized-light",
|
|
28
|
+
"textual-ansi": "default",
|
|
29
|
+
"textual-dark": "monokai",
|
|
30
|
+
"textual-light": "default",
|
|
31
|
+
"tokyo-night": "nord",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
DEFAULT_SYNTAX_THEME = "monokai"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MainPane(Widget):
|
|
38
|
+
"""Main pane with switchable content/metadata views."""
|
|
39
|
+
|
|
40
|
+
BINDINGS = [
|
|
41
|
+
Binding("[", "prev_view", "Prev View", show=False),
|
|
42
|
+
Binding("]", "next_view", "Next View", show=False),
|
|
43
|
+
Binding("j", "scroll_down", "Scroll down", show=False),
|
|
44
|
+
Binding("k", "scroll_up", "Scroll up", show=False),
|
|
45
|
+
Binding("down", "scroll_down", "Scroll down", show=False),
|
|
46
|
+
Binding("up", "scroll_up", "Scroll up", show=False),
|
|
47
|
+
Binding("d", "scroll_page_down", "Page down", show=False),
|
|
48
|
+
Binding("u", "scroll_page_up", "Page up", show=False),
|
|
49
|
+
Binding("home", "scroll_top", "Scroll top", show=False),
|
|
50
|
+
Binding(
|
|
51
|
+
"G", "scroll_bottom", "Scroll bottom", show=False, key_display="shift+g"
|
|
52
|
+
),
|
|
53
|
+
Binding("escape", "go_back", "Go Back", show=False),
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
DEFAULT_CSS = """
|
|
57
|
+
MainPane {
|
|
58
|
+
height: 100%;
|
|
59
|
+
border: solid $primary;
|
|
60
|
+
padding: 1 0 1 2;
|
|
61
|
+
overflow-y: auto;
|
|
62
|
+
border-title-align: left;
|
|
63
|
+
border-subtitle-align: right;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
MainPane:focus {
|
|
67
|
+
border: double $accent;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
MainPane .pane-content {
|
|
71
|
+
width: 100%;
|
|
72
|
+
}
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
customization: reactive[Customization | None] = reactive(None)
|
|
76
|
+
view_mode: reactive[str] = reactive("content")
|
|
77
|
+
display_path: reactive[Path | None] = reactive(None)
|
|
78
|
+
selected_file: reactive[Path | None] = reactive(None)
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
name: str | None = None,
|
|
83
|
+
id: str | None = None,
|
|
84
|
+
classes: str | None = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Initialize MainPane."""
|
|
87
|
+
super().__init__(name=name, id=id, classes=classes)
|
|
88
|
+
self.can_focus = True
|
|
89
|
+
|
|
90
|
+
def compose(self) -> ComposeResult:
|
|
91
|
+
"""Compose the pane content."""
|
|
92
|
+
yield Static(self._get_renderable(), classes="pane-content")
|
|
93
|
+
|
|
94
|
+
def _get_renderable(self) -> RenderableType:
|
|
95
|
+
"""Render content based on current view mode."""
|
|
96
|
+
if self.view_mode == "metadata":
|
|
97
|
+
return self._render_metadata()
|
|
98
|
+
return self._render_file_content()
|
|
99
|
+
|
|
100
|
+
def _render_metadata(self) -> str:
|
|
101
|
+
"""Render metadata view."""
|
|
102
|
+
if not self.customization:
|
|
103
|
+
return "[dim italic]Select a customization[/]"
|
|
104
|
+
|
|
105
|
+
c = self.customization
|
|
106
|
+
display_path = self.display_path or c.path
|
|
107
|
+
lines = [
|
|
108
|
+
f"[bold]{c.name}[/]",
|
|
109
|
+
"",
|
|
110
|
+
f"[dim]Type:[/] {c.type_label}",
|
|
111
|
+
f"[dim]Level:[/] {c.level_label}",
|
|
112
|
+
f"[dim]Path:[/] {display_path}",
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
if c.description:
|
|
116
|
+
lines.append(f"[dim]Description:[/] {c.description}")
|
|
117
|
+
|
|
118
|
+
if c.metadata:
|
|
119
|
+
# Skip internal fields (files is used for tree navigation)
|
|
120
|
+
skip_keys = {"description", "files"}
|
|
121
|
+
extra_metadata = {k: v for k, v in c.metadata.items() if k not in skip_keys}
|
|
122
|
+
if extra_metadata:
|
|
123
|
+
lines.append("")
|
|
124
|
+
for key, value in extra_metadata.items():
|
|
125
|
+
lines.append(f"[dim]{key}:[/] {value}")
|
|
126
|
+
|
|
127
|
+
if c.has_error:
|
|
128
|
+
lines.append("")
|
|
129
|
+
lines.append(f"[red]Error:[/] {c.error}")
|
|
130
|
+
return "\n".join(lines)
|
|
131
|
+
|
|
132
|
+
def _get_syntax_theme(self) -> str:
|
|
133
|
+
"""Get Pygments theme based on current app theme."""
|
|
134
|
+
app_theme = self.app.theme or "textual-dark"
|
|
135
|
+
return TEXTUAL_TO_PYGMENTS_THEME.get(app_theme, DEFAULT_SYNTAX_THEME)
|
|
136
|
+
|
|
137
|
+
def _extract_frontmatter_text(self, content: str) -> tuple[str | None, str]:
|
|
138
|
+
"""Extract raw frontmatter text and body from markdown content."""
|
|
139
|
+
pattern = r"^---\s*\n(.*?)\n---\s*\n(.*)$"
|
|
140
|
+
match = re.match(pattern, content, re.DOTALL)
|
|
141
|
+
if match:
|
|
142
|
+
return match.group(1), match.group(2)
|
|
143
|
+
return None, content
|
|
144
|
+
|
|
145
|
+
def _render_markdown_with_frontmatter(self, content: str) -> RenderableType:
|
|
146
|
+
"""Render markdown with separate frontmatter highlighting."""
|
|
147
|
+
theme = self._get_syntax_theme()
|
|
148
|
+
frontmatter_text, body = self._extract_frontmatter_text(content)
|
|
149
|
+
|
|
150
|
+
if frontmatter_text:
|
|
151
|
+
parts: list[RenderableType] = [
|
|
152
|
+
Syntax(frontmatter_text, "yaml", theme=theme, word_wrap=True),
|
|
153
|
+
"",
|
|
154
|
+
Syntax(body, "markdown", theme=theme, word_wrap=True),
|
|
155
|
+
]
|
|
156
|
+
return Group(*parts)
|
|
157
|
+
|
|
158
|
+
return Syntax(content, "markdown", theme=theme, word_wrap=True)
|
|
159
|
+
|
|
160
|
+
def _render_file_content(self) -> RenderableType:
|
|
161
|
+
"""Render file content view with syntax highlighting."""
|
|
162
|
+
# Check if a specific file is selected (from skill tree)
|
|
163
|
+
if self.selected_file:
|
|
164
|
+
return self._render_selected_file()
|
|
165
|
+
|
|
166
|
+
if not self.customization:
|
|
167
|
+
return "[dim italic]No content to display[/]"
|
|
168
|
+
if self.customization.has_error:
|
|
169
|
+
return f"[red]Error:[/] {self.customization.error}"
|
|
170
|
+
|
|
171
|
+
content = self.customization.content
|
|
172
|
+
if not content:
|
|
173
|
+
# Try to read from file
|
|
174
|
+
if self.customization.path and self.customization.path.exists():
|
|
175
|
+
try:
|
|
176
|
+
content = self.customization.path.read_text(encoding="utf-8")
|
|
177
|
+
except Exception as e:
|
|
178
|
+
return f"[red]Error reading file:[/] {e}"
|
|
179
|
+
else:
|
|
180
|
+
return "[dim italic]Empty[/]"
|
|
181
|
+
|
|
182
|
+
suffix = self.customization.path.suffix.lower()
|
|
183
|
+
|
|
184
|
+
# Check if content has frontmatter (for both .md files and synthetic markdown)
|
|
185
|
+
if suffix == ".md" or content.startswith("---\n"):
|
|
186
|
+
return self._render_markdown_with_frontmatter(content)
|
|
187
|
+
|
|
188
|
+
lexer_map = {
|
|
189
|
+
".json": "json",
|
|
190
|
+
".py": "python",
|
|
191
|
+
".js": "javascript",
|
|
192
|
+
".ts": "typescript",
|
|
193
|
+
".yaml": "yaml",
|
|
194
|
+
".yml": "yaml",
|
|
195
|
+
}
|
|
196
|
+
lexer = lexer_map.get(suffix, "text")
|
|
197
|
+
|
|
198
|
+
return Syntax(
|
|
199
|
+
content,
|
|
200
|
+
lexer,
|
|
201
|
+
theme=self._get_syntax_theme(),
|
|
202
|
+
word_wrap=True,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def _render_selected_file(self) -> RenderableType:
|
|
206
|
+
"""Render content of a selected file (from skill tree)."""
|
|
207
|
+
if not self.selected_file:
|
|
208
|
+
return "[dim italic]No file selected[/]"
|
|
209
|
+
|
|
210
|
+
path = self.selected_file
|
|
211
|
+
if path.is_dir():
|
|
212
|
+
return f"[bold]{path.name}/[/]\n\n[dim](directory)[/]"
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
content = path.read_text(encoding="utf-8")
|
|
216
|
+
except OSError as e:
|
|
217
|
+
return f"[red]Error reading file:[/] {e}"
|
|
218
|
+
|
|
219
|
+
if not content:
|
|
220
|
+
return "[dim italic]Empty file[/]"
|
|
221
|
+
|
|
222
|
+
suffix = path.suffix.lower()
|
|
223
|
+
theme = self._get_syntax_theme()
|
|
224
|
+
|
|
225
|
+
lexer_map = {
|
|
226
|
+
".md": "markdown",
|
|
227
|
+
".json": "json",
|
|
228
|
+
".py": "python",
|
|
229
|
+
".sh": "bash",
|
|
230
|
+
".yaml": "yaml",
|
|
231
|
+
".yml": "yaml",
|
|
232
|
+
".js": "javascript",
|
|
233
|
+
".ts": "typescript",
|
|
234
|
+
}
|
|
235
|
+
lexer = lexer_map.get(suffix, "text")
|
|
236
|
+
|
|
237
|
+
if suffix == ".md":
|
|
238
|
+
return self._render_markdown_with_frontmatter(content)
|
|
239
|
+
|
|
240
|
+
return Syntax(
|
|
241
|
+
content,
|
|
242
|
+
lexer,
|
|
243
|
+
theme=theme,
|
|
244
|
+
word_wrap=True,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def on_mount(self) -> None:
|
|
248
|
+
"""Handle mount event."""
|
|
249
|
+
self._update_title()
|
|
250
|
+
self.border_subtitle = self._render_footer()
|
|
251
|
+
|
|
252
|
+
def _update_title(self) -> None:
|
|
253
|
+
"""Update border title based on view mode."""
|
|
254
|
+
if self.view_mode == "content":
|
|
255
|
+
tabs = "[b]Content[/b] - Metadata"
|
|
256
|
+
else:
|
|
257
|
+
tabs = "Content - [b]Metadata[/b]"
|
|
258
|
+
self.border_title = f"[0]-{tabs}-"
|
|
259
|
+
|
|
260
|
+
def _render_footer(self) -> str:
|
|
261
|
+
"""Render the panel footer with file path.
|
|
262
|
+
|
|
263
|
+
Priority: display_path > selected_file > customization.path
|
|
264
|
+
"""
|
|
265
|
+
if self.display_path:
|
|
266
|
+
return str(self.display_path)
|
|
267
|
+
if self.selected_file:
|
|
268
|
+
return str(self.selected_file)
|
|
269
|
+
if not self.customization:
|
|
270
|
+
return ""
|
|
271
|
+
return str(self.customization.path)
|
|
272
|
+
|
|
273
|
+
def watch_view_mode(self, _mode: str) -> None:
|
|
274
|
+
"""React to view mode changes."""
|
|
275
|
+
self._update_title()
|
|
276
|
+
self._refresh_display()
|
|
277
|
+
|
|
278
|
+
def watch_customization(self, customization: Customization | None) -> None:
|
|
279
|
+
"""React to customization changes."""
|
|
280
|
+
self.selected_file = None
|
|
281
|
+
if customization is None:
|
|
282
|
+
self.display_path = None
|
|
283
|
+
self.border_subtitle = self._render_footer()
|
|
284
|
+
self._refresh_display()
|
|
285
|
+
|
|
286
|
+
def watch_selected_file(self, _path: Path | None) -> None:
|
|
287
|
+
"""React to selected file changes (for skill files)."""
|
|
288
|
+
self.border_subtitle = self._render_footer()
|
|
289
|
+
self._refresh_display()
|
|
290
|
+
|
|
291
|
+
def watch_display_path(self, _path: Path | None) -> None:
|
|
292
|
+
"""React to display path changes."""
|
|
293
|
+
self.border_subtitle = self._render_footer()
|
|
294
|
+
self.refresh()
|
|
295
|
+
|
|
296
|
+
def _refresh_display(self) -> None:
|
|
297
|
+
"""Refresh the pane display."""
|
|
298
|
+
try:
|
|
299
|
+
content = self.query_one(".pane-content", Static)
|
|
300
|
+
content.update(self._get_renderable())
|
|
301
|
+
except Exception:
|
|
302
|
+
pass
|
|
303
|
+
|
|
304
|
+
def action_next_view(self) -> None:
|
|
305
|
+
"""Switch to next view."""
|
|
306
|
+
self.view_mode = "metadata" if self.view_mode == "content" else "content"
|
|
307
|
+
|
|
308
|
+
def action_prev_view(self) -> None:
|
|
309
|
+
"""Switch to previous view."""
|
|
310
|
+
self.view_mode = "content" if self.view_mode == "metadata" else "metadata"
|
|
311
|
+
|
|
312
|
+
def action_scroll_down(self) -> None:
|
|
313
|
+
"""Scroll content down."""
|
|
314
|
+
self.scroll_down(animate=False)
|
|
315
|
+
|
|
316
|
+
def action_scroll_up(self) -> None:
|
|
317
|
+
"""Scroll content up."""
|
|
318
|
+
self.scroll_up(animate=False)
|
|
319
|
+
|
|
320
|
+
def action_scroll_top(self) -> None:
|
|
321
|
+
"""Scroll to top."""
|
|
322
|
+
self.scroll_home(animate=False)
|
|
323
|
+
|
|
324
|
+
def action_scroll_bottom(self) -> None:
|
|
325
|
+
"""Scroll to bottom."""
|
|
326
|
+
self.scroll_end(animate=False)
|
|
327
|
+
|
|
328
|
+
def action_scroll_page_down(self) -> None:
|
|
329
|
+
"""Scroll page down."""
|
|
330
|
+
self.scroll_page_down(animate=False)
|
|
331
|
+
|
|
332
|
+
def action_scroll_page_up(self) -> None:
|
|
333
|
+
"""Scroll page up."""
|
|
334
|
+
self.scroll_page_up(animate=False)
|
|
335
|
+
|
|
336
|
+
def action_go_back(self) -> None:
|
|
337
|
+
"""Go back to the previously focused panel."""
|
|
338
|
+
cast("LazyOpenCode", self.app).action_go_back_from_main_pane()
|