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,65 @@
1
+ """Parser for Rules customizations."""
2
+
3
+ from pathlib import Path
4
+
5
+ from lazyopencode.models.customization import (
6
+ ConfigLevel,
7
+ Customization,
8
+ CustomizationType,
9
+ )
10
+ from lazyopencode.services.parsers import ICustomizationParser, read_file_safe
11
+
12
+
13
+ class RulesParser(ICustomizationParser):
14
+ """Parses AGENTS.md files and instruction files."""
15
+
16
+ def can_parse(self, path: Path) -> bool:
17
+ """Check if path is an AGENTS.md file."""
18
+ return path.is_file() and path.name == "AGENTS.md"
19
+
20
+ def parse(self, path: Path, level: ConfigLevel) -> Customization:
21
+ """Parse rules file."""
22
+ content, error = read_file_safe(path)
23
+
24
+ return Customization(
25
+ name="AGENTS.md",
26
+ type=CustomizationType.RULES,
27
+ level=level,
28
+ path=path,
29
+ description="Project rules and instructions",
30
+ content=content,
31
+ error=error,
32
+ )
33
+
34
+ def parse_instruction(
35
+ self, path: Path, base_dir: Path, level: ConfigLevel
36
+ ) -> Customization:
37
+ """Parse an instruction file referenced from opencode.json instructions field.
38
+
39
+ Args:
40
+ path: Path to the instruction file
41
+ base_dir: Base directory for computing relative path
42
+ level: Configuration level (GLOBAL or PROJECT)
43
+
44
+ Returns:
45
+ Customization object for the instruction file
46
+ """
47
+ content, error = read_file_safe(path)
48
+
49
+ # Compute relative path for display name
50
+ try:
51
+ # Use forward slashes for consistency across platforms
52
+ relative_name = str(path.relative_to(base_dir)).replace("\\", "/")
53
+ except ValueError:
54
+ # If path is not relative to base_dir, use the filename
55
+ relative_name = path.name
56
+
57
+ return Customization(
58
+ name=relative_name,
59
+ type=CustomizationType.RULES,
60
+ level=level,
61
+ path=path,
62
+ description="Instruction file",
63
+ content=content,
64
+ error=error,
65
+ )
@@ -0,0 +1,138 @@
1
+ """Parser for Skill customizations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING
7
+
8
+ from lazyopencode.models.customization import (
9
+ ConfigLevel,
10
+ Customization,
11
+ CustomizationType,
12
+ SkillFile,
13
+ SkillMetadata,
14
+ )
15
+ from lazyopencode.services.parsers import (
16
+ ICustomizationParser,
17
+ parse_frontmatter,
18
+ read_file_safe,
19
+ )
20
+
21
+ if TYPE_CHECKING:
22
+ from lazyopencode.services.gitignore_filter import GitignoreFilter
23
+
24
+
25
+ def _read_skill_files(
26
+ directory: Path,
27
+ exclude: set[str] | None = None,
28
+ gitignore_filter: GitignoreFilter | None = None,
29
+ ) -> list[SkillFile]:
30
+ """Recursively read all files in a skill directory."""
31
+ if exclude is None:
32
+ exclude = set()
33
+
34
+ files: list[SkillFile] = []
35
+ try:
36
+ # Sort entries: directories first, then alphabetically
37
+ entries = sorted(
38
+ directory.iterdir(), key=lambda p: (p.is_file(), p.name.lower())
39
+ )
40
+ except OSError:
41
+ return files
42
+
43
+ for entry in entries:
44
+ # Skip excluded files and hidden files
45
+ if entry.name in exclude or entry.name.startswith("."):
46
+ continue
47
+
48
+ if entry.is_dir():
49
+ # Skip gitignored directories
50
+ if gitignore_filter and (
51
+ gitignore_filter.should_skip_dir(entry.name)
52
+ or gitignore_filter.is_dir_ignored(entry)
53
+ ):
54
+ continue
55
+
56
+ children = _read_skill_files(entry, exclude, gitignore_filter)
57
+ files.append(
58
+ SkillFile(
59
+ name=entry.name,
60
+ path=entry,
61
+ is_directory=True,
62
+ children=children,
63
+ )
64
+ )
65
+ elif entry.is_file():
66
+ try:
67
+ content = entry.read_text(encoding="utf-8")
68
+ except (OSError, UnicodeDecodeError):
69
+ content = None
70
+ files.append(
71
+ SkillFile(
72
+ name=entry.name,
73
+ path=entry,
74
+ content=content,
75
+ )
76
+ )
77
+
78
+ return files
79
+
80
+
81
+ class SkillParser(ICustomizationParser):
82
+ """Parses skill/*/SKILL.md files."""
83
+
84
+ def __init__(
85
+ self,
86
+ skills_dir: Path | None = None,
87
+ gitignore_filter: GitignoreFilter | None = None,
88
+ ) -> None:
89
+ """Initialize with optional skills directory and gitignore filter."""
90
+ self._skills_dir = skills_dir
91
+ self._filter = gitignore_filter
92
+
93
+ def can_parse(self, path: Path) -> bool:
94
+ """Check if path is a SKILL.md file in a skill directory."""
95
+ return (
96
+ path.is_file()
97
+ and path.name == "SKILL.md"
98
+ and path.parent.parent.name == "skill"
99
+ )
100
+
101
+ def parse(self, path: Path, level: ConfigLevel) -> Customization:
102
+ """Parse skill file and detect directory contents."""
103
+ skill_dir = path.parent
104
+ content, error = read_file_safe(path)
105
+
106
+ if error:
107
+ return Customization(
108
+ name=skill_dir.name,
109
+ type=CustomizationType.SKILL,
110
+ level=level,
111
+ path=path,
112
+ error=error,
113
+ )
114
+
115
+ frontmatter, _ = parse_frontmatter(content or "")
116
+
117
+ # Extract name from frontmatter or fallback to directory name
118
+ name = frontmatter.get("name", skill_dir.name)
119
+ description = frontmatter.get("description")
120
+
121
+ # Recursively read all files in the skill directory
122
+ skill_files = _read_skill_files(
123
+ skill_dir, exclude={"SKILL.md"}, gitignore_filter=self._filter
124
+ )
125
+
126
+ # Build metadata with file tree
127
+ metadata = SkillMetadata(files=skill_files)
128
+
129
+ return Customization(
130
+ name=name,
131
+ type=CustomizationType.SKILL,
132
+ level=level,
133
+ path=path,
134
+ description=description or f"Skill: {skill_dir.name}",
135
+ metadata=metadata.__dict__,
136
+ content=content,
137
+ error=error,
138
+ )
@@ -0,0 +1,67 @@
1
+ """Parser for Tool customizations."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from lazyopencode.models.customization import (
7
+ ConfigLevel,
8
+ Customization,
9
+ CustomizationType,
10
+ )
11
+ from lazyopencode.services.parsers import (
12
+ ICustomizationParser,
13
+ read_file_safe,
14
+ )
15
+
16
+
17
+ class ToolParser(ICustomizationParser):
18
+ """Parses tool customizations from TypeScript/JavaScript files."""
19
+
20
+ VALID_EXTENSIONS = {".ts", ".js"}
21
+
22
+ def can_parse(self, path: Path) -> bool:
23
+ """Check if path is a tool file."""
24
+ return (
25
+ path.is_file()
26
+ and path.suffix in self.VALID_EXTENSIONS
27
+ and path.parent.name == "tool"
28
+ )
29
+
30
+ def parse(self, path: Path, level: ConfigLevel) -> Customization:
31
+ """Parse tool file - shows source code as preview."""
32
+ content, error = read_file_safe(path)
33
+
34
+ description = None
35
+ if content and not error:
36
+ description = self._extract_description(content)
37
+
38
+ return Customization(
39
+ name=path.stem,
40
+ type=CustomizationType.TOOL,
41
+ level=level,
42
+ path=path,
43
+ description=description or f"Tool: {path.stem}",
44
+ content=content,
45
+ error=error,
46
+ )
47
+
48
+ def _extract_description(self, content: str) -> str | None:
49
+ """
50
+ Extract description from tool definition.
51
+
52
+ Looks for patterns like:
53
+ - description: "...",
54
+ - description: '...',
55
+ - description: `...`,
56
+ """
57
+ patterns = [
58
+ r'description:\s*["\']([^"\']+)["\']',
59
+ r"description:\s*`([^`]+)`",
60
+ ]
61
+
62
+ for pattern in patterns:
63
+ match = re.search(pattern, content)
64
+ if match:
65
+ return match.group(1).strip()
66
+
67
+ return None
@@ -0,0 +1,173 @@
1
+ /* LazyOpenCode TUI Application Styles */
2
+ /* Lazygit-inspired panel layout */
3
+
4
+ Screen {
5
+ layout: grid;
6
+ grid-size: 2;
7
+ grid-columns: 1fr 2fr;
8
+ background: $surface;
9
+ }
10
+
11
+ #sidebar {
12
+ layout: vertical;
13
+ height: 100%;
14
+ }
15
+
16
+ #main-pane {
17
+ height: 100%;
18
+ }
19
+
20
+ /* Status Panel */
21
+ StatusPanel {
22
+ height: 3;
23
+ border: solid $primary;
24
+ padding: 0 1;
25
+ margin-bottom: 0;
26
+ border-title-align: left;
27
+ }
28
+
29
+ /* Type Panels */
30
+ TypePanel {
31
+ height: 1fr;
32
+ min-height: 3;
33
+ border: solid $primary;
34
+ padding: 0 1;
35
+ margin-bottom: 0;
36
+ border-title-align: left;
37
+ border-subtitle-align: right;
38
+ }
39
+
40
+ TypePanel:focus {
41
+ border: double $accent;
42
+ }
43
+
44
+ TypePanel:focus-within {
45
+ border: double $accent;
46
+ }
47
+
48
+ TypePanel.empty {
49
+ height: 3;
50
+ min-height: 3;
51
+ max-height: 3;
52
+ }
53
+
54
+ TypePanel .items-container {
55
+ height: 1fr;
56
+ scrollbar-gutter: stable;
57
+ }
58
+
59
+ TypePanel .item {
60
+ height: 1;
61
+ width: 100%;
62
+ text-wrap: nowrap;
63
+ text-overflow: ellipsis;
64
+ }
65
+
66
+ TypePanel .item-selected {
67
+ background: $accent;
68
+ text-style: bold;
69
+ }
70
+
71
+ /* Combined Panel (Rules, MCPs, Plugins) */
72
+ CombinedPanel {
73
+ height: 1fr;
74
+ min-height: 3;
75
+ border: solid $primary;
76
+ padding: 0 1;
77
+ margin-bottom: 0;
78
+ border-title-align: left;
79
+ border-subtitle-align: right;
80
+ }
81
+
82
+ CombinedPanel:focus {
83
+ border: double $accent;
84
+ }
85
+
86
+ CombinedPanel:focus-within {
87
+ border: double $accent;
88
+ }
89
+
90
+ CombinedPanel.empty {
91
+ height: 3;
92
+ min-height: 3;
93
+ max-height: 3;
94
+ }
95
+
96
+ CombinedPanel .items-container {
97
+ height: 1fr;
98
+ scrollbar-gutter: stable;
99
+ }
100
+
101
+ CombinedPanel .item {
102
+ height: 1;
103
+ width: 100%;
104
+ text-wrap: nowrap;
105
+ text-overflow: ellipsis;
106
+ }
107
+
108
+ CombinedPanel .item-selected {
109
+ background: $accent;
110
+ text-style: bold;
111
+ }
112
+
113
+ /* Main Pane */
114
+ MainPane {
115
+ height: 100%;
116
+ border: solid $primary;
117
+ padding: 1 0 1 2;
118
+ overflow-y: auto;
119
+ border-title-align: left;
120
+ }
121
+
122
+ MainPane:focus {
123
+ border: double $accent;
124
+ }
125
+
126
+ MainPane .pane-content {
127
+ width: 100%;
128
+ }
129
+
130
+ /* Footer */
131
+ AppFooter {
132
+ dock: bottom;
133
+ height: 1;
134
+ background: $panel;
135
+ }
136
+
137
+ /* Filter Input */
138
+ FilterInput {
139
+ dock: bottom;
140
+ height: 3;
141
+ border: solid $accent;
142
+ padding: 0 1;
143
+ display: none;
144
+ }
145
+
146
+ FilterInput.visible {
147
+ display: block;
148
+ margin-bottom: 1;
149
+ }
150
+
151
+ FilterInput:focus-within {
152
+ border: double $accent;
153
+ }
154
+
155
+ FilterInput Input {
156
+ width: 100%;
157
+ height: 1;
158
+ border: none;
159
+ padding: 0;
160
+ background: transparent;
161
+ }
162
+
163
+ /* Help Overlay */
164
+ #help-overlay {
165
+ layer: overlay;
166
+ dock: right;
167
+ width: 60;
168
+ height: 100%;
169
+ border: double $accent;
170
+ background: $surface;
171
+ padding: 1 2;
172
+ overflow-y: auto;
173
+ }
lazyopencode/themes.py ADDED
@@ -0,0 +1,30 @@
1
+ """Custom themes for LazyOpenCode TUI application."""
2
+
3
+ from textual.theme import Theme
4
+
5
+ LAZYGIT_THEME = Theme(
6
+ name="lazygit",
7
+ primary="#d4d4d4",
8
+ secondary="#808080",
9
+ accent="#4a90d9",
10
+ foreground="#cccccc",
11
+ background="#1a1a1a",
12
+ surface="#222222",
13
+ panel="#2d2d2d",
14
+ success="#98c379",
15
+ warning="#e5c07b",
16
+ error="#e06c75",
17
+ dark=True,
18
+ variables={
19
+ "border": "#3a3a3a",
20
+ "footer-background": "#1a1a1a",
21
+ "footer-foreground": "#808080",
22
+ "footer-key-background": "#1a1a1a",
23
+ "footer-key-foreground": "#4a90d9",
24
+ "footer-description-foreground": "#707070",
25
+ },
26
+ )
27
+
28
+ CUSTOM_THEMES = [LAZYGIT_THEME]
29
+
30
+ DEFAULT_THEME = "gruvbox"
@@ -0,0 +1,17 @@
1
+ """Widgets for LazyOpenCode."""
2
+
3
+ from lazyopencode.widgets.app_footer import AppFooter
4
+ from lazyopencode.widgets.combined_panel import CombinedPanel
5
+ from lazyopencode.widgets.detail_pane import MainPane
6
+ from lazyopencode.widgets.filter_input import FilterInput
7
+ from lazyopencode.widgets.status_panel import StatusPanel
8
+ from lazyopencode.widgets.type_panel import TypePanel
9
+
10
+ __all__ = [
11
+ "StatusPanel",
12
+ "TypePanel",
13
+ "CombinedPanel",
14
+ "MainPane",
15
+ "AppFooter",
16
+ "FilterInput",
17
+ ]
@@ -0,0 +1,71 @@
1
+ """AppFooter widget for displaying keybindings."""
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
+ from lazyopencode.widgets.helpers.rendering import format_keybinding
9
+
10
+
11
+ class AppFooter(Widget):
12
+ """Footer displaying available keybindings."""
13
+
14
+ DEFAULT_CSS = """
15
+ AppFooter {
16
+ dock: bottom;
17
+ height: 1;
18
+ background: $panel;
19
+ }
20
+
21
+ AppFooter .footer-content {
22
+ width: 100%;
23
+ text-align: center;
24
+ }
25
+ """
26
+
27
+ filter_level: reactive[str] = reactive("All")
28
+ search_active: reactive[bool] = reactive(False)
29
+
30
+ def compose(self) -> ComposeResult:
31
+ """Compose the footer content."""
32
+ yield Static(self._get_footer_text(), classes="footer-content")
33
+
34
+ def _get_footer_text(self) -> str:
35
+ """Build the footer text with keybindings."""
36
+ all_key = format_keybinding("a", "All", active=self.filter_level == "All")
37
+ user_key = format_keybinding(
38
+ "g", "Global", active=self.filter_level == "Global"
39
+ )
40
+ project_key = format_keybinding(
41
+ "p", "Project", active=self.filter_level == "Project"
42
+ )
43
+ search_key = format_keybinding("/", "Search", active=self.search_active)
44
+
45
+ return (
46
+ f"[bold]q[/] Quit [bold]?[/] Help [bold]r[/] Refresh "
47
+ f"[bold]e[/] Edit "
48
+ f"{all_key} {user_key} {project_key} "
49
+ f"{search_key} │ [bold][$accent]^p[/][/] Palette"
50
+ )
51
+
52
+ def on_mount(self) -> None:
53
+ """Handle mount event."""
54
+ pass
55
+
56
+ def _update_content(self) -> None:
57
+ """Update the footer content."""
58
+ if self.is_mounted:
59
+ try:
60
+ content = self.query_one(".footer-content", Static)
61
+ content.update(self._get_footer_text())
62
+ except Exception:
63
+ pass
64
+
65
+ def watch_filter_level(self, _level: str) -> None:
66
+ """React to filter level changes."""
67
+ self._update_content()
68
+
69
+ def watch_search_active(self, _active: bool) -> None:
70
+ """React to search active changes."""
71
+ self._update_content()