aicodestat 0.0.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.
cli/menus.py ADDED
@@ -0,0 +1,540 @@
1
+ """Interactive main menu and sub-menus based on questionary"""
2
+ import logging
3
+ import questionary
4
+ from typing import Optional, List, Dict, Any
5
+ from compute.metrics_service import (
6
+ calculate_session_metrics,
7
+ calculate_file_metrics,
8
+ calculate_project_metrics,
9
+ calculate_global_metrics,
10
+ )
11
+ from storage.models import get_session_summaries
12
+ from cli.views import (
13
+ display_metrics_table,
14
+ display_session_info,
15
+ display_diff_lines_table,
16
+ display_agent_comparison,
17
+ display_global_dashboard,
18
+ )
19
+ from cli.exporter import export_metrics
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def _arrow_menu(title: str, choices: List[Dict[str, Any]]) -> Optional[str]:
25
+ """
26
+ Simple arrow-key menu implemented with prompt_toolkit.
27
+ Each choice is a dict: {"label": str, "value": str}.
28
+ Returns selected value or None if cancelled / unsupported.
29
+ """
30
+ try:
31
+ from prompt_toolkit import Application
32
+ from prompt_toolkit.key_binding import KeyBindings
33
+ from prompt_toolkit.layout import Layout
34
+ from prompt_toolkit.layout.containers import HSplit, Window
35
+ from prompt_toolkit.layout.controls import FormattedTextControl
36
+ from prompt_toolkit.styles import Style
37
+ except ImportError:
38
+ return None
39
+
40
+ current_index = {"value": 0}
41
+
42
+ def get_menu_text():
43
+ fragments = [("class:title", title + "\n\n")]
44
+ for idx, item in enumerate(choices):
45
+ is_current = idx == current_index["value"]
46
+ prefix = "➤ " if is_current else " "
47
+ style = "class:selected" if is_current else "class:item"
48
+ fragments.append((style, f"{prefix}{item['label']}\n"))
49
+ return fragments
50
+
51
+ text_control = FormattedTextControl(get_menu_text)
52
+ root_container = HSplit([Window(content=text_control, dont_extend_height=True)])
53
+ kb = KeyBindings()
54
+
55
+ @kb.add("up")
56
+ @kb.add("k")
57
+ def _up(event):
58
+ if current_index["value"] > 0:
59
+ current_index["value"] -= 1
60
+
61
+ @kb.add("down")
62
+ @kb.add("j")
63
+ def _down(event):
64
+ if current_index["value"] < len(choices) - 1:
65
+ current_index["value"] += 1
66
+
67
+ @kb.add("enter")
68
+ def _enter(event):
69
+ value = choices[current_index["value"]]["value"]
70
+ event.app.exit(result=value)
71
+
72
+ @kb.add("c-c")
73
+ @kb.add("q")
74
+ def _cancel(event):
75
+ event.app.exit(result=None)
76
+
77
+ style = Style.from_dict(
78
+ {
79
+ "title": "bold cyan",
80
+ "item": "",
81
+ "selected": "reverse bold",
82
+ }
83
+ )
84
+
85
+ app = Application(
86
+ layout=Layout(root_container),
87
+ key_bindings=kb,
88
+ style=style,
89
+ full_screen=False,
90
+ )
91
+
92
+ try:
93
+ return app.run()
94
+ except Exception:
95
+ return None
96
+
97
+
98
+ def show_main_menu() -> str:
99
+ """Show main menu; prefer custom arrow-key navigation, fallback to numeric input."""
100
+ from service_manager import get_service_manager
101
+ from cli.views import console
102
+
103
+ manager = get_service_manager()
104
+ from config import get_server_config
105
+
106
+ console.print()
107
+ # Title line
108
+ console.print("[bold cyan]CodeStat - AI Code Metrics[/bold cyan]")
109
+
110
+ # MCP server status (small text)
111
+ server_config = get_server_config()
112
+ if manager.is_running():
113
+ status = manager.get_status()
114
+ console.print(
115
+ f"[dim]MCP Server[/dim] [green]● ONLINE[/green] "
116
+ f"[dim]at[/dim] [cyan]http://{status['host']}:{status['port']}[/cyan]"
117
+ )
118
+ else:
119
+ console.print(
120
+ f"[dim]MCP Server[/dim] [red]● OFFLINE[/red] "
121
+ f"[dim]at[/dim] http://{server_config['host']}:{server_config['port']}"
122
+ )
123
+
124
+ # Repo / author info (even smaller / dimmer)
125
+ console.print(
126
+ "[grey50]Repo: https://github.com/2hangchen/CodeStat Author: 2hangchen[/grey50]"
127
+ )
128
+ console.print("\n[dim]Use ↑/↓ to move, Enter to confirm:[/dim]\n")
129
+
130
+ # Preferred: custom arrow-key navigation via prompt_toolkit
131
+ menu_items: List[Dict[str, Any]] = [
132
+ {"label": "📈 Global Dashboard (All Data)", "value": "overview"},
133
+ {"label": "🔧 MCP Service Management", "value": "service"},
134
+ {"label": "📄 Query Metrics by File", "value": "file"},
135
+ {"label": "📋 Query Metrics by Session", "value": "session"},
136
+ {"label": "📊 Query Metrics by Project", "value": "project"},
137
+ {"label": "🆚 Compare Agents", "value": "compare"},
138
+ {"label": "📤 Export Data", "value": "export"},
139
+ {"label": "🧹 Data Management (Cleanup/Backup)", "value": "manage"},
140
+ {"label": "❌ Exit", "value": "exit"},
141
+ ]
142
+
143
+ selected = _arrow_menu("Select operation:", menu_items)
144
+ if selected:
145
+ return selected
146
+
147
+ # Fallback: numeric input menu when arrow menu is not available
148
+ console.print("[yellow]⚠ Arrow-key menu not fully supported, fallback to numeric menu.[/yellow]")
149
+ console.print(" [bold magenta]1[/bold magenta] 📈 Global Dashboard (All Data)")
150
+ console.print(" [bold magenta]2[/bold magenta] 🔧 MCP Service Management")
151
+ console.print(" [bold magenta]3[/bold magenta] 📄 Query Metrics by File")
152
+ console.print(" [bold magenta]4[/bold magenta] 📋 Query Metrics by Session")
153
+ console.print(" [bold magenta]5[/bold magenta] 📊 Query Metrics by Project")
154
+ console.print(" [bold magenta]6[/bold magenta] 🆚 Compare Agents")
155
+ console.print(" [bold magenta]7[/bold magenta] 📤 Export Data")
156
+ console.print(" [bold magenta]8[/bold magenta] 🧹 Data Management (Cleanup/Backup)")
157
+ console.print(" [bold magenta]0[/bold magenta] ❌ Exit")
158
+
159
+ mapping = {
160
+ "1": "overview",
161
+ "2": "service",
162
+ "3": "file",
163
+ "4": "session",
164
+ "5": "project",
165
+ "6": "compare",
166
+ "7": "export",
167
+ "8": "manage",
168
+ "0": "exit",
169
+ }
170
+
171
+ try:
172
+ raw = input("> ").strip()
173
+ except EOFError:
174
+ return "exit"
175
+ return mapping.get(raw, "exit")
176
+
177
+
178
+ def query_by_session():
179
+ """Query metrics by session"""
180
+ session_id = questionary.text(
181
+ "Enter session ID (or 'all' to view all sessions):"
182
+ ).ask()
183
+
184
+ if not session_id:
185
+ return
186
+
187
+ if session_id.lower() == "all":
188
+ # Show all session list
189
+ summaries = get_session_summaries()
190
+ if not summaries:
191
+ print("No session data available")
192
+ return
193
+
194
+ session_ids = list(set(s["session_id"] for s in summaries))
195
+ selected = questionary.select(
196
+ "Please select a session:",
197
+ choices=session_ids
198
+ ).ask()
199
+
200
+ if selected:
201
+ session_id = selected
202
+ else:
203
+ return
204
+
205
+ print(f"\nQuerying session: {session_id}")
206
+ print("Calculating metrics (LCS comparison in progress...)")
207
+
208
+ try:
209
+ metrics = calculate_session_metrics(session_id)
210
+
211
+ # Display session info
212
+ if metrics.get("summaries"):
213
+ display_session_info(metrics["summaries"])
214
+
215
+ # Display metrics table
216
+ display_metrics_table(metrics, "Session Metrics (Precise LCS Calculation)")
217
+
218
+ # Display diff lines details
219
+ diff_lines = metrics.get("diff_lines", [])
220
+ if diff_lines:
221
+ show_details = questionary.confirm("Show diff lines details?").ask()
222
+ if show_details:
223
+ display_diff_lines_table(diff_lines)
224
+
225
+ # Ask if export
226
+ export_choice = questionary.confirm("Export this session's metrics?").ask()
227
+ if export_choice:
228
+ format_choice = questionary.select(
229
+ "Select export format:",
230
+ choices=["JSON", "CSV"]
231
+ ).ask()
232
+
233
+ if format_choice:
234
+ output_path = questionary.text(
235
+ f"Enter export file path (default: session_{session_id}.{format_choice.lower()}):"
236
+ ).ask()
237
+
238
+ if not output_path:
239
+ output_path = f"session_{session_id}.{format_choice.lower()}"
240
+
241
+ export_metrics(metrics, output_path, format_choice.lower())
242
+ print(f"✅ Data exported to: {output_path}")
243
+
244
+ except Exception as e:
245
+ print(f"❌ Query failed: {e}")
246
+ logger.error(f"Query by session failed: {e}", exc_info=True)
247
+
248
+
249
+ def query_by_file():
250
+ """Query metrics by file"""
251
+ file_path = questionary.text("Enter file path:").ask()
252
+
253
+ if not file_path:
254
+ return
255
+
256
+ print(f"\nQuerying file: {file_path}")
257
+ print("Calculating metrics (LCS comparison in progress...)")
258
+
259
+ try:
260
+ metrics = calculate_file_metrics(file_path)
261
+
262
+ # Display metrics table
263
+ display_metrics_table(metrics, "File Metrics")
264
+
265
+ # Display diff lines details
266
+ diff_lines = metrics.get("diff_lines", [])
267
+ if diff_lines:
268
+ show_details = questionary.confirm("Show diff lines details?").ask()
269
+ if show_details:
270
+ display_diff_lines_table(diff_lines)
271
+
272
+ # Ask if export
273
+ export_choice = questionary.confirm("Export this file's metrics?").ask()
274
+ if export_choice:
275
+ format_choice = questionary.select(
276
+ "Select export format:",
277
+ choices=["JSON", "CSV"]
278
+ ).ask()
279
+
280
+ if format_choice:
281
+ import os
282
+ safe_filename = os.path.basename(file_path).replace(".", "_")
283
+ output_path = questionary.text(
284
+ f"Enter export file path (default: file_{safe_filename}.{format_choice.lower()}):"
285
+ ).ask()
286
+
287
+ if not output_path:
288
+ output_path = f"file_{safe_filename}.{format_choice.lower()}"
289
+
290
+ export_metrics(metrics, output_path, format_choice.lower())
291
+ print(f"✅ Data exported to: {output_path}")
292
+
293
+ except Exception as e:
294
+ print(f"❌ Query failed: {e}")
295
+ logger.error(f"Query by file failed: {e}", exc_info=True)
296
+
297
+
298
+ def query_by_project():
299
+ """Query metrics by project"""
300
+ project_root = questionary.text("Enter project root directory path:").ask()
301
+
302
+ if not project_root:
303
+ return
304
+
305
+ print(f"\nQuerying project: {project_root}")
306
+ print("Calculating metrics (LCS comparison in progress...)")
307
+
308
+ try:
309
+ metrics = calculate_project_metrics(project_root)
310
+
311
+ # Display metrics table
312
+ display_metrics_table(metrics, "Project Metrics")
313
+
314
+ # Ask if export
315
+ export_choice = questionary.confirm("Export this project's metrics?").ask()
316
+ if export_choice:
317
+ format_choice = questionary.select(
318
+ "Select export format:",
319
+ choices=["JSON", "CSV"]
320
+ ).ask()
321
+
322
+ if format_choice:
323
+ import os
324
+ safe_dirname = os.path.basename(project_root).replace(".", "_")
325
+ output_path = questionary.text(
326
+ f"Enter export file path (default: project_{safe_dirname}.{format_choice.lower()}):"
327
+ ).ask()
328
+
329
+ if not output_path:
330
+ output_path = f"project_{safe_dirname}.{format_choice.lower()}"
331
+
332
+ export_metrics(metrics, output_path, format_choice.lower())
333
+ print(f"✅ Data exported to: {output_path}")
334
+
335
+ except Exception as e:
336
+ print(f"❌ Query failed: {e}")
337
+ logger.error(f"Query by project failed: {e}", exc_info=True)
338
+
339
+
340
+ def compare_agents():
341
+ """Compare metrics across agents"""
342
+ summaries = get_session_summaries()
343
+ if not summaries:
344
+ print("No session data available")
345
+ return
346
+
347
+ # Get all session IDs
348
+ session_ids = list(set(s["session_id"] for s in summaries))
349
+
350
+ if len(session_ids) < 2:
351
+ print("At least 2 sessions are required for comparison")
352
+ return
353
+
354
+ # Let user select sessions to compare
355
+ selected = questionary.checkbox(
356
+ "Select sessions to compare (at least 2):",
357
+ choices=session_ids
358
+ ).ask()
359
+
360
+ if not selected or len(selected) < 2:
361
+ return
362
+
363
+ print("\nCalculating metrics (LCS comparison in progress...)")
364
+
365
+ try:
366
+ metrics_list = []
367
+ for session_id in selected:
368
+ metrics = calculate_session_metrics(session_id)
369
+ metrics_list.append({
370
+ "session_id": session_id,
371
+ "metrics": metrics
372
+ })
373
+
374
+ # Display comparison table
375
+ display_agent_comparison(metrics_list)
376
+
377
+ except Exception as e:
378
+ print(f"❌ Comparison failed: {e}")
379
+ logger.error(f"Compare agents failed: {e}", exc_info=True)
380
+
381
+
382
+ def show_global_dashboard():
383
+ """Show global dashboard for all local data."""
384
+ try:
385
+ metrics = calculate_global_metrics()
386
+ display_global_dashboard(metrics)
387
+ except Exception as e:
388
+ print(f"❌ Failed to load global dashboard: {e}")
389
+ logger.error(f"Global dashboard failed: {e}", exc_info=True)
390
+
391
+
392
+ def manage_service():
393
+ """MCP service management menu (uses custom arrow-key menu)."""
394
+ from service_manager import get_service_manager
395
+ from rich.console import Console
396
+
397
+ console = Console()
398
+ manager = get_service_manager()
399
+
400
+ menu_items: List[Dict[str, Any]] = [
401
+ {"label": "▶️ Start MCP Service", "value": "start"},
402
+ {"label": "⏹️ Stop MCP Service", "value": "stop"},
403
+ {"label": "🔄 Restart MCP Service", "value": "restart"},
404
+ {"label": "📊 View Service Status", "value": "status"},
405
+ {"label": "🔙 Back to Main Menu", "value": "back"},
406
+ ]
407
+
408
+ choice = _arrow_menu("Please select service management operation:", menu_items)
409
+ if not choice:
410
+ # Fallback to simple text input if arrow menu not available
411
+ console.print("[yellow]⚠ Arrow-key menu not fully supported, fallback to numeric menu.[/yellow]")
412
+ console.print(" [bold magenta]1[/bold magenta] ▶️ Start MCP Service")
413
+ console.print(" [bold magenta]2[/bold magenta] ⏹️ Stop MCP Service")
414
+ console.print(" [bold magenta]3[/bold magenta] 🔄 Restart MCP Service")
415
+ console.print(" [bold magenta]4[/bold magenta] 📊 View Service Status")
416
+ console.print(" [bold magenta]0[/bold magenta] 🔙 Back to Main Menu")
417
+
418
+ mapping = {
419
+ "1": "start",
420
+ "2": "stop",
421
+ "3": "restart",
422
+ "4": "status",
423
+ "0": "back",
424
+ }
425
+ try:
426
+ raw = input("> ").strip()
427
+ except EOFError:
428
+ return
429
+ choice = mapping.get(raw, "back")
430
+
431
+ if choice == "start":
432
+ if manager.is_running():
433
+ console.print("[yellow]⚠️ Service is already running[/yellow]")
434
+ else:
435
+ console.print("[cyan]Starting MCP service...[/cyan]")
436
+ if manager.start(background=True):
437
+ console.print("[green]✅ MCP service started successfully[/green]")
438
+ else:
439
+ console.print("[red]❌ Failed to start MCP service[/red]")
440
+
441
+ elif choice == "stop":
442
+ if not manager.is_running():
443
+ console.print("[yellow]⚠️ Service is not running[/yellow]")
444
+ else:
445
+ console.print("[cyan]Stopping MCP service...[/cyan]")
446
+ if manager.stop():
447
+ console.print("[green]✅ MCP service stopped[/green]")
448
+ else:
449
+ console.print("[red]❌ Failed to stop MCP service[/red]")
450
+
451
+ elif choice == "restart":
452
+ console.print("[cyan]Restarting MCP service...[/cyan]")
453
+ if manager.restart():
454
+ console.print("[green]✅ MCP service restarted successfully[/green]")
455
+ else:
456
+ console.print("[red]❌ Failed to restart MCP service[/red]")
457
+
458
+ elif choice == "status":
459
+ status = manager.get_status()
460
+ from cli.views import display_service_status
461
+ display_service_status(status)
462
+
463
+ elif choice == "back":
464
+ return
465
+
466
+
467
+ def manage_data():
468
+ """Data management menu"""
469
+ from storage.backup import backup_database, get_backup_records, restore_database
470
+ from storage.models import delete_sessions
471
+ from utils.time_utils import get_current_time, get_time_range_days
472
+ from config import get_database_config
473
+
474
+ choice = questionary.select(
475
+ "Please select data management operation:",
476
+ choices=[
477
+ questionary.Choice("📦 Backup Data", "backup"),
478
+ questionary.Choice("📥 Restore Data", "restore"),
479
+ questionary.Choice("📋 View Backup Records", "list_backups"),
480
+ questionary.Choice("🧹 Clean Expired Data", "clean"),
481
+ questionary.Choice("🔙 Back to Main Menu", "back"),
482
+ ]
483
+ ).ask()
484
+
485
+ if choice == "backup":
486
+ output_path = questionary.text("Enter backup file path (leave empty for default path):").ask()
487
+ backup_path = backup_database(output_path if output_path else None)
488
+ if backup_path:
489
+ print(f"✅ Backup successful: {backup_path}")
490
+ else:
491
+ print("❌ Backup failed")
492
+
493
+ elif choice == "restore":
494
+ backup_path = questionary.text("Enter backup file path:").ask()
495
+ if backup_path:
496
+ confirm = questionary.confirm("Restore will overwrite existing data. Continue?").ask()
497
+ if confirm:
498
+ if restore_database(backup_path):
499
+ print("✅ Restore successful")
500
+ else:
501
+ print("❌ Restore failed")
502
+
503
+ elif choice == "list_backups":
504
+ limit = questionary.text("Number of records to display (default 10):").ask()
505
+ limit = int(limit) if limit and limit.isdigit() else 10
506
+ backups = get_backup_records(limit)
507
+ if backups:
508
+ from rich.table import Table
509
+ from rich.console import Console
510
+ console = Console()
511
+ table = Table(title="Backup Records")
512
+ table.add_column("ID", style="cyan")
513
+ table.add_column("Backup Path", style="green")
514
+ table.add_column("Backup Time", style="yellow")
515
+ table.add_column("Size (KB)", style="blue", justify="right")
516
+ for backup in backups:
517
+ table.add_row(
518
+ str(backup["id"]),
519
+ backup["backup_path"],
520
+ backup["backup_time"],
521
+ str(backup["backup_size"])
522
+ )
523
+ console.print(table)
524
+ else:
525
+ print("No backup records available")
526
+
527
+ elif choice == "clean":
528
+ config = get_database_config()
529
+ clean_cycle = config.get("clean_cycle", 30)
530
+ days = questionary.text(f"Clean data older than how many days (default {clean_cycle} days):").ask()
531
+ days = int(days) if days and days.isdigit() else clean_cycle
532
+
533
+ confirm = questionary.confirm(f"Confirm deletion of all data older than {days} days?").ask()
534
+ if confirm:
535
+ _, before_time = get_time_range_days(days)
536
+ deleted = delete_sessions(before_time=before_time)
537
+ print(f"✅ Deleted {deleted} session records")
538
+
539
+ elif choice == "back":
540
+ return