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,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()
|