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,48 @@
1
+ """LazyOpenCode - TUI for managing OpenCode customizations."""
2
+
3
+ try:
4
+ from lazyopencode._version import __version__
5
+ except ImportError:
6
+ __version__ = "0.0.0+dev"
7
+
8
+ import argparse
9
+ from pathlib import Path
10
+
11
+ from lazyopencode.app import create_app
12
+
13
+
14
+ def main() -> None:
15
+ """Run the LazyOpenCode application."""
16
+ parser = argparse.ArgumentParser(
17
+ description="A lazygit-style TUI for visualizing OpenCode customizations",
18
+ prog="lazyopencode",
19
+ )
20
+
21
+ parser.add_argument(
22
+ "-V", "--version", action="version", version=f"%(prog)s {__version__}"
23
+ )
24
+
25
+ parser.add_argument(
26
+ "-d",
27
+ "--directory",
28
+ type=Path,
29
+ default=None,
30
+ help="Project directory to scan for customizations (default: current directory)",
31
+ )
32
+
33
+ parser.add_argument(
34
+ "-u",
35
+ "--user-config",
36
+ type=Path,
37
+ default=None,
38
+ help="Override user config path (default: ~/.config/opencode)",
39
+ )
40
+
41
+ args = parser.parse_args()
42
+
43
+ # Handle directory argument - resolve to absolute path
44
+ project_root = args.directory.resolve() if args.directory else None
45
+ user_config = args.user_config.resolve() if args.user_config else None
46
+
47
+ app = create_app(project_root=project_root, global_config_path=user_config)
48
+ app.run()
@@ -0,0 +1,6 @@
1
+ """CLI entry point for python -m lazyopencode."""
2
+
3
+ from lazyopencode import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '0.1.0'
32
+ __version_tuple__ = version_tuple = (0, 1, 0)
33
+
34
+ __commit_id__ = commit_id = None
lazyopencode/app.py ADDED
@@ -0,0 +1,310 @@
1
+ """Main LazyOpenCode TUI Application."""
2
+
3
+ import os
4
+ import shlex
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from textual.app import App, ComposeResult
10
+ from textual.containers import Container
11
+
12
+ from lazyopencode import __version__
13
+ from lazyopencode.bindings import APP_BINDINGS
14
+ from lazyopencode.mixins.filtering import FilteringMixin
15
+ from lazyopencode.mixins.help import HelpMixin
16
+ from lazyopencode.mixins.navigation import NavigationMixin
17
+ from lazyopencode.models.customization import (
18
+ ConfigLevel,
19
+ Customization,
20
+ CustomizationType,
21
+ )
22
+ from lazyopencode.services.discovery import ConfigDiscoveryService
23
+ from lazyopencode.themes import CUSTOM_THEMES
24
+ from lazyopencode.widgets.app_footer import AppFooter
25
+ from lazyopencode.widgets.combined_panel import CombinedPanel
26
+ from lazyopencode.widgets.detail_pane import MainPane
27
+ from lazyopencode.widgets.filter_input import FilterInput
28
+ from lazyopencode.widgets.status_panel import StatusPanel
29
+ from lazyopencode.widgets.type_panel import TypePanel
30
+
31
+
32
+ class LazyOpenCode(App, NavigationMixin, FilteringMixin, HelpMixin):
33
+ """A lazygit-style TUI for visualizing OpenCode customizations."""
34
+
35
+ CSS_PATH = "styles/app.tcss"
36
+ LAYERS = ["default", "overlay"]
37
+ BINDINGS = APP_BINDINGS
38
+
39
+ TITLE = f"LazyOpenCode v{__version__}"
40
+ SUB_TITLE = ""
41
+
42
+ def __init__(
43
+ self,
44
+ discovery_service: ConfigDiscoveryService | None = None,
45
+ project_root: Path | None = None,
46
+ global_config_path: Path | None = None,
47
+ ) -> None:
48
+ """Initialize LazyOpenCode application."""
49
+ super().__init__()
50
+ self.theme = "gruvbox"
51
+ self._discovery_service = discovery_service or ConfigDiscoveryService(
52
+ project_root=project_root,
53
+ global_config_path=global_config_path,
54
+ )
55
+ self._customizations: list[Customization] = []
56
+ self._level_filter: ConfigLevel | None = None
57
+ self._search_query: str = ""
58
+ self._panels: list[TypePanel | CombinedPanel] = []
59
+ self._status_panel: StatusPanel | None = None
60
+ self._main_pane: MainPane | None = None
61
+ self._filter_input: FilterInput | None = None
62
+ self._app_footer: AppFooter | None = None
63
+ self._last_focused_panel: TypePanel | CombinedPanel | None = None
64
+
65
+ def compose(self) -> ComposeResult:
66
+ """Compose the application layout."""
67
+ with Container(id="sidebar"):
68
+ self._status_panel = StatusPanel(id="status-panel")
69
+ yield self._status_panel
70
+
71
+ # [1]+[2] Combined Panel: Commands, Agents
72
+ cp1 = CombinedPanel(
73
+ tabs=[
74
+ (CustomizationType.COMMAND, 1, "Commands"),
75
+ (CustomizationType.AGENT, 2, "Agents"),
76
+ ],
77
+ id="panel-combined-1",
78
+ )
79
+ self._panels.append(cp1)
80
+ yield cp1
81
+
82
+ # [3] Type Panel: Skills
83
+ tp_skills = TypePanel(CustomizationType.SKILL, id="panel-skill")
84
+ tp_skills.panel_number = 3
85
+ self._panels.append(tp_skills)
86
+ yield tp_skills
87
+
88
+ # [4] Type Panel: Agent Memory (Rules)
89
+ tp_rules = TypePanel(CustomizationType.RULES, id="panel-rules")
90
+ tp_rules.panel_number = 4
91
+ self._panels.append(tp_rules)
92
+ yield tp_rules
93
+
94
+ # [5]+[6]+[7] Combined Panel: MCPs, Tools, Plugins
95
+ cp2 = CombinedPanel(
96
+ tabs=[
97
+ (CustomizationType.MCP, 5, "MCPs"),
98
+ (CustomizationType.TOOL, 6, "Tools"),
99
+ (CustomizationType.PLUGIN, 7, "Plugins"),
100
+ ],
101
+ id="panel-combined-2",
102
+ )
103
+ self._panels.append(cp2)
104
+ yield cp2
105
+
106
+ self._main_pane = MainPane(id="main-pane")
107
+ yield self._main_pane
108
+
109
+ self._filter_input = FilterInput(id="filter-input")
110
+ yield self._filter_input
111
+
112
+ self._app_footer = AppFooter(id="app-footer")
113
+ yield self._app_footer
114
+
115
+ def on_mount(self) -> None:
116
+ """Handle mount event - load customizations."""
117
+ for theme in CUSTOM_THEMES:
118
+ self.register_theme(theme)
119
+
120
+ self._load_customizations()
121
+ self._update_status_panel()
122
+ project_name = self._discovery_service.project_root.name
123
+ self.title = f"{project_name} - LazyOpenCode"
124
+ # Focus first non-empty panel or first panel
125
+ if self._panels:
126
+ self._panels[0].focus()
127
+
128
+ def _update_status_panel(self) -> None:
129
+ """Update status panel with current config path and filter level."""
130
+ filter_label = self._level_filter.label if self._level_filter else "All"
131
+
132
+ if self._status_panel:
133
+ project_name = self._discovery_service.project_root.name
134
+ self._status_panel.config_path = project_name
135
+ self._status_panel.filter_level = filter_label
136
+
137
+ if self._app_footer:
138
+ self._app_footer.filter_level = filter_label
139
+ # Also update search active status
140
+ self._app_footer.search_active = bool(self._search_query)
141
+
142
+ def _load_customizations(self) -> None:
143
+ """Load customizations from discovery service."""
144
+ self._customizations = self._discovery_service.discover_all()
145
+ self._update_panels()
146
+
147
+ def _update_panels(self) -> None:
148
+ """Update all panels with filtered customizations."""
149
+ customizations = self._get_filtered_customizations()
150
+ for panel in self._panels:
151
+ panel.set_customizations(customizations)
152
+
153
+ def _get_filtered_customizations(self) -> list[Customization]:
154
+ """Get customizations filtered by current level and search query."""
155
+ result = self._customizations
156
+ if self._level_filter:
157
+ result = [c for c in result if c.level == self._level_filter]
158
+ if self._search_query:
159
+ query = self._search_query.lower()
160
+ result = [c for c in result if query in c.name.lower()]
161
+ return result
162
+
163
+ # Panel selection message handlers
164
+
165
+ def on_type_panel_selection_changed(
166
+ self, message: TypePanel.SelectionChanged
167
+ ) -> None:
168
+ """Handle selection change in a type panel."""
169
+ if self._main_pane:
170
+ self._main_pane.customization = message.customization
171
+
172
+ def on_type_panel_drill_down(self, message: TypePanel.DrillDown) -> None:
173
+ """Handle drill down into a customization."""
174
+ if self._main_pane:
175
+ self._last_focused_panel = self._get_focused_panel()
176
+ self._main_pane.customization = message.customization
177
+ self._main_pane.focus()
178
+
179
+ def on_type_panel_skill_file_selected(
180
+ self, message: TypePanel.SkillFileSelected
181
+ ) -> None:
182
+ """Handle skill file selection in the skills tree."""
183
+ if self._main_pane:
184
+ self._main_pane.selected_file = message.file_path
185
+
186
+ def on_combined_panel_selection_changed(
187
+ self, message: CombinedPanel.SelectionChanged
188
+ ) -> None:
189
+ """Handle selection change in the combined panel."""
190
+ if self._main_pane:
191
+ self._main_pane.customization = message.customization
192
+
193
+ def on_combined_panel_drill_down(self, message: CombinedPanel.DrillDown) -> None:
194
+ """Handle drill down from the combined panel."""
195
+ if self._main_pane:
196
+ self._last_focused_panel = self._get_focused_panel()
197
+ self._main_pane.customization = message.customization
198
+ self._main_pane.focus()
199
+
200
+ # Filter input message handlers
201
+
202
+ def on_filter_input_filter_changed(
203
+ self, message: FilterInput.FilterChanged
204
+ ) -> None:
205
+ """Handle filter query changes (real-time filtering)."""
206
+ self._search_query = message.query
207
+ self._last_focused_panel = None
208
+ if self._main_pane:
209
+ self._main_pane.customization = None
210
+ self._update_status_panel() # Updates footer search active state
211
+ self._update_panels()
212
+
213
+ def on_filter_input_filter_cancelled(
214
+ self,
215
+ message: FilterInput.FilterCancelled, # noqa: ARG002
216
+ ) -> None:
217
+ """Handle filter cancellation."""
218
+ self._search_query = ""
219
+ self._last_focused_panel = None
220
+ if self._main_pane:
221
+ self._main_pane.customization = None
222
+ self._update_status_panel()
223
+ self._update_panels()
224
+ # Restore focus
225
+ if self._panels:
226
+ self._panels[0].focus()
227
+
228
+ def on_filter_input_filter_applied(
229
+ self,
230
+ message: FilterInput.FilterApplied, # noqa: ARG002
231
+ ) -> None:
232
+ """Handle filter application (Enter key)."""
233
+ if self._filter_input:
234
+ self._filter_input.hide()
235
+ # Restore focus
236
+ if self._panels:
237
+ self._panels[0].focus()
238
+
239
+ # Navigation actions (handled by NavigationMixin)
240
+
241
+ # Filter actions (handled by FilteringMixin)
242
+
243
+ def action_search(self) -> None:
244
+ """Activate search."""
245
+ if self._filter_input:
246
+ if self._filter_input.is_visible:
247
+ self._filter_input.hide()
248
+ else:
249
+ self._filter_input.show()
250
+
251
+ # Other actions
252
+
253
+ def action_refresh(self) -> None:
254
+ """Refresh customizations from disk."""
255
+ self._discovery_service.refresh()
256
+ self._load_customizations()
257
+ self.notify("Refreshed", severity="information")
258
+
259
+ # action_toggle_help handled by HelpMixin
260
+
261
+ def action_open_in_editor(self) -> None:
262
+ """Open currently selected customization in editor."""
263
+ panel = self._get_focused_panel()
264
+ customization = None
265
+
266
+ if panel:
267
+ customization = panel.selected_customization
268
+
269
+ if not customization:
270
+ self.notify("No customization selected", severity="warning")
271
+ return
272
+
273
+ file_path = customization.path
274
+ if customization.type == CustomizationType.SKILL:
275
+ file_path = customization.path.parent
276
+
277
+ if file_path and file_path.exists():
278
+ self._open_path_in_editor(file_path)
279
+ else:
280
+ self.notify("File does not exist", severity="error")
281
+
282
+ def action_open_user_config(self) -> None:
283
+ """Open user configuration in editor."""
284
+ config_path = self._discovery_service.global_config_path / "opencode.json"
285
+ if not config_path.exists():
286
+ # Fallback to the directory if file doesn't exist
287
+ config_path = self._discovery_service.global_config_path
288
+
289
+ self._open_path_in_editor(config_path)
290
+
291
+ def _open_path_in_editor(self, path: Path) -> None:
292
+ """Helper to open a path in the system editor."""
293
+ editor = os.environ.get("EDITOR", "vi")
294
+ try:
295
+ cmd = shlex.split(editor) + [str(path)]
296
+ subprocess.Popen(cmd, shell=(sys.platform == "win32"))
297
+ except Exception as e:
298
+ self.notify(f"Error opening editor: {e}", severity="error")
299
+
300
+
301
+ def create_app(
302
+ project_root: Path | None = None,
303
+ global_config_path: Path | None = None,
304
+ ) -> LazyOpenCode:
305
+ """Create application with all dependencies wired."""
306
+ discovery_service = ConfigDiscoveryService(
307
+ project_root=project_root,
308
+ global_config_path=global_config_path,
309
+ )
310
+ return LazyOpenCode(discovery_service=discovery_service)
@@ -0,0 +1,27 @@
1
+ """App keybindings configuration."""
2
+
3
+ from textual.binding import Binding, BindingType
4
+
5
+ APP_BINDINGS: list[BindingType] = [
6
+ Binding("q", "quit", "Quit"),
7
+ Binding("?", "toggle_help", "Help"),
8
+ Binding("r", "refresh", "Refresh"),
9
+ Binding("e", "open_in_editor", "Edit"),
10
+ Binding("ctrl+u", "open_user_config", "User Config"),
11
+ Binding("tab", "focus_next_panel", "Next Panel", show=False),
12
+ Binding("shift+tab", "focus_previous_panel", "Prev Panel", show=False),
13
+ Binding("a", "filter_all", "All"),
14
+ Binding("g", "filter_global", "Global"),
15
+ Binding("p", "filter_project", "Project"),
16
+ Binding("/", "search", "Search"),
17
+ Binding("[", "prev_view", "[", show=True),
18
+ Binding("]", "next_view", "]", show=True),
19
+ Binding("0", "focus_main_pane", "Panel 0", show=False),
20
+ Binding("1", "focus_panel_1", "Panel 1", show=False),
21
+ Binding("2", "focus_panel_2", "Panel 2", show=False),
22
+ Binding("3", "focus_panel_3", "Panel 3", show=False),
23
+ Binding("4", "focus_panel_4", "Panel 4", show=False),
24
+ Binding("5", "focus_panel_5", "Panel 5", show=False),
25
+ Binding("6", "focus_panel_6", "Panel 6", show=False),
26
+ Binding("7", "focus_panel_7", "Panel 7", show=False),
27
+ ]
@@ -0,0 +1,33 @@
1
+ """Filtering mixin for handling filter actions."""
2
+
3
+ from typing import TYPE_CHECKING, cast
4
+
5
+ if TYPE_CHECKING:
6
+ from lazyopencode.app import LazyOpenCode
7
+
8
+ from lazyopencode.models.customization import ConfigLevel
9
+
10
+
11
+ class FilteringMixin:
12
+ """Mixin for filtering actions."""
13
+
14
+ def action_filter_all(self) -> None:
15
+ """Show all customizations."""
16
+ app = cast("LazyOpenCode", self)
17
+ app._level_filter = None
18
+ app._update_status_panel()
19
+ app._update_panels()
20
+
21
+ def action_filter_global(self) -> None:
22
+ """Show only global customizations."""
23
+ app = cast("LazyOpenCode", self)
24
+ app._level_filter = ConfigLevel.GLOBAL
25
+ app._update_status_panel()
26
+ app._update_panels()
27
+
28
+ def action_filter_project(self) -> None:
29
+ """Show only project customizations."""
30
+ app = cast("LazyOpenCode", self)
31
+ app._level_filter = ConfigLevel.PROJECT
32
+ app._update_status_panel()
33
+ app._update_panels()
@@ -0,0 +1,74 @@
1
+ """Help mixin for LazyOpenCode application."""
2
+
3
+ from typing import TYPE_CHECKING, cast
4
+
5
+ from textual.app import App
6
+ from textual.widgets import Static
7
+
8
+ from lazyopencode import __version__
9
+
10
+ if TYPE_CHECKING:
11
+ pass
12
+
13
+
14
+ class HelpMixin:
15
+ """Mixin providing help overlay functionality."""
16
+
17
+ _help_visible: bool = False
18
+
19
+ def action_toggle_help(self) -> None:
20
+ """Toggle help overlay visibility."""
21
+ if self._help_visible:
22
+ self._hide_help()
23
+ else:
24
+ self._show_help()
25
+
26
+ def _show_help(self) -> None:
27
+ """Show help overlay."""
28
+ app = cast("App", self)
29
+ help_content = f"""[bold]LazyOpenCode v{__version__}[/]
30
+
31
+ [bold]Navigation[/]
32
+ j/k or Up/Down Move up/down in list
33
+ d/u Page down/up (detail pane)
34
+ g/G Go to top/bottom
35
+ 0 Focus main pane
36
+ 1-3 Focus panel by number
37
+ 4-6 Focus combined panel tab
38
+ Tab Switch between panels
39
+ Enter Drill down
40
+ Esc Go back
41
+
42
+ [bold]Filtering[/]
43
+ / Search by name/description
44
+ a Show all levels
45
+ g Show global-level only
46
+ p Show project-level only
47
+
48
+ [bold]Views[/]
49
+ [ / ] Main: content/metadata
50
+ Combined: switch tabs
51
+
52
+ [bold]Actions[/]
53
+ e Open in $EDITOR
54
+ ctrl+u Open User Config
55
+ r Refresh from disk
56
+ ? Toggle this help
57
+ q Quit
58
+
59
+ [dim]Press ? or Esc to close[/]"""
60
+
61
+ if not app.query("#help-overlay"):
62
+ help_widget = Static(help_content, id="help-overlay")
63
+ app.mount(help_widget)
64
+ self._help_visible = True
65
+
66
+ def _hide_help(self) -> None:
67
+ """Hide help overlay."""
68
+ app = cast("App", self)
69
+ try:
70
+ help_widget = app.query_one("#help-overlay")
71
+ help_widget.remove()
72
+ self._help_visible = False
73
+ except Exception:
74
+ pass