octotui 0.1.1__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.
octotui/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
octotui/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for running octotui as a module."""
2
+
3
+ from octotui.main import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
File without changes
@@ -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))