tunacode-cli 0.0.75__py3-none-any.whl → 0.0.76.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.

Potentially problematic release.


This version of tunacode-cli might be problematic. Click here for more details.

@@ -0,0 +1,567 @@
1
+ """
2
+ Interactive configuration dashboard UI component.
3
+
4
+ Provides terminal-based visualization of configuration state with
5
+ navigation, filtering, and detailed inspection capabilities.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ from rich.box import HEAVY, ROUNDED
12
+ from rich.console import Console, Group
13
+ from rich.layout import Layout
14
+ from rich.live import Live
15
+ from rich.panel import Panel
16
+ from rich.table import Table
17
+ from rich.text import Text
18
+ from rich.tree import Tree
19
+
20
+ from tunacode.utils.config_comparator import (
21
+ ConfigAnalysis,
22
+ ConfigComparator,
23
+ ConfigDifference,
24
+ create_config_report,
25
+ )
26
+
27
+
28
+ @dataclass
29
+ class DashboardConfig:
30
+ """Configuration for dashboard behavior."""
31
+
32
+ show_defaults: bool = True
33
+ show_custom: bool = True
34
+ show_missing: bool = True
35
+ show_extra: bool = True
36
+ show_type_mismatches: bool = True
37
+ max_section_items: int = 20
38
+ sort_by: str = "section" # "section", "type", "key"
39
+ filter_section: Optional[str] = None
40
+ filter_type: Optional[str] = None
41
+
42
+
43
+ class ConfigDashboard:
44
+ """Interactive configuration dashboard with Rich UI."""
45
+
46
+ def __init__(self, user_config: Optional[Dict[str, Any]] = None):
47
+ """Initialize the configuration dashboard."""
48
+ self.console = Console()
49
+ self.analysis: Optional[ConfigAnalysis] = None
50
+ self.config = DashboardConfig()
51
+ self.selected_item: Optional[ConfigDifference] = None
52
+
53
+ # Load and analyze configuration
54
+ if user_config is None:
55
+ from tunacode.utils.user_configuration import load_config
56
+
57
+ user_config = load_config()
58
+ if user_config is None:
59
+ raise ValueError("No user configuration found")
60
+
61
+ self.load_analysis(user_config)
62
+
63
+ def load_analysis(self, user_config: Dict[str, Any]) -> None:
64
+ """Load and analyze the user configuration."""
65
+ comparator = ConfigComparator()
66
+ self.analysis = comparator.analyze_config(user_config)
67
+
68
+ def render_overview(self) -> Panel:
69
+ """Render the overview panel with key statistics."""
70
+ if not self.analysis:
71
+ return Panel("No configuration loaded", title="Overview", box=ROUNDED)
72
+
73
+ stats = ConfigComparator().get_summary_stats(self.analysis)
74
+
75
+ # Create overview table
76
+ table = Table.grid(padding=(0, 2))
77
+ table.add_column("Metric", style="cyan", no_wrap=True)
78
+ table.add_column("Value", style="white")
79
+
80
+ table.add_row("Total Keys", str(stats["total_keys_analyzed"]))
81
+ table.add_row(
82
+ "Custom Keys", f"{stats['custom_keys_count']} ({stats['custom_percentage']:.1f}%)"
83
+ )
84
+ table.add_row("Missing Keys", str(stats["missing_keys_count"]))
85
+ table.add_row("Extra Keys", str(stats["extra_keys_count"]))
86
+ table.add_row("Type Mismatches", str(stats["type_mismatches_count"]))
87
+ table.add_row("Sections", str(stats["sections_analyzed"]))
88
+
89
+ health_status = "✅ Healthy" if not stats["has_issues"] else "⚠️ Issues Found"
90
+ health_style = "green" if not stats["has_issues"] else "yellow"
91
+ table.add_row("Status", Text(health_status, style=health_style))
92
+
93
+ return Panel(table, title="Configuration Overview", box=ROUNDED, border_style="blue")
94
+
95
+ def render_section_tree(self) -> Panel:
96
+ """Render the configuration section tree."""
97
+ if not self.analysis:
98
+ return Panel("No configuration loaded", title="Sections", box=ROUNDED)
99
+
100
+ tree = Tree("Configuration Structure")
101
+
102
+ for section in sorted(self.analysis.sections_analyzed):
103
+ section_diffs = [diff for diff in self.analysis.differences if diff.section == section]
104
+
105
+ if section_diffs:
106
+ section_node = tree.add(f"[bold]{section}[/bold] ({len(section_diffs)} items)")
107
+
108
+ for diff in section_diffs[: self.config.max_section_items]:
109
+ self._add_diff_to_tree(section_node, diff)
110
+
111
+ if len(section_diffs) > self.config.max_section_items:
112
+ section_node.add(
113
+ Text(
114
+ f"[dim]... and {len(section_diffs) - self.config.max_section_items} more[/dim]"
115
+ )
116
+ )
117
+ else:
118
+ tree.add(f"[dim]{section}[/dim]")
119
+
120
+ return Panel(tree, title="Configuration Sections", box=ROUNDED, border_style="green")
121
+
122
+ def _add_diff_to_tree(self, parent: Tree, diff: ConfigDifference) -> None:
123
+ """Add a configuration difference to the tree."""
124
+ icon_map = {"custom": "✏️", "missing": "❌", "extra": "➕", "type_mismatch": "⚠️"}
125
+
126
+ style_map = {
127
+ "custom": "yellow",
128
+ "missing": "red",
129
+ "extra": "blue",
130
+ "type_mismatch": "bold red",
131
+ }
132
+
133
+ icon = icon_map.get(diff.difference_type, "❓")
134
+ style = style_map.get(diff.difference_type, "white")
135
+
136
+ diff_text = f"{icon} [dim]{diff.key_path}[/dim]"
137
+
138
+ if diff.user_value is not None:
139
+ # Mask sensitive values
140
+ display_value = self._mask_sensitive_value(diff.user_value)
141
+ diff_text += f": [white]{display_value}[/white]"
142
+
143
+ parent.add(Text(diff_text, style=style))
144
+
145
+ def render_differences_table(self) -> Panel:
146
+ """Render the detailed differences table."""
147
+ if not self.analysis:
148
+ return Panel("No configuration loaded", title="Differences", box=ROUNDED)
149
+
150
+ # Filter differences based on config
151
+ filtered_diffs = self._filter_differences()
152
+
153
+ if not filtered_diffs:
154
+ return Panel("No differences to display", title="Differences", box=ROUNDED)
155
+
156
+ # Create differences table
157
+ table = Table(box=ROUNDED, show_header=True, header_style="bold magenta")
158
+
159
+ table.add_column("Key", style="cyan", no_wrap=True)
160
+ table.add_column("Type", style="yellow")
161
+ table.add_column("User Value", style="white")
162
+ table.add_column("Default Value", style="dim")
163
+ table.add_column("Section", style="green")
164
+
165
+ for diff in filtered_diffs[: self.config.max_section_items]:
166
+ user_value = self._mask_sensitive_value(diff.user_value, diff.key_path)
167
+ default_value = self._mask_sensitive_value(diff.default_value, diff.key_path)
168
+
169
+ # Format type with icon
170
+ type_map = {
171
+ "custom": "✏️ Custom",
172
+ "missing": "❌ Missing",
173
+ "extra": "➕ Extra",
174
+ "type_mismatch": "⚠️ Type Mismatch",
175
+ }
176
+
177
+ diff_type = type_map.get(diff.difference_type, diff.difference_type)
178
+
179
+ table.add_row(
180
+ diff.key_path,
181
+ diff_type,
182
+ str(user_value) if user_value is not None else "",
183
+ str(default_value) if default_value is not None else "",
184
+ diff.section,
185
+ )
186
+
187
+ if len(filtered_diffs) > self.config.max_section_items:
188
+ table.add_row(
189
+ "",
190
+ f"[dim]... and {len(filtered_diffs) - self.config.max_section_items} more[/dim]",
191
+ "",
192
+ "",
193
+ "",
194
+ )
195
+
196
+ return Panel(
197
+ table,
198
+ title=f"Configuration Differences ({len(filtered_diffs)} items)",
199
+ box=ROUNDED,
200
+ border_style="magenta",
201
+ )
202
+
203
+ def _filter_differences(self) -> List[ConfigDifference]:
204
+ """Filter differences based on dashboard configuration."""
205
+ if not self.analysis:
206
+ return []
207
+
208
+ filtered = []
209
+
210
+ for diff in self.analysis.differences:
211
+ # Apply type filter
212
+ if self.config.filter_type and diff.difference_type != self.config.filter_type:
213
+ continue
214
+
215
+ # Apply section filter
216
+ if self.config.filter_section and diff.section != self.config.filter_section:
217
+ continue
218
+
219
+ # Apply show/hide filters
220
+ if diff.difference_type == "custom" and not self.config.show_custom:
221
+ continue
222
+ elif diff.difference_type == "missing" and not self.config.show_missing:
223
+ continue
224
+ elif diff.difference_type == "extra" and not self.config.show_extra:
225
+ continue
226
+ elif diff.difference_type == "type_mismatch" and not self.config.show_type_mismatches:
227
+ continue
228
+
229
+ filtered.append(diff)
230
+
231
+ # Sort differences
232
+ if self.config.sort_by == "section":
233
+ filtered.sort(key=lambda d: (d.section, d.key_path))
234
+ elif self.config.sort_by == "type":
235
+ filtered.sort(key=lambda d: (d.difference_type, d.key_path))
236
+ else: # key
237
+ filtered.sort(key=lambda d: d.key_path)
238
+
239
+ return filtered
240
+
241
+ def _mask_sensitive_value(self, value: Any, key_path: str = "") -> str:
242
+ """Mask sensitive configuration values for display with service identification."""
243
+ if value is None:
244
+ return ""
245
+
246
+ value_str = str(value)
247
+
248
+ # Empty values should show as empty
249
+ if not value_str.strip():
250
+ return "[dim]<not configured>[/dim]"
251
+
252
+ # Check if this is an API key based on key path
253
+ service_type = self._get_service_type_from_key_path(key_path)
254
+ if service_type:
255
+ return self._format_api_key_with_service(value_str, service_type)
256
+
257
+ # Check for common API key patterns
258
+ if value_str.startswith("sk-"):
259
+ # OpenAI-style keys
260
+ service = "OpenAI" if "openai" in key_path.lower() else "Unknown"
261
+ return self._format_api_key_with_service(value_str, service.lower())
262
+ elif value_str.startswith("sk-ant-"):
263
+ return self._format_api_key_with_service(value_str, "anthropic")
264
+ elif value_str.startswith("sk-or-"):
265
+ return self._format_api_key_with_service(value_str, "openrouter")
266
+ elif value_str.startswith("AIza"):
267
+ return self._format_api_key_with_service(value_str, "google")
268
+
269
+ # Check for other sensitive patterns (non-API keys)
270
+ sensitive_patterns = [
271
+ "secret",
272
+ "token",
273
+ "password",
274
+ "credential",
275
+ ]
276
+
277
+ for pattern in sensitive_patterns:
278
+ if pattern in key_path.lower() or pattern in value_str.lower():
279
+ return "•" * 8 # Fully mask non-API key secrets
280
+
281
+ return value_str
282
+
283
+ def _get_service_type_from_key_path(self, key_path: str) -> str:
284
+ """Determine service type from configuration key path."""
285
+ key_lower = key_path.lower()
286
+
287
+ if "openai_api_key" in key_lower:
288
+ return "openai"
289
+ elif "anthropic_api_key" in key_lower:
290
+ return "anthropic"
291
+ elif "openrouter_api_key" in key_lower:
292
+ return "openrouter"
293
+ elif "gemini_api_key" in key_lower:
294
+ return "google"
295
+
296
+ return ""
297
+
298
+ def _format_api_key_with_service(self, api_key: str, service_type: str) -> str:
299
+ """Format API key with service identification and partial masking."""
300
+ service_names = {
301
+ "openai": "OpenAI",
302
+ "anthropic": "Anthropic",
303
+ "openrouter": "OpenRouter",
304
+ "google": "Google",
305
+ }
306
+
307
+ service_name = service_names.get(service_type, service_type.title())
308
+
309
+ if len(api_key) <= 8:
310
+ # Short keys - just show service and mask
311
+ return f"[cyan]{service_name}:[/cyan] •••••••"
312
+ else:
313
+ # Show first 4 and last 4 characters with service label
314
+ masked = f"{api_key[:4]}...{api_key[-4:]}"
315
+ return f"[cyan]{service_name}:[/cyan] {masked}"
316
+
317
+ def render_recommendations(self) -> Panel:
318
+ """Render configuration recommendations."""
319
+ if not self.analysis:
320
+ return Panel("No configuration loaded", title="Recommendations", box=ROUNDED)
321
+
322
+ recommendations = ConfigComparator().get_recommendations(self.analysis)
323
+
324
+ if not recommendations:
325
+ return Panel(
326
+ "✅ No recommendations - configuration looks good!",
327
+ title="Recommendations",
328
+ box=ROUNDED,
329
+ border_style="green",
330
+ )
331
+
332
+ rec_text = "\n".join(f"• {rec}" for rec in recommendations)
333
+
334
+ return Panel(rec_text, title="Recommendations", box=ROUNDED, border_style="yellow")
335
+
336
+ def render_custom_settings(self) -> Panel:
337
+ """Render panel showing only custom (user-modified) settings."""
338
+ if not self.analysis:
339
+ return Panel("No configuration loaded", title="Your Customizations", box=ROUNDED)
340
+
341
+ # Filter for only custom settings
342
+ custom_diffs = [
343
+ diff for diff in self.analysis.differences if diff.difference_type == "custom"
344
+ ]
345
+
346
+ if not custom_diffs:
347
+ return Panel(
348
+ "✨ You're using all default settings!\n\nThis means TunaCode is running with its built-in configuration. "
349
+ "You can customize settings by editing ~/.config/tunacode.json",
350
+ title="🔧 Your Customizations (0)",
351
+ box=ROUNDED,
352
+ border_style="green",
353
+ )
354
+
355
+ # Create table for custom settings
356
+ table = Table(box=ROUNDED, show_header=True, header_style="bold cyan")
357
+ table.add_column("Setting", style="cyan", no_wrap=True)
358
+ table.add_column("Your Value", style="white")
359
+ table.add_column("Default Value", style="dim")
360
+ table.add_column("Category", style="green")
361
+
362
+ for diff in custom_diffs[:15]: # Limit to prevent overflow
363
+ user_value = self._mask_sensitive_value(diff.user_value, diff.key_path)
364
+ default_value = self._mask_sensitive_value(diff.default_value, diff.key_path)
365
+
366
+ # Get category from key descriptions
367
+ from tunacode.configuration.key_descriptions import get_key_description
368
+
369
+ desc = get_key_description(diff.key_path)
370
+ category = desc.category if desc else diff.section.title()
371
+
372
+ table.add_row(
373
+ diff.key_path,
374
+ str(user_value) if user_value is not None else "",
375
+ str(default_value) if default_value is not None else "",
376
+ category,
377
+ )
378
+
379
+ if len(custom_diffs) > 15:
380
+ table.add_row("", f"[dim]... and {len(custom_diffs) - 15} more[/dim]", "", "")
381
+
382
+ summary = f"You have customized {len(custom_diffs)} out of {self.analysis.total_keys} available settings"
383
+
384
+ content = Group(Text(summary, style="bold"), Text(""), table)
385
+
386
+ return Panel(
387
+ content,
388
+ title=f"🔧 Your Customizations ({len(custom_diffs)})",
389
+ box=ROUNDED,
390
+ border_style="cyan",
391
+ )
392
+
393
+ def render_default_settings_summary(self) -> Panel:
394
+ """Render panel showing summary of default settings by category."""
395
+ if not self.analysis:
396
+ return Panel("No configuration loaded", title="Default Settings", box=ROUNDED)
397
+
398
+ # Get all default settings (not customized)
399
+ custom_keys = {
400
+ diff.key_path for diff in self.analysis.differences if diff.difference_type == "custom"
401
+ }
402
+
403
+ # Import here to avoid circular imports
404
+ from tunacode.configuration.key_descriptions import get_categories
405
+
406
+ categories = get_categories()
407
+
408
+ # Count settings by category
409
+ category_counts = {}
410
+ category_examples = {}
411
+
412
+ for category, descriptions in categories.items():
413
+ default_count = 0
414
+ examples: List[str] = []
415
+
416
+ for desc in descriptions:
417
+ if desc.name not in custom_keys:
418
+ default_count += 1
419
+ if len(examples) < 3: # Show up to 3 examples
420
+ examples.append(f"• {desc.name}")
421
+
422
+ if default_count > 0:
423
+ category_counts[category] = default_count
424
+ category_examples[category] = examples
425
+
426
+ if not category_counts:
427
+ return Panel(
428
+ "All settings have been customized", title="📋 Default Settings", box=ROUNDED
429
+ )
430
+
431
+ # Create summary table
432
+ table = Table.grid(padding=(0, 2))
433
+ table.add_column("Category", style="yellow", no_wrap=True)
434
+ table.add_column("Count", style="white")
435
+ table.add_column("Examples", style="dim")
436
+
437
+ for category, count in sorted(category_counts.items()):
438
+ examples_text = "\n".join(category_examples[category])
439
+ table.add_row(category, f"{count} settings", examples_text)
440
+
441
+ total_defaults = sum(category_counts.values())
442
+ summary = f"Using TunaCode defaults for {total_defaults} settings"
443
+
444
+ content = Group(Text(summary, style="bold"), Text(""), table)
445
+
446
+ return Panel(
447
+ content,
448
+ title=f"📋 Default Settings ({total_defaults})",
449
+ box=ROUNDED,
450
+ border_style="blue",
451
+ )
452
+
453
+ def render_help(self) -> Panel:
454
+ """Render help information with configuration key glossary."""
455
+ # Import here to avoid circular imports
456
+ from tunacode.configuration.key_descriptions import get_configuration_glossary
457
+
458
+ help_text = """
459
+ [bold]Configuration Dashboard Guide[/bold]
460
+
461
+ [cyan]Dashboard Sections:[/cyan]
462
+ • [yellow]Your Customizations[/yellow]: Settings you've changed from defaults (🔧)
463
+ • [yellow]Default Settings[/yellow]: TunaCode's built-in settings you're using (📋)
464
+ • [yellow]All Differences[/yellow]: Complete comparison view
465
+
466
+ [cyan]API Key Display:[/cyan]
467
+ • [cyan]OpenAI:[/cyan] sk-abc...xyz - Shows service and partial key
468
+ • [cyan]Anthropic:[/cyan] sk-ant...xyz - Secure but identifiable
469
+ • [dim]<not configured>[/dim] - No API key set
470
+
471
+ [cyan]Visual Indicators:[/cyan]
472
+ • 🔧 Custom: Values you've changed from defaults
473
+ • 📋 Default: TunaCode's built-in settings
474
+ • ❌ Missing: Required configuration keys not found
475
+ • ➕ Extra: Keys not in default configuration
476
+ • ⚠️ Type Mismatch: Wrong data type for configuration
477
+
478
+ [cyan]Exit:[/cyan]
479
+ • Press Ctrl+C to return to TunaCode
480
+ """
481
+
482
+ glossary = get_configuration_glossary()
483
+
484
+ content = Group(Text(help_text.strip()), Text(""), Text(glossary))
485
+
486
+ return Panel(content, title="Help & Glossary", box=ROUNDED, border_style="blue")
487
+
488
+ def render_dashboard(self) -> Layout:
489
+ """Render the complete dashboard layout with improved organization."""
490
+ layout = Layout()
491
+
492
+ # Split into main areas - increase footer size for glossary
493
+ layout.split_column(
494
+ Layout(name="header", size=3), Layout(name="main"), Layout(name="footer", size=12)
495
+ )
496
+
497
+ # Split main area into three columns for better organization
498
+ layout["main"].split_row(
499
+ Layout(name="left", ratio=1),
500
+ Layout(name="center", ratio=1),
501
+ Layout(name="right", ratio=1),
502
+ )
503
+
504
+ # Left column: Overview and custom settings
505
+ layout["left"].split_column(
506
+ Layout(name="overview", size=8), Layout(name="custom_settings", ratio=1)
507
+ )
508
+
509
+ # Center column: Default settings and section tree
510
+ layout["center"].split_column(
511
+ Layout(name="default_settings", ratio=1), Layout(name="sections", ratio=1)
512
+ )
513
+
514
+ # Right column: All differences and recommendations
515
+ layout["right"].split_column(
516
+ Layout(name="differences", ratio=2), Layout(name="recommendations", size=6)
517
+ )
518
+
519
+ # Add content to each area
520
+ layout["header"].update(
521
+ Panel("🐟 TunaCode Configuration Dashboard", style="bold blue", box=HEAVY)
522
+ )
523
+
524
+ layout["overview"].update(self.render_overview())
525
+ layout["custom_settings"].update(self.render_custom_settings())
526
+ layout["default_settings"].update(self.render_default_settings_summary())
527
+ layout["sections"].update(self.render_section_tree())
528
+ layout["differences"].update(self.render_differences_table())
529
+ layout["recommendations"].update(self.render_recommendations())
530
+ layout["footer"].update(self.render_help())
531
+
532
+ return layout
533
+
534
+ def show(self) -> None:
535
+ """Display the interactive dashboard."""
536
+ if not self.analysis:
537
+ self.console.print("[red]No configuration analysis available[/red]")
538
+ return
539
+
540
+ layout = self.render_dashboard()
541
+
542
+ try:
543
+ with Live(layout, console=self.console, refresh_per_second=4):
544
+ # In a real implementation, this would handle user input
545
+ # For now, we'll just display the dashboard
546
+ input("Press Enter to continue...")
547
+ except KeyboardInterrupt:
548
+ self.console.print("\n[dim]Dashboard closed[/dim]")
549
+
550
+ def generate_report(self) -> str:
551
+ """Generate a text report of the configuration analysis."""
552
+ if not self.analysis:
553
+ return "No configuration analysis available"
554
+
555
+ return create_config_report(self.analysis)
556
+
557
+
558
+ def show_config_dashboard(user_config: Optional[Dict[str, Any]] = None) -> None:
559
+ """Convenience function to show the configuration dashboard."""
560
+ dashboard = ConfigDashboard(user_config)
561
+ dashboard.show()
562
+
563
+
564
+ def generate_config_report(user_config: Optional[Dict[str, Any]] = None) -> str:
565
+ """Convenience function to generate a configuration report."""
566
+ dashboard = ConfigDashboard(user_config)
567
+ return dashboard.generate_report()