aizen-ai-cli 2.2.2__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.
aizen/commands.py ADDED
@@ -0,0 +1,694 @@
1
+ import copy
2
+ import os
3
+ import platform
4
+ import re
5
+ import subprocess
6
+ from datetime import datetime
7
+
8
+ from prompt_toolkit.completion import Completer, Completion
9
+ from prompt_toolkit.shortcuts import prompt
10
+ from rich.table import Table
11
+
12
+ from .config import (
13
+ BACKUPS_DIR,
14
+ CONFIG_PATH,
15
+ SESSIONS_DIR,
16
+ console,
17
+ get_active_model,
18
+ get_cached_models,
19
+ load_config,
20
+ set_active_model,
21
+ )
22
+ from .logging_config import logger
23
+ from .session import list_sessions, load_session, save_session
24
+ from .tools import backup_manager
25
+ from .utils import TokenTracker, load_gitignore_patterns, should_ignore
26
+
27
+ SLASH_COMMANDS = [
28
+ ("/help", "Show all available commands"),
29
+ ("/model", "View or switch the active model"),
30
+ ("/clear", "Clear conversation history"),
31
+ ("/drop", "Drop attached files/URLs from history"),
32
+ ("/save", "Save current conversation"),
33
+ ("/load", "Load a saved conversation"),
34
+ ("/usage", "Show token usage statistics"),
35
+ ("/compact", "Summarize conversation to save tokens"),
36
+ ("/undo", "Undo the last file modification"),
37
+ ("/retry", "Retry the last user message"),
38
+ ("/copy", "Copy last AI response to clipboard"),
39
+ ("/export", "Export conversation to Markdown"),
40
+ ("/checkpoint", "Save a named snapshot of the conversation"),
41
+ ("/restore", "Restore a previously saved checkpoint"),
42
+ ("/config", "View current configuration"),
43
+ ("/mcp", "View configured MCP servers and their status"),
44
+ ("/commit", "Auto-generate and commit changes"),
45
+ ("/diff", "Show all uncommitted changes"),
46
+ ]
47
+
48
+ # In-memory checkpoint storage for conversation branching
49
+ _checkpoints: dict[str, list] = {}
50
+
51
+
52
+ class AizenCompleter(Completer):
53
+ """Autocomplete for both slash commands (/) and file mentions (@)."""
54
+
55
+ def __init__(self):
56
+ super().__init__()
57
+ self.ignore_patterns = load_gitignore_patterns()
58
+
59
+ def get_completions(self, document, complete_event):
60
+ text = document.text_before_cursor
61
+ stripped = text.lstrip()
62
+
63
+ # ── Slash command completion ──
64
+ # Only complete if '/' is the very first character typed (start of input)
65
+ if stripped.startswith("/"):
66
+ if " " not in stripped:
67
+ query = stripped.lower()
68
+ cmds_with_args = {"/model", "/save", "/load", "/export", "/checkpoint", "/restore"}
69
+ for cmd, description in SLASH_COMMANDS:
70
+ if cmd.startswith(query):
71
+ completion_text = cmd + " " if cmd in cmds_with_args else cmd
72
+ yield Completion(
73
+ completion_text,
74
+ start_position=-len(stripped),
75
+ display=cmd,
76
+ display_meta=description,
77
+ )
78
+ elif stripped.startswith("/model "):
79
+ query = stripped[7:].lower()
80
+ models = get_cached_models()
81
+ for m in models:
82
+ if m["id"].lower().startswith(query) or query in m["id"].lower() or query in m["name"].lower():
83
+ yield Completion(
84
+ m["id"],
85
+ start_position=-len(query),
86
+ display=m["id"],
87
+ display_meta=m["name"]
88
+ )
89
+ return
90
+
91
+ # ── File mention completion (@) ──
92
+ words = text.split()
93
+ if not words:
94
+ return
95
+
96
+ current = words[-1]
97
+ if not current.startswith("@"):
98
+ return
99
+
100
+ query = current[1:]
101
+
102
+ # Support directory traversal
103
+ if "/" in query:
104
+ dir_part = os.path.dirname(query)
105
+ base_part = os.path.basename(query)
106
+ search_dir = dir_part if dir_part else "."
107
+ if os.path.isdir(search_dir):
108
+ try:
109
+ for item in sorted(os.listdir(search_dir)):
110
+ item_path = os.path.join(search_dir, item)
111
+ if item.lower().startswith(base_part.lower()):
112
+ if not should_ignore(item_path, self.ignore_patterns):
113
+ display = os.path.join(dir_part, item)
114
+ if os.path.isdir(item_path):
115
+ display += "/"
116
+ yield Completion(
117
+ display, start_position=-len(query)
118
+ )
119
+ except Exception as e:
120
+ logger.debug("Failed to list directory contents for autocomplete: %s", e)
121
+ else:
122
+ try:
123
+ for item in sorted(os.listdir(".")):
124
+ if item.lower().startswith(query.lower()):
125
+ item_path = item
126
+ if not should_ignore(item_path, self.ignore_patterns):
127
+ if os.path.isdir(item):
128
+ yield Completion(
129
+ item + "/", start_position=-len(query)
130
+ )
131
+ elif os.path.isfile(item):
132
+ yield Completion(
133
+ item, start_position=-len(query)
134
+ )
135
+ except Exception as e:
136
+ logger.debug("Failed to list current directory for autocomplete: %s", e)
137
+
138
+
139
+ async def handle_slash_command(
140
+ command_str: str, messages: list, token_tracker: TokenTracker, mcp_manager=None, client=None
141
+ ) -> bool:
142
+ """Handle slash commands. Returns True if the agent loop should re-process (e.g. /retry)."""
143
+ parts = command_str.split(maxsplit=1)
144
+ cmd = parts[0].lower()
145
+ arg = parts[1].strip() if len(parts) > 1 else ""
146
+
147
+ current_model = get_active_model()
148
+
149
+ if cmd == "/clear":
150
+ if len(messages) > 1:
151
+ messages[:] = [messages[0]]
152
+ console.print("[green]✓ Conversation cleared.[/green]\n")
153
+
154
+ elif cmd == "/drop":
155
+ dropped_count = 0
156
+ for msg in messages:
157
+ if msg["role"] == "user" and msg.get("content"):
158
+ old_content = msg["content"]
159
+ new_content = re.sub(
160
+ r'<file_context path="[^"]+">.*?</file_context>',
161
+ '[File context dropped to save tokens]',
162
+ old_content,
163
+ flags=re.DOTALL
164
+ )
165
+ new_content = re.sub(
166
+ r'<url_context url="[^"]+">.*?</url_context>',
167
+ '[URL context dropped to save tokens]',
168
+ new_content,
169
+ flags=re.DOTALL
170
+ )
171
+ new_content = re.sub(
172
+ r'<directory_context path="[^"]+">.*?</directory_context>',
173
+ '[Directory context dropped to save tokens]',
174
+ new_content,
175
+ flags=re.DOTALL
176
+ )
177
+ new_content = re.sub(
178
+ r'<command_context cmd="[^"]+">.*?</command_context>',
179
+ '[Command context dropped to save tokens]',
180
+ new_content,
181
+ flags=re.DOTALL
182
+ )
183
+ if old_content != new_content:
184
+ msg["content"] = new_content
185
+ dropped_count += 1
186
+ if dropped_count > 0:
187
+ console.print(f"[green]✓ Dropped attached contexts from {dropped_count} past messages.[/green]\n")
188
+ else:
189
+ console.print("[yellow]No attached contexts found to drop.[/yellow]\n")
190
+
191
+ elif cmd == "/model":
192
+ if arg:
193
+ if arg.startswith("search ") or arg == "list" or arg == "search":
194
+ models = get_cached_models()
195
+ if not models:
196
+ console.print("[yellow]Model list is still fetching or unavailable. Try again in a moment.[/yellow]\n")
197
+ return False
198
+
199
+ search_query = arg[7:].lower().strip() if arg.startswith("search ") else ""
200
+
201
+ table = Table(title=f"🧠 OpenRouter Models{' (Search: ' + search_query + ')' if search_query else ''}")
202
+ table.add_column("Model ID", style="cyan")
203
+ table.add_column("Name", style="white")
204
+ table.add_column("Context", style="dim")
205
+ table.add_column("Pricing", style="green")
206
+
207
+ count = 0
208
+ for m in models:
209
+ if not search_query or search_query in m["id"].lower() or search_query in m["name"].lower():
210
+ price_prompt = m.get("pricing", {}).get("prompt", "?")
211
+ price_comp = m.get("pricing", {}).get("completion", "?")
212
+ pricing_str = f"P: {price_prompt} C: {price_comp}"
213
+ table.add_row(m["id"], m["name"], str(m.get("context_length")), pricing_str)
214
+ count += 1
215
+ if count >= 30: # limit output
216
+ break
217
+
218
+ console.print(table)
219
+ if count >= 30:
220
+ console.print("[dim]... and more (showing top 30). Use `/model search <query>` to narrow down.[/dim]\n")
221
+ else:
222
+ console.print()
223
+ else:
224
+ models = get_cached_models()
225
+ found = any(m["id"] == arg for m in models)
226
+
227
+ if models and not found:
228
+ console.print(f"[yellow]⚠️ Warning: Model '{arg}' not found in OpenRouter API list.[/yellow]")
229
+
230
+ set_active_model(arg, save=True)
231
+ console.print(f"[green]✓ Model switched to:[/green] [bold cyan]{arg}[/bold cyan]\n")
232
+ else:
233
+ console.print(f"[bold]Current model:[/bold] [cyan]{current_model}[/cyan]")
234
+ console.print("[dim]Usage: /model <model_name>[/dim]")
235
+ console.print("[dim] /model search <query> (or `/model list`)[/dim]\n")
236
+
237
+ elif cmd == "/help":
238
+ help_table = Table(
239
+ title="⚡ Aizen Commands",
240
+ border_style="magenta",
241
+ show_header=True,
242
+ header_style="bold magenta",
243
+ )
244
+ help_table.add_column("Command", style="cyan bold", min_width=22)
245
+ help_table.add_column("Description", style="white")
246
+ help_table.add_row("/help", "Show this help message")
247
+ help_table.add_row("/model [name]", "View or switch the active model")
248
+ help_table.add_row("/clear", "Clear conversation history")
249
+ help_table.add_row("/drop", "Drop attached files/URLs from history")
250
+ help_table.add_row("/save [name]", "Save current conversation")
251
+ help_table.add_row("/load [name]", "Load a saved conversation")
252
+ help_table.add_row("/usage", "Show token usage statistics")
253
+ help_table.add_row("/compact", "Summarize conversation to save tokens")
254
+ help_table.add_row("/undo", "Undo the last file modification")
255
+ help_table.add_row("/retry", "Retry the last user message")
256
+ help_table.add_row("/copy", "Copy last AI response to clipboard")
257
+ help_table.add_row("/export [file]", "Export conversation to Markdown")
258
+ help_table.add_row("/checkpoint [name]", "Save a conversation snapshot")
259
+ help_table.add_row("/restore [name]", "Restore a saved checkpoint")
260
+ help_table.add_row("/config", "View current configuration")
261
+ help_table.add_row("/mcp", "View configured MCP servers and their status")
262
+ help_table.add_row("/commit", "Auto-generate and commit changes")
263
+ help_table.add_row("/diff", "Show all uncommitted changes")
264
+ help_table.add_row("", "")
265
+ help_table.add_row("@filename / @url", "Attach file context or web URL")
266
+ help_table.add_row("exit / quit", "Exit Aizen")
267
+ help_table.add_row("", "")
268
+ help_table.add_row("[dim]Tip[/dim]", "[dim]End a line with \\\\ for multi-line input[/dim]")
269
+ console.print(help_table)
270
+ console.print()
271
+
272
+ elif cmd == "/usage":
273
+ console.print(token_tracker.get_summary_table(get_active_model()))
274
+ console.print()
275
+
276
+ elif cmd == "/save":
277
+ try:
278
+ path = save_session(messages, arg if arg else None, token_tracker)
279
+ console.print(f"[green]✓ Session saved to {path}[/green]\n")
280
+ except Exception as e:
281
+ console.print(f"[red]Error saving session: {e}[/red]\n")
282
+
283
+ elif cmd == "/load":
284
+ if not arg:
285
+ sessions = list_sessions()
286
+ if not sessions:
287
+ console.print("[yellow]No saved sessions found.[/yellow]\n")
288
+ else:
289
+ table = Table(
290
+ title="📂 Saved Sessions",
291
+ border_style="magenta",
292
+ header_style="bold magenta",
293
+ )
294
+ table.add_column("Name", style="cyan")
295
+ table.add_column("Saved At", style="dim")
296
+ table.add_column("Messages", style="white", justify="right")
297
+ for s in sessions[:10]:
298
+ table.add_row(s["name"], s["saved_at"][:19], str(s["messages"]))
299
+ console.print(table)
300
+ console.print("[dim]Usage: /load <session_name>[/dim]\n")
301
+ else:
302
+ loaded = load_session(arg)
303
+ if loaded:
304
+ messages[:] = loaded
305
+ console.print(
306
+ f"[green]✓ Loaded session '{arg}' ({len(loaded)} messages)[/green]\n"
307
+ )
308
+ else:
309
+ console.print(f"[red]Session '{arg}' not found.[/red]\n")
310
+
311
+ elif cmd == "/undo":
312
+ result = backup_manager.undo()
313
+ console.print(f"[green]{result}[/green]\n")
314
+
315
+ elif cmd == "/retry":
316
+ # Remove last assistant + tool messages, then re-process the last user message
317
+ while messages and messages[-1]["role"] in ("assistant", "tool"):
318
+ messages.pop()
319
+ if messages and messages[-1]["role"] == "user":
320
+ console.print("[green]✓ Retrying last message...[/green]\n")
321
+ return True # Signal to re-process
322
+ else:
323
+ console.print("[yellow]Nothing to retry.[/yellow]\n")
324
+
325
+ elif cmd == "/copy":
326
+ last_response = None
327
+ for msg in reversed(messages):
328
+ if msg["role"] == "assistant" and msg.get("content"):
329
+ last_response = msg["content"]
330
+ break
331
+
332
+ if last_response:
333
+ try:
334
+ if platform.system() == "Darwin":
335
+ subprocess.run(
336
+ ["pbcopy"],
337
+ input=last_response,
338
+ text=True,
339
+ check=True,
340
+ )
341
+ elif platform.system() == "Linux":
342
+ subprocess.run(
343
+ ["xclip", "-selection", "clipboard"],
344
+ input=last_response,
345
+ text=True,
346
+ check=True,
347
+ )
348
+ else:
349
+ subprocess.run(
350
+ ["clip"], input=last_response, text=True, check=True
351
+ )
352
+ console.print("[green]✓ Copied to clipboard.[/green]\n")
353
+ except Exception:
354
+ console.print(
355
+ "[yellow]⚠️ Could not copy to clipboard.[/yellow]\n"
356
+ )
357
+ else:
358
+ console.print("[yellow]No response to copy.[/yellow]\n")
359
+
360
+ elif cmd == "/export":
361
+ filename = (
362
+ arg
363
+ if arg
364
+ else f"aizen_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.md"
365
+ )
366
+ try:
367
+ with open(filename, "w") as f:
368
+ f.write("# Aizen Conversation Export\n\n")
369
+ f.write(
370
+ f"**Date:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
371
+ )
372
+ f.write(f"**Model:** {current_model}\n\n---\n\n")
373
+ for msg in messages:
374
+ if msg["role"] == "system":
375
+ continue
376
+ elif msg["role"] == "user":
377
+ f.write(f"## 👤 You\n\n{msg['content']}\n\n")
378
+ elif msg["role"] == "assistant" and msg.get("content"):
379
+ f.write(f"## ✦ Aizen\n\n{msg['content']}\n\n")
380
+ console.print(f"[green]✓ Exported to {filename}[/green]\n")
381
+ except Exception as e:
382
+ console.print(f"[red]Error exporting: {e}[/red]\n")
383
+
384
+ elif cmd == "/compact":
385
+ if len(messages) <= 4:
386
+ console.print("[yellow]Conversation is already compact.[/yellow]\n")
387
+ else:
388
+ system_msg = messages[0]
389
+ recent = messages[-4:]
390
+ middle = messages[1:-4]
391
+
392
+ if middle:
393
+ # Attempt LLM-based summarization for much better context retention
394
+ console.print("[dim]Summarizing conversation with AI...[/dim]")
395
+ try:
396
+ from openai import AsyncOpenAI as _AsyncOpenAI
397
+
398
+ _config = load_config()
399
+ _api_key = _config.get("OPENROUTER_API_KEY", "")
400
+ _api_base = _config.get("API_BASE_URL", "https://openrouter.ai/api/v1")
401
+ _client = _AsyncOpenAI(base_url=_api_base, api_key=_api_key)
402
+
403
+ # Build a summarization request from the middle messages
404
+ summary_messages = [
405
+ {
406
+ "role": "system",
407
+ "content": (
408
+ "Summarize the following conversation between a user and an AI coding assistant. "
409
+ "Focus on: what files were discussed/modified, what tasks were completed, "
410
+ "what decisions were made, and any important context for continuing the work. "
411
+ "Be concise but thorough. Output only the summary."
412
+ ),
413
+ },
414
+ {
415
+ "role": "user",
416
+ "content": "\n".join(
417
+ f"[{m['role']}]: {(m.get('content') or '')[:500]}"
418
+ for m in middle
419
+ if m.get("content")
420
+ ),
421
+ },
422
+ ]
423
+
424
+ response = await _client.chat.completions.create(
425
+ model=get_active_model(),
426
+ messages=summary_messages, # type: ignore
427
+ max_tokens=1000,
428
+ )
429
+ summary = response.choices[0].message.content or ""
430
+ except Exception:
431
+ # Fallback to naive summarization if API call fails
432
+ user_topics = [
433
+ m["content"][:100]
434
+ for m in middle
435
+ if m["role"] == "user" and m.get("content")
436
+ ]
437
+ summary = (
438
+ "Previous conversation summary: The user and assistant discussed "
439
+ + "; ".join(user_topics[:5])
440
+ + ". The assistant helped with these requests using code analysis and editing tools."
441
+ )
442
+
443
+ messages[:] = [
444
+ system_msg,
445
+ {"role": "user", "content": f"Previous conversation summary:\n{summary}"},
446
+ {
447
+ "role": "assistant",
448
+ "content": "Understood. I have the context from our previous discussion. How can I continue helping?",
449
+ },
450
+ ] + recent
451
+ console.print(
452
+ f"[green]✓ Compacted {len(middle)} messages into an AI-generated summary.[/green]\n"
453
+ )
454
+ else:
455
+ console.print("[yellow]Not enough messages to compact.[/yellow]\n")
456
+
457
+ elif cmd == "/config":
458
+ config = load_config()
459
+ table = Table(
460
+ title="⚙️ Configuration",
461
+ border_style="magenta",
462
+ header_style="bold magenta",
463
+ )
464
+ table.add_column("Key", style="cyan")
465
+ table.add_column("Value", style="white")
466
+ table.add_row("Model", current_model)
467
+ table.add_row(
468
+ "API Base URL",
469
+ config.get("API_BASE_URL", "https://openrouter.ai/api/v1"),
470
+ )
471
+ api_key = config.get("OPENROUTER_API_KEY", "")
472
+ table.add_row("API Key", f"***{api_key[-4:]}" if api_key else "Not set")
473
+ table.add_row("Config File", CONFIG_PATH)
474
+ table.add_row("Sessions Dir", SESSIONS_DIR)
475
+ table.add_row("Backups Dir", BACKUPS_DIR)
476
+ console.print(table)
477
+ console.print()
478
+
479
+ elif cmd == "/mcp":
480
+ if not mcp_manager:
481
+ console.print("[yellow]MCP Manager is not available.[/yellow]\n")
482
+ return False
483
+
484
+ if not mcp_manager.config:
485
+ console.print("[yellow]No MCP servers configured in ~/.aizen_config.json[/yellow]\n")
486
+ console.print("[dim]Add an 'mcp_servers' block to your config to enable MCP plugins.[/dim]\n")
487
+ return False
488
+
489
+ table = Table(
490
+ title="🔌 Configured MCP Servers",
491
+ border_style="magenta",
492
+ header_style="bold magenta",
493
+ )
494
+ table.add_column("Server Name", style="cyan bold")
495
+ table.add_column("Status", style="white")
496
+ table.add_column("Tools Available", style="dim")
497
+
498
+ tools = mcp_manager.get_tools()
499
+ server_tools: dict[str, list[str]] = {srv: [] for srv in mcp_manager.config.keys()}
500
+
501
+ for t in tools:
502
+ name = t["function"]["name"]
503
+ for server_name in mcp_manager.config.keys():
504
+ prefix = f"mcp_{server_name}_"
505
+ if name.startswith(prefix):
506
+ server_tools[server_name].append(name[len(prefix):])
507
+ break
508
+
509
+ for server_name in mcp_manager.config.keys():
510
+ if server_name in mcp_manager.sessions:
511
+ status = "[green]Connected[/green]"
512
+ else:
513
+ status = "[red]Disconnected / Failed[/red]"
514
+
515
+ tool_count = len(server_tools[server_name])
516
+ if tool_count > 0:
517
+ tool_list = ", ".join(server_tools[server_name])
518
+ # Truncate if too long
519
+ if len(tool_list) > 50:
520
+ tool_list = tool_list[:47] + "..."
521
+ tools_display = f"{tool_count} tools: {tool_list}"
522
+ else:
523
+ tools_display = "0 tools"
524
+
525
+ table.add_row(server_name, status, tools_display)
526
+
527
+ console.print(table)
528
+ console.print()
529
+
530
+ elif cmd == "/checkpoint":
531
+ name = arg or f"cp_{datetime.now().strftime('%H%M%S')}"
532
+ _checkpoints[name] = copy.deepcopy(messages)
533
+ console.print(
534
+ f"[green]✓ Checkpoint '{name}' saved ({len(messages)} messages)[/green]\n"
535
+ )
536
+
537
+ elif cmd == "/restore":
538
+ if not arg:
539
+ if not _checkpoints:
540
+ console.print("[yellow]No checkpoints saved. Use /checkpoint [name] first.[/yellow]\n")
541
+ else:
542
+ table = Table(
543
+ title="📌 Checkpoints",
544
+ border_style="magenta",
545
+ header_style="bold magenta",
546
+ )
547
+ table.add_column("Name", style="cyan")
548
+ table.add_column("Messages", style="white", justify="right")
549
+ for cp_name, cp_msgs in _checkpoints.items():
550
+ table.add_row(cp_name, str(len(cp_msgs)))
551
+ console.print(table)
552
+ console.print("[dim]Usage: /restore <name>[/dim]\n")
553
+ else:
554
+ if arg in _checkpoints:
555
+ messages[:] = copy.deepcopy(_checkpoints[arg])
556
+ console.print(
557
+ f"[green]✓ Restored checkpoint '{arg}' ({len(messages)} messages)[/green]\n"
558
+ )
559
+ else:
560
+ console.print(f"[red]Checkpoint '{arg}' not found.[/red]\n")
561
+
562
+ elif cmd == "/commit":
563
+ if not client:
564
+ console.print("[red]API client is not available for /commit.[/red]\n")
565
+ return False
566
+
567
+ try:
568
+ # Check staged changes
569
+ result = subprocess.run(["git", "diff", "--cached"], capture_output=True, text=True, check=True)
570
+ diff = result.stdout.strip()
571
+
572
+ if not diff:
573
+ # Check unstaged
574
+ result_unstaged = subprocess.run(["git", "diff"], capture_output=True, text=True, check=True)
575
+ unstaged_diff = result_unstaged.stdout.strip()
576
+
577
+ if not unstaged_diff:
578
+ console.print("[yellow]No changes found to commit.[/yellow]\n")
579
+ return False
580
+
581
+ answer = prompt("No staged changes. Stage all current changes? [Y/n] ")
582
+ if answer.lower() not in ("y", "yes", ""):
583
+ console.print("[yellow]Commit aborted.[/yellow]\n")
584
+ return False
585
+
586
+ subprocess.run(["git", "add", "-u"], check=True)
587
+ result = subprocess.run(["git", "diff", "--cached"], capture_output=True, text=True, check=True)
588
+ diff = result.stdout.strip()
589
+
590
+ if not diff:
591
+ console.print("[yellow]No changes staged to commit.[/yellow]\n")
592
+ return False
593
+
594
+ console.print("[dim]Generating commit message...[/dim]")
595
+
596
+ commit_messages = [
597
+ {"role": "system", "content": "You are a senior developer. Write a concise, conventional commit message for the following diff. Output ONLY the commit message, no explanation, no markdown blocks."},
598
+ {"role": "user", "content": f"Diff:\n{diff[:10000]}"}
599
+ ]
600
+
601
+ response = await client.chat.completions.create(
602
+ model=get_active_model(),
603
+ messages=commit_messages,
604
+ max_tokens=200,
605
+ )
606
+ commit_msg = response.choices[0].message.content.strip()
607
+ # Remove any markdown codeblocks if model didn't listen
608
+ commit_msg = commit_msg.replace("```text", "").replace("```", "").strip()
609
+
610
+ console.print("\n[bold]Generated Commit Message:[/bold]")
611
+ console.print(f"[cyan]{commit_msg}[/cyan]\n")
612
+
613
+ action = prompt("Commit with this message? [Y/n/e(dit)] ")
614
+ action = action.lower().strip()
615
+
616
+ if action in ("y", "yes", ""):
617
+ final_msg = commit_msg
618
+ elif action in ("e", "edit"):
619
+ final_msg = prompt("Edit message: ", default=commit_msg)
620
+ else:
621
+ console.print("[yellow]Commit aborted.[/yellow]\n")
622
+ return False
623
+
624
+ subprocess.run(["git", "commit", "-m", final_msg], check=True)
625
+ console.print("[green]✓ Committed successfully.[/green]\n")
626
+
627
+ except subprocess.CalledProcessError:
628
+ console.print("[red]Error: Not a git repository or git command failed.[/red]\n")
629
+ except Exception as e:
630
+ console.print(f"[red]Error during auto-commit: {e}[/red]\n")
631
+
632
+ elif cmd == "/diff":
633
+ try:
634
+ # Show staged + unstaged changes
635
+ result_staged = subprocess.run(
636
+ ["git", "diff", "--cached", "--stat"],
637
+ capture_output=True, text=True, check=True
638
+ )
639
+ result_unstaged = subprocess.run(
640
+ ["git", "diff", "--stat"],
641
+ capture_output=True, text=True, check=True
642
+ )
643
+ result_untracked = subprocess.run(
644
+ ["git", "ls-files", "--others", "--exclude-standard"],
645
+ capture_output=True, text=True, check=True
646
+ )
647
+
648
+ has_output = False
649
+
650
+ if result_staged.stdout.strip():
651
+ console.print("[bold green]Staged changes:[/bold green]")
652
+ console.print(f"[dim]{result_staged.stdout.strip()}[/dim]")
653
+ has_output = True
654
+
655
+ if result_unstaged.stdout.strip():
656
+ console.print("[bold yellow]Unstaged changes:[/bold yellow]")
657
+ console.print(f"[dim]{result_unstaged.stdout.strip()}[/dim]")
658
+ has_output = True
659
+
660
+ if result_untracked.stdout.strip():
661
+ untracked = result_untracked.stdout.strip().split("\n")
662
+ console.print(f"[bold cyan]Untracked files ({len(untracked)}):[/bold cyan]")
663
+ for f in untracked[:20]:
664
+ console.print(f" [dim]+ {f}[/dim]")
665
+ if len(untracked) > 20:
666
+ console.print(f" [dim]... and {len(untracked) - 20} more[/dim]")
667
+ has_output = True
668
+
669
+ if not has_output:
670
+ console.print("[green]✓ Working tree is clean.[/green]")
671
+
672
+ # Show full diff if requested
673
+ if arg == "--full" or arg == "-f":
674
+ result_full = subprocess.run(
675
+ ["git", "diff"],
676
+ capture_output=True, text=True, check=True
677
+ )
678
+ if result_full.stdout.strip():
679
+ from rich.syntax import Syntax
680
+ syntax = Syntax(result_full.stdout, "diff", theme="monokai")
681
+ console.print(syntax)
682
+
683
+ console.print()
684
+ except subprocess.CalledProcessError:
685
+ console.print("[red]Error: Not a git repository or git command failed.[/red]\n")
686
+ except Exception as e:
687
+ console.print(f"[red]Error showing diff: {e}[/red]\n")
688
+
689
+ else:
690
+ console.print(
691
+ f"[red]Unknown command: {cmd}[/red] — type [bold]/help[/bold] for commands.\n"
692
+ )
693
+
694
+ return False