octotui 0.1.3__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.
Potentially problematic release.
This version of octotui might be problematic. Click here for more details.
- octotui/__init__.py +1 -0
- octotui/__main__.py +6 -0
- octotui/custom_figlet_widget.py +0 -0
- octotui/diff_markdown.py +187 -0
- octotui/gac_config_modal.py +369 -0
- octotui/gac_integration.py +183 -0
- octotui/gac_provider_registry.py +199 -0
- octotui/git_diff_viewer.py +1416 -0
- octotui/git_status_sidebar.py +1010 -0
- octotui/main.py +15 -0
- octotui/octotui_logo.py +52 -0
- octotui/style.tcss +431 -0
- octotui/syntax_utils.py +0 -0
- octotui-0.1.3.data/data/octotui/style.tcss +431 -0
- octotui-0.1.3.dist-info/METADATA +207 -0
- octotui-0.1.3.dist-info/RECORD +18 -0
- octotui-0.1.3.dist-info/WHEEL +4 -0
- octotui-0.1.3.dist-info/entry_points.txt +2 -0
octotui/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
octotui/__main__.py
ADDED
|
File without changes
|
octotui/diff_markdown.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Iterable, List, Optional
|
|
7
|
+
|
|
8
|
+
from pygments.lexers import get_lexer_for_filename, guess_lexer
|
|
9
|
+
from pygments.util import ClassNotFound
|
|
10
|
+
from textual.content import Content
|
|
11
|
+
from textual.widgets import Markdown
|
|
12
|
+
from textual.widgets._markdown import MarkdownFence
|
|
13
|
+
|
|
14
|
+
from .git_status_sidebar import Hunk
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(slots=True)
|
|
18
|
+
class DiffMarkdownConfig:
|
|
19
|
+
"""Configuration for how a diff hunk should be rendered."""
|
|
20
|
+
|
|
21
|
+
repo_root: Path
|
|
22
|
+
wrap: bool = False
|
|
23
|
+
prefer_diff_language: bool = False
|
|
24
|
+
code_block_theme: str = "tokyo-night"
|
|
25
|
+
show_headers: bool = False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DiffMarkdownFence(MarkdownFence):
|
|
29
|
+
"""Fenced code block that decorates diff lines with background highlights."""
|
|
30
|
+
|
|
31
|
+
ADDITION_CLASS = ".diff-line--addition"
|
|
32
|
+
REMOVAL_CLASS = ".diff-line--removal"
|
|
33
|
+
ADDITION_STYLE = "on rgba(158, 206, 106, 0.45)"
|
|
34
|
+
REMOVAL_STYLE = "on rgba(140, 74, 126, 0.45)"
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def highlight(cls, code: str, language: str) -> Content:
|
|
38
|
+
"""Apply syntax highlighting and add diff-aware line highlights."""
|
|
39
|
+
content = super().highlight(code, language)
|
|
40
|
+
if not content:
|
|
41
|
+
return content
|
|
42
|
+
|
|
43
|
+
plain = content.plain
|
|
44
|
+
if not plain:
|
|
45
|
+
return content
|
|
46
|
+
|
|
47
|
+
cursor = 0
|
|
48
|
+
for line in plain.splitlines(keepends=True):
|
|
49
|
+
# Retain the newline so the highlight matches the selection effect.
|
|
50
|
+
marker = line[:1]
|
|
51
|
+
if marker == "+" and not line.startswith("+++"):
|
|
52
|
+
start, end = cursor, cursor + len(line)
|
|
53
|
+
content = content.stylize(cls.ADDITION_CLASS, start, end)
|
|
54
|
+
content = content.stylize(cls.ADDITION_STYLE, start, end)
|
|
55
|
+
elif marker == "-" and not line.startswith("---"):
|
|
56
|
+
start, end = cursor, cursor + len(line)
|
|
57
|
+
content = content.stylize(cls.REMOVAL_CLASS, start, end)
|
|
58
|
+
content = content.stylize(cls.REMOVAL_STYLE, start, end)
|
|
59
|
+
cursor += len(line)
|
|
60
|
+
return content
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class DiffMarkdown(Markdown):
|
|
64
|
+
"""Markdown widget specialised for unified diff hunks with syntax highlighting.
|
|
65
|
+
|
|
66
|
+
The widget converts hunk headers and line payloads into a fenced Markdown block.
|
|
67
|
+
It attempts to strike a balance between diff semantics (+/- context) and
|
|
68
|
+
per-language syntax highlighting by dynamically picking an appropriate lexer.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
BLOCKS = {
|
|
72
|
+
**Markdown.BLOCKS,
|
|
73
|
+
"fence": DiffMarkdownFence,
|
|
74
|
+
"code_block": DiffMarkdownFence,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
DEFAULT_CSS = """
|
|
78
|
+
DiffMarkdown {
|
|
79
|
+
background: transparent;
|
|
80
|
+
border: none;
|
|
81
|
+
&:dark .diff-line--addition {
|
|
82
|
+
background: rgb(158, 206, 106);
|
|
83
|
+
}
|
|
84
|
+
&:light .diff-line--addition {
|
|
85
|
+
background: rgb(200, 230, 180);
|
|
86
|
+
}
|
|
87
|
+
&:dark .diff-line--removal {
|
|
88
|
+
background: rgb(140, 74, 126);
|
|
89
|
+
}
|
|
90
|
+
&:light .diff-line--removal {
|
|
91
|
+
background: rgb(200, 140, 180);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
file_path: str,
|
|
99
|
+
hunks: Iterable[Hunk],
|
|
100
|
+
*,
|
|
101
|
+
config: Optional[DiffMarkdownConfig] = None,
|
|
102
|
+
) -> None:
|
|
103
|
+
self._file_path = file_path
|
|
104
|
+
self._config = config or DiffMarkdownConfig(repo_root=Path.cwd())
|
|
105
|
+
self._hunks_cache = list(hunks)
|
|
106
|
+
markdown_text = self._build_markdown(self._hunks_cache)
|
|
107
|
+
super().__init__(markdown_text)
|
|
108
|
+
if hasattr(self, "inline_code_theme"):
|
|
109
|
+
self.inline_code_theme = self._config.code_block_theme
|
|
110
|
+
|
|
111
|
+
def _build_markdown(self, hunks: List[Hunk]) -> str:
|
|
112
|
+
"""Construct the Markdown payload that encodes diff and syntax info."""
|
|
113
|
+
header_lines: List[str] = ["<!-- Octotui DiffMarkdown -->"]
|
|
114
|
+
|
|
115
|
+
if not hunks:
|
|
116
|
+
return "\n".join(header_lines + ["_No changes to display._"])
|
|
117
|
+
|
|
118
|
+
language = self._detect_language()
|
|
119
|
+
fence_language = (
|
|
120
|
+
"diff" if self._config.prefer_diff_language else language or "diff"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
for hunk in hunks:
|
|
124
|
+
if self._config.show_headers:
|
|
125
|
+
header_lines.append(f"### `{hunk.header or 'File contents'}`")
|
|
126
|
+
header_lines.append(self._render_hunk_block(hunk, fence_language))
|
|
127
|
+
|
|
128
|
+
return "\n\n".join(header_lines)
|
|
129
|
+
|
|
130
|
+
def _render_hunk_block(self, hunk: Hunk, fence_language: str) -> str:
|
|
131
|
+
"""Render a single hunk of diff lines inside a fenced Markdown block."""
|
|
132
|
+
fence_lines: List[str] = [f"```{fence_language}"]
|
|
133
|
+
for line in hunk.lines:
|
|
134
|
+
fence_lines.append(self._normalise_line(line))
|
|
135
|
+
fence_lines.append("```")
|
|
136
|
+
return "\n".join(fence_lines)
|
|
137
|
+
|
|
138
|
+
def _normalise_line(self, line: str) -> str:
|
|
139
|
+
"""Ensure markdown is well-formed while preserving diff semantics."""
|
|
140
|
+
if not line:
|
|
141
|
+
return ""
|
|
142
|
+
|
|
143
|
+
escaped = line.replace("```", "`\u200b``")
|
|
144
|
+
return escaped
|
|
145
|
+
|
|
146
|
+
def _detect_language(self) -> Optional[str]:
|
|
147
|
+
"""Best-effort inference of the target language for syntax highlighting."""
|
|
148
|
+
file_path = self._file_path
|
|
149
|
+
full_path = self._config.repo_root / file_path
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
lexer = _get_cached_lexer(str(full_path))
|
|
153
|
+
return lexer.aliases[0] if lexer.aliases else lexer.name.lower()
|
|
154
|
+
except ClassNotFound:
|
|
155
|
+
pass
|
|
156
|
+
except FileNotFoundError:
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
sample = self._collect_sample()
|
|
160
|
+
if not sample:
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
lexer = guess_lexer(sample)
|
|
165
|
+
return lexer.aliases[0] if lexer.aliases else lexer.name.lower()
|
|
166
|
+
except ClassNotFound:
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
def _collect_sample(self) -> str:
|
|
170
|
+
"""Gather a short sample of code from the hunks to feed into pygments."""
|
|
171
|
+
snippets: List[str] = []
|
|
172
|
+
for hunk in self._hunks_cache:
|
|
173
|
+
for line in hunk.lines:
|
|
174
|
+
if line and line[:1] in {"+", "-", " "}:
|
|
175
|
+
snippets.append(line[1:])
|
|
176
|
+
else:
|
|
177
|
+
snippets.append(line)
|
|
178
|
+
if len("\n".join(snippets)) > 2048:
|
|
179
|
+
break
|
|
180
|
+
if len("\n".join(snippets)) > 2048:
|
|
181
|
+
break
|
|
182
|
+
return "\n".join(snippets)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@lru_cache(maxsize=128)
|
|
186
|
+
def _get_cached_lexer(file_path: str):
|
|
187
|
+
return get_lexer_for_filename(file_path)
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""GAC Configuration Modal - UI for configuring GAC settings.
|
|
2
|
+
|
|
3
|
+
This module provides a Textual modal screen for configuring GAC (Git Auto Commit)
|
|
4
|
+
settings, including provider selection, model configuration, and API key management.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import stat
|
|
9
|
+
import tempfile
|
|
10
|
+
import shutil
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from textual.screen import ModalScreen
|
|
13
|
+
from textual.widgets import Static, Button, Input, Select, Label
|
|
14
|
+
from textual.containers import Horizontal, Container, VerticalScroll
|
|
15
|
+
from textual.app import ComposeResult
|
|
16
|
+
from textual import on
|
|
17
|
+
from typing import Optional, Dict
|
|
18
|
+
|
|
19
|
+
# TODO: Add unit tests for configuration modal
|
|
20
|
+
# TODO: Test GAC availability detection in UI
|
|
21
|
+
# TODO: Add tests for form validation
|
|
22
|
+
|
|
23
|
+
from octotui.gac_provider_registry import GACProviderRegistry, GAC_AVAILABLE
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class GACConfigModal(ModalScreen):
|
|
27
|
+
"""Modal screen for configuring GAC settings with dynamic provider discovery."""
|
|
28
|
+
|
|
29
|
+
DEFAULT_CSS = """
|
|
30
|
+
GACConfigModal {
|
|
31
|
+
align: center middle;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
#gac-container {
|
|
35
|
+
border: solid #6c7086;
|
|
36
|
+
background: #00122f;
|
|
37
|
+
width: 60%;
|
|
38
|
+
height: 70%;
|
|
39
|
+
margin: 1;
|
|
40
|
+
padding: 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#gac-container > VerticalScroll {
|
|
44
|
+
height: 1fr;
|
|
45
|
+
padding: 1;
|
|
46
|
+
overflow-y: auto;
|
|
47
|
+
scrollbar-size: 1 1;
|
|
48
|
+
scrollbar-color: #9399b2;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.gac-input {
|
|
52
|
+
margin: 1 0;
|
|
53
|
+
width: 100%;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.gac-select {
|
|
57
|
+
margin: 1 0;
|
|
58
|
+
width: 100%;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.gac-buttons {
|
|
62
|
+
align: center bottom;
|
|
63
|
+
height: auto;
|
|
64
|
+
margin: 2 0 0 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.gac-label {
|
|
68
|
+
margin: 1 0 0 0;
|
|
69
|
+
color: #bb9af7;
|
|
70
|
+
text-style: bold;
|
|
71
|
+
}
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
# Provider registry (dynamically discovered)
|
|
75
|
+
_provider_registry = GACProviderRegistry()
|
|
76
|
+
|
|
77
|
+
def __init__(self):
|
|
78
|
+
super().__init__()
|
|
79
|
+
self.current_config = self._load_current_config()
|
|
80
|
+
|
|
81
|
+
def _load_current_config(self) -> Dict[str, str]:
|
|
82
|
+
"""Load current GAC configuration from environment or config file."""
|
|
83
|
+
config = {}
|
|
84
|
+
|
|
85
|
+
# Try to load from GAC config
|
|
86
|
+
gac_env_file = Path.home() / ".gac.env"
|
|
87
|
+
if gac_env_file.exists():
|
|
88
|
+
try:
|
|
89
|
+
with open(gac_env_file, "r") as f:
|
|
90
|
+
for line in f:
|
|
91
|
+
line = line.strip()
|
|
92
|
+
if line and "=" in line and not line.startswith("#"):
|
|
93
|
+
key, value = line.split("=", 1)
|
|
94
|
+
config[key.strip()] = value.strip().strip("\"'")
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
# Also check environment variables for all known providers
|
|
99
|
+
providers = self._provider_registry.get_supported_providers()
|
|
100
|
+
for provider in providers.keys():
|
|
101
|
+
api_key_var = f"{provider.upper().replace('-', '_')}_API_KEY"
|
|
102
|
+
if api_key_var in os.environ:
|
|
103
|
+
config[api_key_var] = os.environ[api_key_var]
|
|
104
|
+
|
|
105
|
+
return config
|
|
106
|
+
|
|
107
|
+
def compose(self) -> ComposeResult:
|
|
108
|
+
"""Create the modal content with dynamic provider list."""
|
|
109
|
+
with Container(id="gac-container"):
|
|
110
|
+
yield Static("đ¤ Configure GAC (Git Auto Commit)", classes="panel-header")
|
|
111
|
+
|
|
112
|
+
# Wrap everything in VerticalScroll for scrollability
|
|
113
|
+
with VerticalScroll():
|
|
114
|
+
# Show warning if GAC is not installed
|
|
115
|
+
if not GAC_AVAILABLE:
|
|
116
|
+
yield Static(
|
|
117
|
+
"â ī¸ GAC package not installed!\n"
|
|
118
|
+
"Install with: uv pip install 'gac>=0.18.0'\n\n"
|
|
119
|
+
"You can still configure settings, but commit message generation won't work.",
|
|
120
|
+
classes="gac-label",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
yield Label("Provider (21+ supported):", classes="gac-label")
|
|
124
|
+
# Get all providers dynamically
|
|
125
|
+
providers = self._provider_registry.get_supported_providers()
|
|
126
|
+
provider_options = [
|
|
127
|
+
(f"{info[0]} ({provider})", provider)
|
|
128
|
+
for provider, info in sorted(providers.items())
|
|
129
|
+
]
|
|
130
|
+
current_provider = self._detect_current_provider()
|
|
131
|
+
yield Select(
|
|
132
|
+
provider_options,
|
|
133
|
+
value=current_provider,
|
|
134
|
+
id="provider-select",
|
|
135
|
+
classes="gac-select",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
yield Label(
|
|
139
|
+
"Model (select suggested or type custom):", classes="gac-label"
|
|
140
|
+
)
|
|
141
|
+
# Get suggested models for current provider
|
|
142
|
+
initial_models = self._provider_registry.get_suggested_models(
|
|
143
|
+
current_provider
|
|
144
|
+
)
|
|
145
|
+
model_options = [(model, model) for model in initial_models]
|
|
146
|
+
# Add "Custom..." option
|
|
147
|
+
model_options.insert(0, ("Custom (type below)...", "__custom__"))
|
|
148
|
+
yield Select(model_options, id="model-select", classes="gac-select")
|
|
149
|
+
|
|
150
|
+
yield Label(
|
|
151
|
+
"Custom Model Name (or select from dropdown):", classes="gac-label"
|
|
152
|
+
)
|
|
153
|
+
default_model = self._provider_registry.get_default_model(
|
|
154
|
+
current_provider
|
|
155
|
+
)
|
|
156
|
+
yield Input(
|
|
157
|
+
value=default_model,
|
|
158
|
+
placeholder="e.g., claude-sonnet-4-5, gpt-4o-mini, llama3.2...",
|
|
159
|
+
id="model-input",
|
|
160
|
+
classes="gac-input",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
yield Label(
|
|
164
|
+
"API Key (not needed for local providers):", classes="gac-label"
|
|
165
|
+
)
|
|
166
|
+
current_key = self._get_current_api_key(current_provider)
|
|
167
|
+
yield Input(
|
|
168
|
+
value=current_key,
|
|
169
|
+
password=True,
|
|
170
|
+
placeholder="Enter your API key...",
|
|
171
|
+
id="api-key-input",
|
|
172
|
+
classes="gac-input",
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
with Horizontal(classes="gac-buttons"):
|
|
176
|
+
yield Button("Cancel", id="gac-cancel", classes="cancel-button")
|
|
177
|
+
yield Button("Test", id="gac-test", classes="test-button")
|
|
178
|
+
yield Button("Save", id="gac-save", classes="save-button")
|
|
179
|
+
|
|
180
|
+
def _detect_current_provider(self) -> str:
|
|
181
|
+
"""Detect the current provider from config."""
|
|
182
|
+
# First check if GAC_MODEL is set (most reliable)
|
|
183
|
+
if "GAC_MODEL" in self.current_config:
|
|
184
|
+
gac_model = self.current_config["GAC_MODEL"]
|
|
185
|
+
if ":" in gac_model:
|
|
186
|
+
provider = gac_model.split(":", 1)[0]
|
|
187
|
+
# Validate it's a known provider
|
|
188
|
+
providers = self._provider_registry.get_supported_providers()
|
|
189
|
+
if provider in providers:
|
|
190
|
+
return provider
|
|
191
|
+
|
|
192
|
+
# Fall back to checking for API keys in config
|
|
193
|
+
providers = self._provider_registry.get_supported_providers()
|
|
194
|
+
for provider in providers.keys():
|
|
195
|
+
api_key_var = f"{provider.upper().replace('-', '_')}_API_KEY"
|
|
196
|
+
if api_key_var in self.current_config:
|
|
197
|
+
return provider
|
|
198
|
+
|
|
199
|
+
# Default to OpenAI
|
|
200
|
+
return "openai"
|
|
201
|
+
|
|
202
|
+
def _get_current_api_key(self, provider: str) -> str:
|
|
203
|
+
"""Get the current API key for the provider."""
|
|
204
|
+
api_key_var = f"{provider.upper()}_API_KEY"
|
|
205
|
+
return self.current_config.get(api_key_var, "")
|
|
206
|
+
|
|
207
|
+
@on(Select.Changed, "#provider-select")
|
|
208
|
+
def on_provider_changed(self, event: Select.Changed) -> None:
|
|
209
|
+
"""Update model options when provider changes."""
|
|
210
|
+
provider = str(event.value)
|
|
211
|
+
model_select = self.query_one("#model-select", Select)
|
|
212
|
+
model_input = self.query_one("#model-input", Input)
|
|
213
|
+
api_key_input = self.query_one("#api-key-input", Input)
|
|
214
|
+
|
|
215
|
+
# Update model options with suggestions for this provider
|
|
216
|
+
suggested_models = self._provider_registry.get_suggested_models(provider)
|
|
217
|
+
model_options = [("Custom (type below)...", "__custom__")]
|
|
218
|
+
model_options.extend([(model, model) for model in suggested_models])
|
|
219
|
+
model_select.set_options(model_options)
|
|
220
|
+
|
|
221
|
+
# Update model input with default
|
|
222
|
+
default_model = self._provider_registry.get_default_model(provider)
|
|
223
|
+
model_input.value = default_model
|
|
224
|
+
|
|
225
|
+
# Update API key
|
|
226
|
+
current_key = self._get_current_api_key(provider)
|
|
227
|
+
api_key_input.value = current_key
|
|
228
|
+
|
|
229
|
+
# Show info for local providers
|
|
230
|
+
if provider in ["ollama", "lm-studio"]:
|
|
231
|
+
self.app.notify(
|
|
232
|
+
f"âšī¸ {provider} is a local provider - no API key needed!",
|
|
233
|
+
severity="information",
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
@on(Select.Changed, "#model-select")
|
|
237
|
+
def on_model_changed(self, event: Select.Changed) -> None:
|
|
238
|
+
"""Update model input when selection changes."""
|
|
239
|
+
selected = str(event.value)
|
|
240
|
+
if selected != "__custom__":
|
|
241
|
+
model_input = self.query_one("#model-input", Input)
|
|
242
|
+
model_input.value = selected
|
|
243
|
+
|
|
244
|
+
@on(Button.Pressed, "#gac-cancel")
|
|
245
|
+
def on_cancel(self, event: Button.Pressed) -> None:
|
|
246
|
+
"""Cancel configuration."""
|
|
247
|
+
self.app.pop_screen()
|
|
248
|
+
|
|
249
|
+
@on(Button.Pressed, "#gac-test")
|
|
250
|
+
def on_test(self, event: Button.Pressed) -> None:
|
|
251
|
+
"""Test the GAC configuration."""
|
|
252
|
+
if not GAC_AVAILABLE:
|
|
253
|
+
self.app.notify(
|
|
254
|
+
"â GAC package not installed. Install with: uv pip install 'gac>=0.18.0'",
|
|
255
|
+
severity="error",
|
|
256
|
+
)
|
|
257
|
+
return
|
|
258
|
+
|
|
259
|
+
config = self._get_form_config()
|
|
260
|
+
if not config:
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
# TODO: Implement a test commit message generation
|
|
264
|
+
# For now, just validate the configuration
|
|
265
|
+
self.app.notify(
|
|
266
|
+
"đ§Ē Testing GAC configuration... (Feature coming soon!)",
|
|
267
|
+
severity="information",
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
@on(Button.Pressed, "#gac-save")
|
|
271
|
+
def on_save(self, event: Button.Pressed) -> None:
|
|
272
|
+
"""Save the GAC configuration."""
|
|
273
|
+
config = self._get_form_config()
|
|
274
|
+
if not config:
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
# Warn if GAC is not available (but still allow saving)
|
|
278
|
+
if not GAC_AVAILABLE:
|
|
279
|
+
self.app.notify(
|
|
280
|
+
"â ī¸ GAC not installed - config saved but won't work until you install GAC",
|
|
281
|
+
severity="warning",
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
self._save_config(config)
|
|
286
|
+
self.app.notify(
|
|
287
|
+
"â
GAC configuration saved successfully!", severity="information"
|
|
288
|
+
)
|
|
289
|
+
self.app.pop_screen()
|
|
290
|
+
except Exception as e:
|
|
291
|
+
self.app.notify(f"â Failed to save GAC config: {e}", severity="error")
|
|
292
|
+
|
|
293
|
+
def _get_form_config(self) -> Optional[Dict[str, str]]:
|
|
294
|
+
"""Get configuration from form fields with validation."""
|
|
295
|
+
provider_select = self.query_one("#provider-select", Select)
|
|
296
|
+
model_input = self.query_one("#model-input", Input)
|
|
297
|
+
api_key_input = self.query_one("#api-key-input", Input)
|
|
298
|
+
|
|
299
|
+
provider = str(provider_select.value)
|
|
300
|
+
model = model_input.value.strip()
|
|
301
|
+
api_key = api_key_input.value.strip()
|
|
302
|
+
|
|
303
|
+
if not provider:
|
|
304
|
+
self.app.notify("â Please select a provider", severity="error")
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
if not model:
|
|
308
|
+
self.app.notify("â Please enter a model name", severity="error")
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
# Validate provider is known
|
|
312
|
+
providers = self._provider_registry.get_supported_providers()
|
|
313
|
+
if provider not in providers:
|
|
314
|
+
self.app.notify(
|
|
315
|
+
f"â ī¸ Unknown provider '{provider}' - GAC may not support it",
|
|
316
|
+
severity="warning",
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# Check if API key is needed (not for local providers)
|
|
320
|
+
local_providers = ["ollama", "lm-studio"]
|
|
321
|
+
if not api_key and provider not in local_providers:
|
|
322
|
+
self.app.notify(
|
|
323
|
+
"â Please enter an API key (or use a local provider like ollama/lm-studio)",
|
|
324
|
+
severity="error",
|
|
325
|
+
)
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
return {"provider": provider, "model": model, "api_key": api_key}
|
|
329
|
+
|
|
330
|
+
def _save_config(self, config: Dict[str, str]) -> None:
|
|
331
|
+
"""Save configuration to GAC config file with secure permissions."""
|
|
332
|
+
gac_env_file = Path.home() / ".gac.env"
|
|
333
|
+
provider = config["provider"]
|
|
334
|
+
gac_model = f"{provider}:{config['model']}"
|
|
335
|
+
|
|
336
|
+
# Validate model format
|
|
337
|
+
is_valid, error_msg = self._provider_registry.validate_model_format(gac_model)
|
|
338
|
+
if not is_valid:
|
|
339
|
+
raise ValueError(error_msg)
|
|
340
|
+
|
|
341
|
+
out_config = dict()
|
|
342
|
+
out_config["GAC_MODEL"] = gac_model
|
|
343
|
+
|
|
344
|
+
# Only add API key if it's provided (local providers don't need it)
|
|
345
|
+
if config["api_key"]:
|
|
346
|
+
api_key_var = f"{provider.upper().replace('-', '_')}_API_KEY"
|
|
347
|
+
out_config[api_key_var] = config["api_key"]
|
|
348
|
+
|
|
349
|
+
# Write to temporary file first (atomic operation)
|
|
350
|
+
with tempfile.NamedTemporaryFile(
|
|
351
|
+
mode="w", encoding="utf-8", dir=gac_env_file.parent, delete=False
|
|
352
|
+
) as tmp:
|
|
353
|
+
tmp.write("# GAC Configuration (Generated by Octotui)\n")
|
|
354
|
+
tmp.write(f"# Provider: {provider}\n")
|
|
355
|
+
tmp.write(f"# Model: {config['model']}\n\n")
|
|
356
|
+
for key, value in out_config.items():
|
|
357
|
+
tmp.write(f"{key}='{value}'\n")
|
|
358
|
+
tmp_path = Path(tmp.name)
|
|
359
|
+
|
|
360
|
+
# Set restrictive permissions (owner read/write only - 0o600)
|
|
361
|
+
os.chmod(tmp_path, stat.S_IRUSR | stat.S_IWUSR)
|
|
362
|
+
|
|
363
|
+
# Backup existing config if it exists
|
|
364
|
+
if gac_env_file.exists():
|
|
365
|
+
backup = gac_env_file.with_suffix(".env.backup")
|
|
366
|
+
shutil.copy2(gac_env_file, backup)
|
|
367
|
+
|
|
368
|
+
# Atomic move (rename is atomic on POSIX systems)
|
|
369
|
+
shutil.move(str(tmp_path), str(gac_env_file))
|