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
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""GAC (Git Auto Commit) integration for Octotui.
|
|
2
|
+
|
|
3
|
+
This module provides the main integration class for GAC functionality,
|
|
4
|
+
including commit message generation using AI models.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional, Dict
|
|
9
|
+
|
|
10
|
+
# TODO: Add unit tests for commit message generation
|
|
11
|
+
# TODO: Add integration tests for GAC configuration loading
|
|
12
|
+
# TODO: Test error handling for missing API keys
|
|
13
|
+
|
|
14
|
+
# Graceful GAC import handling - app should work even if GAC is not installed
|
|
15
|
+
try:
|
|
16
|
+
import gac
|
|
17
|
+
|
|
18
|
+
GAC_AVAILABLE = True
|
|
19
|
+
except (ImportError, ModuleNotFoundError):
|
|
20
|
+
GAC_AVAILABLE = False
|
|
21
|
+
gac = None # type: ignore
|
|
22
|
+
|
|
23
|
+
from octotui.gac_provider_registry import GACProviderRegistry
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class GACIntegration:
|
|
27
|
+
"""Integration class for GAC (Git Auto Commit) functionality.
|
|
28
|
+
|
|
29
|
+
This class handles loading GAC configuration and generating commit messages
|
|
30
|
+
using the GAC library with proper validation and error handling.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, git_sidebar):
|
|
34
|
+
"""Initialize GAC integration.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
git_sidebar: GitStatusSidebar instance for accessing git status/diff
|
|
38
|
+
"""
|
|
39
|
+
self.git_sidebar = git_sidebar
|
|
40
|
+
self.config = self._load_config()
|
|
41
|
+
|
|
42
|
+
def _load_config(self) -> Optional[Dict[str, str]]:
|
|
43
|
+
"""Load GAC configuration from ~/.gac.env file.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Dict of config values, or None if file doesn't exist
|
|
47
|
+
"""
|
|
48
|
+
gac_env_file = Path.home() / ".gac.env"
|
|
49
|
+
if not gac_env_file.exists():
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
config = {}
|
|
53
|
+
try:
|
|
54
|
+
with open(gac_env_file, "r") as f:
|
|
55
|
+
for line in f:
|
|
56
|
+
line = line.strip()
|
|
57
|
+
if line and "=" in line and not line.startswith("#"):
|
|
58
|
+
key, value = line.split("=", 1)
|
|
59
|
+
config[key.strip()] = value.strip().strip("\"'")
|
|
60
|
+
self.config = config
|
|
61
|
+
return config
|
|
62
|
+
except Exception:
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
def is_configured(self) -> bool:
|
|
66
|
+
"""Check if GAC is properly configured.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
True if configuration exists and is valid
|
|
70
|
+
"""
|
|
71
|
+
self.config = self._load_config()
|
|
72
|
+
if not self.config:
|
|
73
|
+
return False
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
def generate_commit_message(
|
|
77
|
+
self,
|
|
78
|
+
staged_only: bool = True,
|
|
79
|
+
one_liner: bool = False,
|
|
80
|
+
verbose: bool = False,
|
|
81
|
+
hint: str = "",
|
|
82
|
+
scope: Optional[str] = None,
|
|
83
|
+
) -> Optional[str]:
|
|
84
|
+
"""Generate a commit message using GAC with stable tuple-based API.
|
|
85
|
+
|
|
86
|
+
This method uses GAC's stable public API with tuple-based prompts:
|
|
87
|
+
- gac.build_prompt() returns (system_prompt, user_prompt)
|
|
88
|
+
- gac.generate_commit_message() accepts prompt=(system, user)
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
staged_only: Only include staged changes in the commit
|
|
92
|
+
one_liner: Generate a single-line commit message
|
|
93
|
+
verbose: Generate a more detailed commit message
|
|
94
|
+
hint: Optional hint to guide the AI commit message generation
|
|
95
|
+
scope: Optional scope for the commit (currently unused by GAC)
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Generated commit message string, or None if generation fails
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
ImportError: If GAC package is not installed
|
|
102
|
+
ValueError: If GAC is not configured, no changes exist, or generation fails
|
|
103
|
+
"""
|
|
104
|
+
# Check if GAC is available before attempting to use it
|
|
105
|
+
if not GAC_AVAILABLE or gac is None:
|
|
106
|
+
raise ImportError(
|
|
107
|
+
"GAC package not found. Install with: uv pip install 'gac>=0.18.0'\n"
|
|
108
|
+
"GAC provides AI-powered commit message generation."
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if not self.is_configured():
|
|
112
|
+
raise ValueError("GAC is not configured. Please configure it first.")
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
# Get git status and diff from sidebar
|
|
116
|
+
status = self.git_sidebar.get_git_status()
|
|
117
|
+
if staged_only:
|
|
118
|
+
diff = self.git_sidebar.get_staged_diff()
|
|
119
|
+
else:
|
|
120
|
+
diff = self.git_sidebar.get_full_diff()
|
|
121
|
+
|
|
122
|
+
if not diff.strip():
|
|
123
|
+
raise ValueError("No changes to commit")
|
|
124
|
+
|
|
125
|
+
# Get diff stat for file changes summary (required by GAC)
|
|
126
|
+
# This provides a summary like: "file1.py | 10 +++++-----"
|
|
127
|
+
try:
|
|
128
|
+
if staged_only:
|
|
129
|
+
diff_stat = (
|
|
130
|
+
self.git_sidebar.repo.git.diff("--cached", "--stat")
|
|
131
|
+
if self.git_sidebar.repo
|
|
132
|
+
else ""
|
|
133
|
+
)
|
|
134
|
+
else:
|
|
135
|
+
diff_stat = (
|
|
136
|
+
self.git_sidebar.repo.git.diff("--stat")
|
|
137
|
+
if self.git_sidebar.repo
|
|
138
|
+
else ""
|
|
139
|
+
)
|
|
140
|
+
except Exception:
|
|
141
|
+
diff_stat = "" # Fallback to empty if stat fails
|
|
142
|
+
|
|
143
|
+
# Build the prompt using GAC's stable API (safe because we checked GAC_AVAILABLE above)
|
|
144
|
+
# Returns tuple: (system_prompt, user_prompt)
|
|
145
|
+
# Note: verbose parameter not supported in GAC 1.4.1 - upgrade GAC to use it:
|
|
146
|
+
# uv pip install --upgrade gac
|
|
147
|
+
system_prompt, user_prompt = gac.build_prompt( # type: ignore
|
|
148
|
+
status=status,
|
|
149
|
+
processed_diff=diff,
|
|
150
|
+
diff_stat=diff_stat, # Required parameter for GAC
|
|
151
|
+
one_liner=one_liner,
|
|
152
|
+
hint=hint,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Get the configured model
|
|
156
|
+
model = self.config.get("GAC_MODEL")
|
|
157
|
+
if not model:
|
|
158
|
+
raise ValueError("GAC_MODEL not set in configuration")
|
|
159
|
+
|
|
160
|
+
# Validate model format (provider:model)
|
|
161
|
+
registry = GACProviderRegistry()
|
|
162
|
+
is_valid, error_msg = registry.validate_model_format(model)
|
|
163
|
+
if not is_valid:
|
|
164
|
+
raise ValueError(f"Invalid model format: {error_msg}")
|
|
165
|
+
|
|
166
|
+
# Generate commit message using tuple-based prompt format
|
|
167
|
+
# This is the stable public API as of GAC latest versions
|
|
168
|
+
commit_message = gac.generate_commit_message( # type: ignore
|
|
169
|
+
model=model, prompt=(system_prompt, user_prompt), quiet=True
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if not commit_message or not commit_message.strip():
|
|
173
|
+
raise ValueError("GAC returned an empty commit message")
|
|
174
|
+
|
|
175
|
+
return commit_message.strip()
|
|
176
|
+
|
|
177
|
+
except ValueError:
|
|
178
|
+
# Re-raise ValueError as-is (these are our validation errors)
|
|
179
|
+
raise
|
|
180
|
+
except Exception as e:
|
|
181
|
+
# Wrap other exceptions with more context
|
|
182
|
+
error_type = type(e).__name__
|
|
183
|
+
raise ValueError(f"GAC generation failed ({error_type}): {str(e)}")
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""GAC provider registry - extracts providers from GAC at runtime."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
from typing import Dict, Tuple, Optional
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
# Try to import from GAC
|
|
11
|
+
try:
|
|
12
|
+
import gac
|
|
13
|
+
from gac import init_cli
|
|
14
|
+
|
|
15
|
+
GAC_AVAILABLE = True
|
|
16
|
+
except (ImportError, ModuleNotFoundError):
|
|
17
|
+
GAC_AVAILABLE = False
|
|
18
|
+
gac = None # type: ignore
|
|
19
|
+
init_cli = None # type: ignore
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _extract_providers_from_gac() -> Optional[Dict[str, Tuple[str, str]]]:
|
|
23
|
+
"""Extract provider list from GAC's init_cli module by reading source file.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Dict mapping provider_key -> (display_name, default_model), or None if extraction fails
|
|
27
|
+
"""
|
|
28
|
+
if not GAC_AVAILABLE or init_cli is None:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
# Get the module file path
|
|
33
|
+
module_file = Path(init_cli.__file__)
|
|
34
|
+
if not module_file.exists():
|
|
35
|
+
logger.debug(f"GAC init_cli file not found: {module_file}")
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
# Read the source file
|
|
39
|
+
source = module_file.read_text(encoding="utf-8")
|
|
40
|
+
|
|
41
|
+
# Find the providers list in _configure_model function
|
|
42
|
+
# Pattern: providers = [ ... ("Name", "model"), ... ]
|
|
43
|
+
# Use bracket-counting to handle nested brackets (e.g., list comprehensions)
|
|
44
|
+
start_match = re.search(r"providers\s*=\s*\[", source)
|
|
45
|
+
if not start_match:
|
|
46
|
+
logger.debug("Could not find providers list in GAC's init_cli")
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
# Find the matching closing bracket by counting brackets
|
|
50
|
+
start_pos = start_match.end()
|
|
51
|
+
bracket_count = 1
|
|
52
|
+
pos = start_pos
|
|
53
|
+
|
|
54
|
+
while pos < len(source) and bracket_count > 0:
|
|
55
|
+
if source[pos] == "[":
|
|
56
|
+
bracket_count += 1
|
|
57
|
+
elif source[pos] == "]":
|
|
58
|
+
bracket_count -= 1
|
|
59
|
+
pos += 1
|
|
60
|
+
|
|
61
|
+
if bracket_count != 0:
|
|
62
|
+
logger.debug("Could not find matching closing bracket for providers list")
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
providers_text = source[start_pos : pos - 1]
|
|
66
|
+
|
|
67
|
+
# Parse tuples: ("Display Name", "model")
|
|
68
|
+
tuple_pattern = r'\("([^"]+)",\s*"([^"]*)"\)'
|
|
69
|
+
matches = re.findall(tuple_pattern, providers_text)
|
|
70
|
+
|
|
71
|
+
if not matches:
|
|
72
|
+
logger.debug("Could not parse provider tuples from GAC")
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
# Convert to our format: provider_key -> (display_name, default_model)
|
|
76
|
+
result = {}
|
|
77
|
+
for display_name, default_model in matches:
|
|
78
|
+
# Generate provider key using GAC's logic (from line 112 of init_cli.py)
|
|
79
|
+
provider_key = (
|
|
80
|
+
display_name.lower()
|
|
81
|
+
.replace(".", "")
|
|
82
|
+
.replace(" ", "-")
|
|
83
|
+
.replace("(", "")
|
|
84
|
+
.replace(")", "")
|
|
85
|
+
)
|
|
86
|
+
result[provider_key] = (display_name, default_model)
|
|
87
|
+
|
|
88
|
+
logger.debug(f"Successfully extracted {len(result)} providers from GAC")
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
except Exception as e:
|
|
92
|
+
logger.debug(f"Failed to extract providers from GAC: {e}")
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class GACProviderRegistry:
|
|
97
|
+
"""Registry for GAC AI providers - extracts from GAC at runtime."""
|
|
98
|
+
|
|
99
|
+
# Cache for extracted providers
|
|
100
|
+
_cached_providers: Optional[Dict[str, Tuple[str, str]]] = None
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def get_supported_providers(cls) -> Dict[str, Tuple[str, str]]:
|
|
104
|
+
"""Get dictionary of supported providers from GAC.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Dict mapping provider_key -> (display_name, default_model)
|
|
108
|
+
"""
|
|
109
|
+
# Use cache if available
|
|
110
|
+
if cls._cached_providers is not None:
|
|
111
|
+
return cls._cached_providers.copy()
|
|
112
|
+
|
|
113
|
+
# Try to extract from GAC
|
|
114
|
+
if GAC_AVAILABLE:
|
|
115
|
+
providers = _extract_providers_from_gac()
|
|
116
|
+
if providers:
|
|
117
|
+
cls._cached_providers = providers
|
|
118
|
+
logger.info(f"Loaded {len(providers)} providers from GAC")
|
|
119
|
+
return providers.copy()
|
|
120
|
+
|
|
121
|
+
# Fallback: return empty dict with warning
|
|
122
|
+
logger.warning(
|
|
123
|
+
"Could not load providers from GAC - GAC may not be installed or provider extraction failed. Provider list will be empty."
|
|
124
|
+
)
|
|
125
|
+
return {}
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def get_suggested_models(cls, provider: str) -> list[str]:
|
|
129
|
+
"""Get list of suggested models for a provider.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
provider: Provider key (e.g., "anthropic", "openai")
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
List of suggested model names
|
|
136
|
+
"""
|
|
137
|
+
providers = cls.get_supported_providers()
|
|
138
|
+
if provider not in providers:
|
|
139
|
+
return []
|
|
140
|
+
|
|
141
|
+
_, default_model = providers[provider]
|
|
142
|
+
if not default_model:
|
|
143
|
+
return []
|
|
144
|
+
|
|
145
|
+
return [default_model]
|
|
146
|
+
|
|
147
|
+
@classmethod
|
|
148
|
+
def get_default_model(cls, provider: str) -> str:
|
|
149
|
+
"""Get the default model for a provider.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
provider: Provider key
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Default model name, or empty string if none
|
|
156
|
+
"""
|
|
157
|
+
providers = cls.get_supported_providers()
|
|
158
|
+
if provider not in providers:
|
|
159
|
+
return ""
|
|
160
|
+
|
|
161
|
+
_, default_model = providers[provider]
|
|
162
|
+
return default_model
|
|
163
|
+
|
|
164
|
+
@classmethod
|
|
165
|
+
def is_local_provider(cls, provider: str) -> bool:
|
|
166
|
+
"""Check if provider is local (no API key needed).
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
provider: Provider key
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
True if local provider
|
|
173
|
+
"""
|
|
174
|
+
return provider in ("ollama", "lm-studio")
|
|
175
|
+
|
|
176
|
+
@classmethod
|
|
177
|
+
def validate_model_format(cls, model: str) -> Tuple[bool, str]:
|
|
178
|
+
"""Validate model string format.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
model: Full model string (provider:model)
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Tuple of (is_valid, error_message)
|
|
185
|
+
"""
|
|
186
|
+
if not model:
|
|
187
|
+
return False, "Model cannot be empty"
|
|
188
|
+
|
|
189
|
+
if ":" not in model:
|
|
190
|
+
return False, "Model must be in format 'provider:model'"
|
|
191
|
+
|
|
192
|
+
provider, model_name = model.split(":", 1)
|
|
193
|
+
|
|
194
|
+
if not provider.strip():
|
|
195
|
+
return False, "Provider name cannot be empty"
|
|
196
|
+
if not model_name.strip():
|
|
197
|
+
return False, "Model name cannot be empty"
|
|
198
|
+
|
|
199
|
+
return True, ""
|