gemcli 1.0.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.

Potentially problematic release.


This version of gemcli might be problematic. Click here for more details.

gemini_cli.py ADDED
@@ -0,0 +1,2717 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ GemCLI - Beautiful terminal interface for Gemini AI
4
+ Made by 89P13
5
+ """
6
+
7
+ import asyncio
8
+ import sys
9
+ import os
10
+ import json
11
+ import glob
12
+ import re
13
+ import difflib
14
+ import subprocess
15
+ import tempfile
16
+ from typing import Optional, Tuple, List
17
+ from pathlib import Path
18
+
19
+ # Suppress ALL logging before importing anything
20
+ import logging
21
+ import warnings
22
+ warnings.filterwarnings('ignore')
23
+
24
+ # Disable all logging including loguru
25
+ logging.disable(logging.CRITICAL)
26
+ os.environ['LOGURU_LEVEL'] = 'CRITICAL'
27
+ os.environ['LOGURU_DIAGNOSE'] = 'False'
28
+
29
+ # Redirect stderr to suppress loguru output
30
+ import io
31
+ sys.stderr = io.StringIO()
32
+
33
+ # Third-party imports
34
+ try:
35
+ from gemini_webapi import GeminiClient
36
+ from rich.console import Console
37
+ from rich.markdown import Markdown
38
+ from rich import box
39
+ from rich.theme import Theme
40
+ import questionary
41
+ from questionary import Style
42
+ import base64
43
+ import webbrowser
44
+ except ImportError as e:
45
+ print(f"Missing required library: {e}")
46
+ print("\nPlease install: pip install gemini-webapi rich questionary")
47
+ sys.exit(1)
48
+
49
+ # Theme configuration
50
+ theme_config = {
51
+ 'primary_color': '#FFFFFF', # White (default)
52
+ 'secondary_color': '#FF6B9D', # Vibrant pink
53
+ 'accent_color': '#FFFFFF', # White (default)
54
+ 'text_color': 'white',
55
+ 'border_color': '#3a3a3a',
56
+ 'success_color': '#00FF88',
57
+ 'warning_color': '#FFB700'
58
+ }
59
+
60
+ # Initialize Rich console with custom theme for markdown
61
+ rich_theme = Theme({
62
+ "markdown.link": theme_config['accent_color'],
63
+ "markdown.link_url": theme_config['accent_color'],
64
+ "markdown.code": theme_config['accent_color'],
65
+ "markdown.code_block": theme_config['text_color'],
66
+ })
67
+ console = Console(theme=rich_theme)
68
+
69
+ # =============================================================================
70
+ # UI Components
71
+ # =============================================================================
72
+
73
+ # Store last code blocks for copy functionality
74
+ last_code_blocks = []
75
+
76
+ # Git workflow preferences
77
+ git_preferences = {
78
+ 'enabled': True, # Enable git integration
79
+ 'commit_mode': 'on_exit', # 'immediate' or 'on_exit'
80
+ 'auto_push': True,
81
+ 'ask_branch': True # Ask to create/select branch when entering semi-agent mode
82
+ }
83
+
84
+ # Diff viewer preferences
85
+ diff_preferences = {
86
+ 'enabled': True, # Enable diff viewing
87
+ 'editor': 'vscode' # 'vscode', 'default', or 'none'
88
+ }
89
+
90
+
91
+ def clear_screen():
92
+ """Clear terminal screen."""
93
+ os.system('cls' if os.name == 'nt' else 'clear')
94
+
95
+
96
+ def print_banner():
97
+ """Display beautiful banner."""
98
+ clear_screen()
99
+
100
+ # Banner with gradient effect from white to black (top to bottom)
101
+ banner_lines = [
102
+ " ██████╗ ███████╗███╗ ███╗ ██████╗██╗ ██╗",
103
+ " ██╔════╝ ██╔════╝████╗ ████║ ██╔════╝██║ ██║",
104
+ " ██║ ███╗█████╗ ██╔████╔██║ ██║ ██║ ██║",
105
+ " ██║ ██║██╔══╝ ██║╚██╔╝██║ ██║ ██║ ██║",
106
+ " ╚██████╔╝███████╗██║ ╚═╝ ██║ ╚██████╗███████╗██║",
107
+ " ╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═════╝╚══════╝╚═╝"
108
+ ]
109
+
110
+ # Gradient colors - vibrant golden to orange/pink (top to bottom)
111
+ gradient_colors = ['#FFD700', '#FFC700', '#FFB700', '#FF8C00', '#FF6B35', '#FF6B9D']
112
+
113
+ console.print()
114
+ for i, line in enumerate(banner_lines):
115
+ console.print(line, style=f"bold {gradient_colors[i]}", justify="left")
116
+
117
+ # Clickable GitHub credit
118
+ console.print(f"\n[link=https://github.com/Aniketh78][bold {theme_config['accent_color']}]Made by 89P13[/][/link]\n", justify="center")
119
+
120
+
121
+ def get_file_completions(text: str, current_word: str) -> List[str]:
122
+ """Get file path completions based on current input - restricted to workspace only."""
123
+ try:
124
+ # Get workspace root (current working directory)
125
+ workspace_root = Path.cwd()
126
+
127
+ # Remove the / prefix if present
128
+ path_word = current_word.lstrip('/')
129
+
130
+ # If word starts with / or contains path separators, try to complete
131
+ if current_word.startswith('/') or '/' in current_word or '\\' in current_word:
132
+ # Extract the directory and partial filename
133
+ path_str = path_word.replace('\\', '/')
134
+
135
+ # Convert relative path to absolute within workspace
136
+ search_base = workspace_root
137
+ if path_str.startswith('./'):
138
+ path_str = path_str[2:]
139
+
140
+ if not path_str or path_str.endswith('/'):
141
+ # User typed / alone or a directory path ending with /, show contents
142
+ search_dir = search_base / path_str.rstrip('/') if path_str else search_base
143
+ pattern = '*'
144
+ else:
145
+ # Extract directory and filename pattern
146
+ last_slash = path_str.rfind('/')
147
+ if last_slash != -1:
148
+ dir_part = path_str[:last_slash]
149
+ file_part = path_str[last_slash + 1:]
150
+ search_dir = search_base / dir_part
151
+ pattern = file_part + '*' if file_part else '*'
152
+ else:
153
+ search_dir = search_base
154
+ pattern = path_str + '*' if path_str else '*'
155
+
156
+ # Get matches within workspace only
157
+ matches = []
158
+ try:
159
+ if search_dir.exists() and search_dir.is_dir():
160
+ for item in search_dir.glob(pattern):
161
+ # Only include items within workspace
162
+ try:
163
+ relative = item.relative_to(workspace_root)
164
+ rel_str = str(relative).replace('\\', '/')
165
+ if item.is_dir():
166
+ matches.append('/' + rel_str + '/')
167
+ else:
168
+ matches.append('/' + rel_str)
169
+ except ValueError:
170
+ # Skip items outside workspace
171
+ continue
172
+ except:
173
+ pass
174
+
175
+ # Filter out common ignore patterns
176
+ matches = [m for m in matches if not any(
177
+ ignore in m for ignore in ['__pycache__', '.git', 'venv', 'node_modules', '.pyc']
178
+ )]
179
+
180
+ return sorted(matches)[:20] # Limit to 20 suggestions
181
+ else:
182
+ return []
183
+ except:
184
+ return []
185
+
186
+
187
+ def get_directory_completions(text: str, current_word: str) -> List[str]:
188
+ """Get directory path completions (directories only) - restricted to workspace only."""
189
+ try:
190
+ # Get workspace root (current working directory)
191
+ workspace_root = Path.cwd()
192
+
193
+ # Handle both absolute and relative paths
194
+ if current_word.startswith('/'):
195
+ # Workspace-relative path
196
+ path_word = current_word.lstrip('/')
197
+ search_base = workspace_root
198
+ else:
199
+ # Could be absolute Windows path or relative
200
+ if Path(current_word).is_absolute():
201
+ # Absolute path
202
+ search_base = Path(current_word).parent
203
+ path_word = Path(current_word).name
204
+ else:
205
+ # Relative to workspace
206
+ path_word = current_word
207
+ search_base = workspace_root
208
+
209
+ path_str = path_word.replace('\\', '/')
210
+
211
+ if not path_str or path_str.endswith('/'):
212
+ # Show directories in current level
213
+ search_dir = search_base / path_str.rstrip('/') if path_str else search_base
214
+ pattern = '*'
215
+ else:
216
+ # Extract directory and partial name
217
+ last_slash = path_str.rfind('/')
218
+ if last_slash != -1:
219
+ dir_part = path_str[:last_slash]
220
+ name_part = path_str[last_slash + 1:]
221
+ search_dir = search_base / dir_part
222
+ pattern = name_part + '*' if name_part else '*'
223
+ else:
224
+ search_dir = search_base
225
+ pattern = path_str + '*' if path_str else '*'
226
+
227
+ # Get directory matches only
228
+ matches = []
229
+ try:
230
+ if search_dir.exists() and search_dir.is_dir():
231
+ for item in search_dir.glob(pattern):
232
+ if item.is_dir():
233
+ # Use absolute paths for directory picker
234
+ matches.append(str(item) + os.sep)
235
+ except:
236
+ pass
237
+
238
+ # Filter out common ignore patterns
239
+ matches = [m for m in matches if not any(
240
+ ignore in m for ignore in ['__pycache__', '.git', 'venv', 'node_modules']
241
+ )]
242
+
243
+ return sorted(matches)[:20]
244
+ except:
245
+ return []
246
+ """Get file path completions based on current input - restricted to workspace only."""
247
+ try:
248
+ # Get workspace root (CliTool directory)
249
+ workspace_root = Path(__file__).parent.absolute()
250
+
251
+ # Remove the / prefix if present
252
+ path_word = current_word.lstrip('/')
253
+
254
+ # If word starts with / or contains path separators, try to complete
255
+ if current_word.startswith('/') or '/' in current_word or '\\' in current_word:
256
+ # Extract the directory and partial filename
257
+ path_str = path_word.replace('\\', '/')
258
+
259
+ # Convert relative path to absolute within workspace
260
+ search_base = workspace_root
261
+ if path_str.startswith('./'):
262
+ path_str = path_str[2:]
263
+
264
+ if not path_str or path_str.endswith('/'):
265
+ # User typed / alone or a directory path ending with /, show contents
266
+ search_dir = search_base / path_str.rstrip('/') if path_str else search_base
267
+ pattern = '*'
268
+ else:
269
+ # Extract directory and filename pattern
270
+ last_slash = path_str.rfind('/')
271
+ if last_slash != -1:
272
+ dir_part = path_str[:last_slash]
273
+ file_part = path_str[last_slash + 1:]
274
+ search_dir = search_base / dir_part
275
+ pattern = file_part + '*' if file_part else '*'
276
+ else:
277
+ search_dir = search_base
278
+ pattern = path_str + '*' if path_str else '*'
279
+
280
+ # Get matches within workspace only
281
+ matches = []
282
+ try:
283
+ if search_dir.exists() and search_dir.is_dir():
284
+ for item in search_dir.glob(pattern):
285
+ # Only include items within workspace
286
+ try:
287
+ relative = item.relative_to(workspace_root)
288
+ rel_str = str(relative).replace('\\', '/')
289
+ if item.is_dir():
290
+ matches.append('/' + rel_str + '/')
291
+ else:
292
+ matches.append('/' + rel_str)
293
+ except ValueError:
294
+ # Skip items outside workspace
295
+ continue
296
+ except:
297
+ pass
298
+
299
+ # Filter out common ignore patterns
300
+ matches = [m for m in matches if not any(
301
+ ignore in m for ignore in ['__pycache__', '.git', 'venv', 'node_modules', '.pyc']
302
+ )]
303
+
304
+ return sorted(matches)[:20] # Limit to 20 suggestions
305
+ else:
306
+ return []
307
+ except:
308
+ return []
309
+
310
+
311
+ async def get_text_input_async(prompt: str) -> str:
312
+ """Get text input with real-time file path highlighting using /."""
313
+ from prompt_toolkit import PromptSession
314
+ from prompt_toolkit.completion import Completer, Completion
315
+ from prompt_toolkit.styles import Style as PTStyle
316
+ from prompt_toolkit.formatted_text import HTML, FormattedText
317
+ from prompt_toolkit.key_binding import KeyBindings
318
+ from prompt_toolkit.lexers import Lexer
319
+ from prompt_toolkit.document import Document
320
+ import re as regex
321
+
322
+ class FilePathLexer(Lexer):
323
+ """Lexer that highlights file paths starting with / in accent color."""
324
+ def lex_document(self, document):
325
+ def get_line(lineno):
326
+ line = document.lines[lineno]
327
+ result = []
328
+
329
+ # Find all file path patterns /something
330
+ pattern = regex.compile(r'(/[^\s]+)')
331
+ last_end = 0
332
+
333
+ for match in pattern.finditer(line):
334
+ # Add text before the match in default style
335
+ if match.start() > last_end:
336
+ result.append(('', line[last_end:match.start()]))
337
+
338
+ # Add the file path in accent color with background
339
+ file_path = match.group(1)
340
+ result.append((f'class:filepath', file_path))
341
+ last_end = match.end()
342
+
343
+ # Add remaining text
344
+ if last_end < len(line):
345
+ result.append(('', line[last_end:]))
346
+
347
+ return result
348
+
349
+ return get_line
350
+
351
+ class PathCompleter(Completer):
352
+ def get_completions(self, document, complete_event):
353
+ text = document.text
354
+ text_before_cursor = document.text_before_cursor
355
+
356
+ # Find the last / symbol and get the word starting from it
357
+ slash_pos = text_before_cursor.rfind('/')
358
+ if slash_pos != -1:
359
+ word = text_before_cursor[slash_pos:]
360
+ completions = get_file_completions(text, word)
361
+ for completion in completions:
362
+ # Display just the filename/folder with styled display
363
+ display_name = completion.lstrip('/').split('/')[-1] or completion
364
+ if completion.endswith('/'):
365
+ display_name = '📁 ' + display_name
366
+ else:
367
+ display_name = '📄 ' + display_name
368
+
369
+ # White text in dropdown
370
+ yield Completion(
371
+ completion,
372
+ start_position=-len(word),
373
+ display=FormattedText([('#ffffff bold', display_name)])
374
+ )
375
+
376
+ style = PTStyle.from_dict({
377
+ 'prompt': f'{theme_config["primary_color"]} bold',
378
+ '': f'{theme_config["text_color"]}',
379
+ 'completion-menu': 'bg:#1a1a2e #ffffff',
380
+ 'completion-menu.completion': 'bg:#16213e #ffffff',
381
+ 'completion-menu.completion.current': f'bg:{theme_config["accent_color"]} #000000 bold',
382
+ 'scrollbar.background': 'bg:#444444',
383
+ 'scrollbar.button': f'bg:{theme_config["accent_color"]}',
384
+ 'filepath': f'fg:{theme_config["accent_color"]} bold bg:#2a2a3a',
385
+ })
386
+
387
+ # Create key bindings
388
+ kb = KeyBindings()
389
+
390
+ @kb.add('tab')
391
+ def select_completion(event):
392
+ """Use Tab to accept the current highlighted completion."""
393
+ buff = event.app.current_buffer
394
+ if buff.complete_state:
395
+ buff.complete_state = None
396
+ else:
397
+ buff.start_completion(select_first=True)
398
+
399
+ @kb.add('down')
400
+ def next_completion(event):
401
+ """Navigate down in completion menu."""
402
+ buff = event.app.current_buffer
403
+ if buff.complete_state:
404
+ buff.complete_next()
405
+
406
+ @kb.add('up')
407
+ def prev_completion(event):
408
+ """Navigate up in completion menu."""
409
+ buff = event.app.current_buffer
410
+ if buff.complete_state:
411
+ buff.complete_previous()
412
+
413
+ @kb.add('enter')
414
+ def handle_enter(event):
415
+ """Enter: submit the input."""
416
+ buff = event.app.current_buffer
417
+ if buff.complete_state:
418
+ buff.complete_state = None
419
+ buff.validate_and_handle()
420
+
421
+ # Claude-style prompt with diamond and vibrant colors
422
+ console.print()
423
+ console.print(f"[bold {theme_config['primary_color']}]◆ {prompt}[/]")
424
+
425
+ session = PromptSession(
426
+ completer=PathCompleter(),
427
+ lexer=FilePathLexer(),
428
+ complete_while_typing=True,
429
+ style=style,
430
+ key_bindings=kb,
431
+ message=HTML(f'<style fg="{theme_config["accent_color"]}" bg="">❯ </style>'),
432
+ )
433
+
434
+ result = await session.prompt_async()
435
+
436
+ return result.strip()
437
+
438
+
439
+ async def get_directory_input_async(prompt: str) -> str:
440
+ """Get directory path input with autocomplete for directories only."""
441
+ from prompt_toolkit import PromptSession
442
+ from prompt_toolkit.completion import Completer, Completion
443
+ from prompt_toolkit.styles import Style as PTStyle
444
+ from prompt_toolkit.formatted_text import HTML, FormattedText
445
+ from prompt_toolkit.key_binding import KeyBindings
446
+
447
+ class DirectoryCompleter(Completer):
448
+ def get_completions(self, document, complete_event):
449
+ text = document.text
450
+ completions = get_directory_completions(text, text)
451
+ for completion in completions:
452
+ # Display directory name with folder icon
453
+ display_name = Path(completion).name or completion
454
+ display_name = '📁 ' + display_name
455
+
456
+ yield Completion(
457
+ completion,
458
+ start_position=-len(text),
459
+ display=FormattedText([('#ffffff bold', display_name)])
460
+ )
461
+
462
+ style = PTStyle.from_dict({
463
+ 'prompt': f'{theme_config["primary_color"]} bold',
464
+ '': f'{theme_config["text_color"]}',
465
+ 'completion-menu': 'bg:#1a1a2e #ffffff',
466
+ 'completion-menu.completion': 'bg:#16213e #ffffff',
467
+ 'completion-menu.completion.current': f'bg:{theme_config["accent_color"]} #000000 bold',
468
+ 'scrollbar.background': 'bg:#444444',
469
+ 'scrollbar.button': f'bg:{theme_config["accent_color"]}',
470
+ })
471
+
472
+ # Create key bindings
473
+ kb = KeyBindings()
474
+
475
+ @kb.add('tab')
476
+ def select_completion(event):
477
+ buff = event.app.current_buffer
478
+ if buff.complete_state:
479
+ buff.complete_state = None
480
+ else:
481
+ buff.start_completion(select_first=True)
482
+
483
+ @kb.add('down')
484
+ def next_completion(event):
485
+ buff = event.app.current_buffer
486
+ if buff.complete_state:
487
+ buff.complete_next()
488
+
489
+ @kb.add('up')
490
+ def prev_completion(event):
491
+ buff = event.app.current_buffer
492
+ if buff.complete_state:
493
+ buff.complete_previous()
494
+
495
+ @kb.add('enter')
496
+ def handle_enter(event):
497
+ buff = event.app.current_buffer
498
+ if buff.complete_state:
499
+ buff.complete_state = None
500
+ buff.validate_and_handle()
501
+
502
+ console.print()
503
+ console.print(f"[bold {theme_config['primary_color']}]◆ {prompt}[/]")
504
+
505
+ session = PromptSession(
506
+ completer=DirectoryCompleter(),
507
+ complete_while_typing=True,
508
+ style=style,
509
+ key_bindings=kb,
510
+ message=HTML(f'<style fg="{theme_config["accent_color"]}" bg="">❯ </style>'),
511
+ )
512
+
513
+ result = await session.prompt_async()
514
+
515
+ return result.strip()
516
+ """Get text input with real-time file path highlighting using /."""
517
+ from prompt_toolkit import PromptSession
518
+ from prompt_toolkit.completion import Completer, Completion
519
+ from prompt_toolkit.styles import Style as PTStyle
520
+ from prompt_toolkit.formatted_text import HTML, FormattedText
521
+ from prompt_toolkit.key_binding import KeyBindings
522
+ from prompt_toolkit.lexers import Lexer
523
+ from prompt_toolkit.document import Document
524
+ import re as regex
525
+
526
+ class FilePathLexer(Lexer):
527
+ """Lexer that highlights file paths starting with / in accent color."""
528
+ def lex_document(self, document):
529
+ def get_line(lineno):
530
+ line = document.lines[lineno]
531
+ result = []
532
+
533
+ # Find all file path patterns /something
534
+ pattern = regex.compile(r'(/[^\s]+)')
535
+ last_end = 0
536
+
537
+ for match in pattern.finditer(line):
538
+ # Add text before the match in default style
539
+ if match.start() > last_end:
540
+ result.append(('', line[last_end:match.start()]))
541
+
542
+ # Add the file path in accent color with background
543
+ file_path = match.group(1)
544
+ result.append((f'class:filepath', file_path))
545
+ last_end = match.end()
546
+
547
+ # Add remaining text
548
+ if last_end < len(line):
549
+ result.append(('', line[last_end:]))
550
+
551
+ return result
552
+
553
+ return get_line
554
+
555
+ class PathCompleter(Completer):
556
+ def get_completions(self, document, complete_event):
557
+ text = document.text
558
+ text_before_cursor = document.text_before_cursor
559
+
560
+ # Find the last / symbol and get the word starting from it
561
+ slash_pos = text_before_cursor.rfind('/')
562
+ if slash_pos != -1:
563
+ word = text_before_cursor[slash_pos:]
564
+ completions = get_file_completions(text, word)
565
+ for completion in completions:
566
+ # Display just the filename/folder with styled display
567
+ display_name = completion.lstrip('/').split('/')[-1] or completion
568
+ if completion.endswith('/'):
569
+ display_name = '📁 ' + display_name
570
+ else:
571
+ display_name = '📄 ' + display_name
572
+
573
+ # White text in dropdown
574
+ yield Completion(
575
+ completion,
576
+ start_position=-len(word),
577
+ display=FormattedText([('#ffffff bold', display_name)])
578
+ )
579
+
580
+ style = PTStyle.from_dict({
581
+ 'prompt': f'{theme_config["primary_color"]} bold',
582
+ '': f'{theme_config["text_color"]}',
583
+ 'completion-menu': 'bg:#1a1a2e #ffffff',
584
+ 'completion-menu.completion': 'bg:#16213e #ffffff',
585
+ 'completion-menu.completion.current': f'bg:{theme_config["accent_color"]} #000000 bold',
586
+ 'scrollbar.background': 'bg:#444444',
587
+ 'scrollbar.button': f'bg:{theme_config["accent_color"]}',
588
+ 'filepath': f'fg:{theme_config["accent_color"]} bold bg:#2a2a3a',
589
+ })
590
+
591
+ # Create key bindings
592
+ kb = KeyBindings()
593
+
594
+ @kb.add('tab')
595
+ def select_completion(event):
596
+ """Use Tab to accept the current highlighted completion."""
597
+ buff = event.app.current_buffer
598
+ if buff.complete_state:
599
+ buff.complete_state = None
600
+ else:
601
+ buff.start_completion(select_first=True)
602
+
603
+ @kb.add('down')
604
+ def next_completion(event):
605
+ """Navigate down in completion menu."""
606
+ buff = event.app.current_buffer
607
+ if buff.complete_state:
608
+ buff.complete_next()
609
+
610
+ @kb.add('up')
611
+ def prev_completion(event):
612
+ """Navigate up in completion menu."""
613
+ buff = event.app.current_buffer
614
+ if buff.complete_state:
615
+ buff.complete_previous()
616
+
617
+ @kb.add('enter')
618
+ def handle_enter(event):
619
+ """Enter: submit the input."""
620
+ buff = event.app.current_buffer
621
+ if buff.complete_state:
622
+ buff.complete_state = None
623
+ buff.validate_and_handle()
624
+
625
+ # Claude-style prompt with diamond and vibrant colors
626
+ console.print()
627
+ console.print(f"[bold {theme_config['primary_color']}]◆ {prompt}[/]")
628
+
629
+ session = PromptSession(
630
+ completer=PathCompleter(),
631
+ lexer=FilePathLexer(),
632
+ complete_while_typing=True,
633
+ style=style,
634
+ key_bindings=kb,
635
+ message=HTML(f'<style fg="{theme_config["accent_color"]}" bg="">❯ </style>'),
636
+ )
637
+
638
+ result = await session.prompt_async()
639
+
640
+ return result.strip()
641
+
642
+
643
+
644
+ def get_text_input(prompt: str, placeholder: str = "") -> str:
645
+ """Get text input with creative styling."""
646
+ console.print()
647
+ console.print(f"[bold {theme_config['primary_color']}]◆ {prompt}[/]")
648
+ if placeholder:
649
+ console.print(f" [dim italic]{placeholder}[/]")
650
+ console.print(f"[{theme_config['accent_color']}]❯[/] ", end="")
651
+ text = input().strip()
652
+ console.print()
653
+ return text
654
+
655
+
656
+ def typewriter_print(text: str, delay: float = 0.01):
657
+ """Print text with typewriter effect character by character."""
658
+ import sys
659
+ import time
660
+
661
+ for char in text:
662
+ sys.stdout.write(char)
663
+ sys.stdout.flush()
664
+ # Faster for spaces and newlines, slower for other characters
665
+ if char in ' \n':
666
+ time.sleep(delay * 0.5)
667
+ else:
668
+ time.sleep(delay)
669
+ print() # Final newline
670
+
671
+
672
+ async def typewriter_response(response_text: str):
673
+ """Display response with typewriter effect, handling markdown properly."""
674
+ import time
675
+ import sys
676
+
677
+ # For markdown content, we'll type it out line by line with rich formatting
678
+ lines = response_text.split('\n')
679
+
680
+ for line in lines:
681
+ # Print each line with a slight delay between characters
682
+ if line.strip():
683
+ # Use console for rich formatting, but add small delay between lines
684
+ console.print(Markdown(line))
685
+ await asyncio.sleep(0.03) # Small delay between lines
686
+ else:
687
+ console.print()
688
+ await asyncio.sleep(0.01)
689
+
690
+
691
+ def render_response_with_code_blocks(response_text: str):
692
+ """Render response with styled code blocks that have copy functionality."""
693
+ import re
694
+ import pyperclip
695
+
696
+ global last_code_blocks
697
+ last_code_blocks = []
698
+
699
+ # Pattern to match code blocks with optional language
700
+ code_pattern = r'```(\w*)\n(.*?)```'
701
+
702
+ # Split text by code blocks
703
+ parts = re.split(r'(```\w*\n.*?```)', response_text, flags=re.DOTALL)
704
+
705
+ code_index = 0
706
+ for part in parts:
707
+ code_match = re.match(r'```(\w*)\n(.*?)```', part, re.DOTALL)
708
+ if code_match:
709
+ language = code_match.group(1) or 'code'
710
+ code_content = code_match.group(2).rstrip()
711
+ code_index += 1
712
+
713
+ # Store code for copy functionality
714
+ last_code_blocks.append(code_content)
715
+
716
+ # Print styled code block header
717
+ console.print()
718
+ console.print(f"[{theme_config['border_color']}]┌─[/] [{theme_config['accent_color']} bold]{language}[/] [{theme_config['border_color']}]{'─' * 60}[/] [dim][ /copy {code_index} ][/]")
719
+ console.print(f"[{theme_config['border_color']}]│[/]")
720
+
721
+ # Print code with syntax highlighting
722
+ for line in code_content.split('\n'):
723
+ console.print(f"[{theme_config['border_color']}]│[/] [{theme_config['text_color']}]{line}[/]")
724
+
725
+ console.print(f"[{theme_config['border_color']}]│[/]")
726
+ console.print(f"[{theme_config['border_color']}]└{'─' * 70}[/]")
727
+ console.print()
728
+ else:
729
+ # Render regular markdown
730
+ if part.strip():
731
+ console.print(Markdown(part))
732
+
733
+
734
+ def copy_code_block(index: int) -> bool:
735
+ """Copy a code block to clipboard by index."""
736
+ try:
737
+ import pyperclip
738
+ if 1 <= index <= len(last_code_blocks):
739
+ pyperclip.copy(last_code_blocks[index - 1])
740
+ return True
741
+ except:
742
+ pass
743
+ return False
744
+
745
+
746
+ def display_file_diff(file_path: str, old_content: str, new_content: str, is_new: bool = False):
747
+ """Display a diff between old and new file content with red/green indicators."""
748
+ console.print()
749
+ console.print(f"[bold {theme_config['accent_color']}]━━━ {file_path} {'(NEW FILE)' if is_new else ''} ━━━[/]")
750
+ console.print()
751
+
752
+ if is_new:
753
+ # For new files, show all lines as additions
754
+ lines = new_content.splitlines(keepends=False)
755
+ for line in lines:
756
+ console.print(f"[bold green]+ {line}[/]")
757
+ else:
758
+ # Generate diff for existing files
759
+ old_lines = old_content.splitlines(keepends=False)
760
+ new_lines = new_content.splitlines(keepends=False)
761
+
762
+ diff = difflib.unified_diff(
763
+ old_lines,
764
+ new_lines,
765
+ lineterm='',
766
+ fromfile=f'a/{file_path}',
767
+ tofile=f'b/{file_path}'
768
+ )
769
+
770
+ # Skip the file headers (first 2 lines: --- and +++)
771
+ diff_lines = list(diff)
772
+ for line in diff_lines[2:]:
773
+ if line.startswith('@@'):
774
+ # Hunk header
775
+ console.print(f"[bold cyan]{line}[/]")
776
+ elif line.startswith('+'):
777
+ # Added line
778
+ console.print(f"[bold green]{line}[/]")
779
+ elif line.startswith('-'):
780
+ # Removed line
781
+ console.print(f"[bold red]{line}[/]")
782
+ else:
783
+ # Context line
784
+ console.print(f"[dim]{line}[/]")
785
+
786
+ console.print()
787
+ console.print(f"[bold {theme_config['accent_color']}]━━━ End of {file_path} ━━━[/]")
788
+ console.print()
789
+
790
+
791
+ def open_vscode_diff(original_path: Path, new_content: str, file_label: str, is_new: bool = False):
792
+ """Open diff viewer to show changes visually based on user preferences."""
793
+ try:
794
+ # Check if diff viewer is enabled
795
+ if not diff_preferences['enabled']:
796
+ return None
797
+
798
+ # Create a temporary file with the new content
799
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.tmp', delete=False, encoding='utf-8') as tmp:
800
+ tmp.write(new_content)
801
+ tmp_path = tmp.name
802
+
803
+ # Choose editor based on preferences
804
+ if diff_preferences['editor'] == 'none':
805
+ # Just return tmp path without opening any editor
806
+ return tmp_path
807
+ elif diff_preferences['editor'] == 'default':
808
+ # Use system default editor
809
+ try:
810
+ if os.name == 'nt':
811
+ os.startfile(tmp_path)
812
+ elif sys.platform == 'darwin':
813
+ subprocess.Popen(['open', tmp_path])
814
+ else:
815
+ subprocess.Popen(['xdg-open', tmp_path])
816
+ return tmp_path
817
+ except Exception:
818
+ return tmp_path
819
+ else: # vscode
820
+ # Try multiple ways to launch VS Code on Windows
821
+ vscode_commands = [
822
+ 'code',
823
+ 'code.cmd',
824
+ os.path.expandvars(r'%LOCALAPPDATA%\Programs\Microsoft VS Code\bin\code.cmd'),
825
+ os.path.expandvars(r'%PROGRAMFILES%\Microsoft VS Code\bin\code.cmd'),
826
+ ]
827
+
828
+ launched = False
829
+ for cmd in vscode_commands:
830
+ try:
831
+ if is_new:
832
+ # For new files, just open the temp file in preview mode
833
+ subprocess.Popen([cmd, '--reuse-window', tmp_path],
834
+ stdout=subprocess.DEVNULL,
835
+ stderr=subprocess.DEVNULL,
836
+ creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0)
837
+ else:
838
+ # For existing files, open VS Code diff editor
839
+ # Format: code --diff <original> <modified>
840
+ subprocess.Popen([cmd, '--diff', str(original_path), tmp_path],
841
+ stdout=subprocess.DEVNULL,
842
+ stderr=subprocess.DEVNULL,
843
+ creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0)
844
+ launched = True
845
+ break
846
+ except (FileNotFoundError, OSError):
847
+ continue
848
+
849
+ if not launched:
850
+ raise FileNotFoundError("VS Code not found. Make sure VS Code is installed and 'code' command is in PATH.")
851
+
852
+ return tmp_path
853
+ except Exception as e:
854
+ console.print(f"[{theme_config['warning_color']}]⚠ Could not open diff viewer: {e}[/]")
855
+ return None
856
+
857
+
858
+ # =============================================================================
859
+ # Cookie Management
860
+ # =============================================================================
861
+
862
+ def save_cookies(psid: str, psidts: Optional[str] = None):
863
+ """Save cookies to local file."""
864
+ cookies_file = Path('.gemini_cookies.json')
865
+ cookies_data = {
866
+ 'psid': psid,
867
+ 'psidts': psidts
868
+ }
869
+ try:
870
+ cookies_file.write_text(json.dumps(cookies_data, indent=2))
871
+ except Exception as e:
872
+ console.print(f"[yellow]⚠ Could not save cookies: {e}[/]")
873
+
874
+
875
+ def load_cookies() -> Tuple[Optional[str], Optional[str]]:
876
+ """Load cookies from local file."""
877
+ cookies_file = Path('.gemini_cookies.json')
878
+ if cookies_file.exists():
879
+ try:
880
+ cookies_data = json.loads(cookies_file.read_text())
881
+ return cookies_data.get('psid'), cookies_data.get('psidts')
882
+ except Exception as e:
883
+ console.print(f"[yellow]⚠ Could not load cookies: {e}[/]")
884
+ return None, None
885
+
886
+
887
+ def get_cookies() -> Tuple[Optional[str], Optional[str]]:
888
+ """Get authentication cookies from user."""
889
+
890
+ console.print(f"\n[bold {theme_config['primary_color']}]◆ Cookie Setup ◆[/]")
891
+ console.rule(style=theme_config['border_color'])
892
+
893
+ console.print(f"\n[{theme_config['text_color']}]◆ To get your authentication cookies:[/]\n")
894
+ console.print(f" [{theme_config['accent_color']}]1.[/] Open [{theme_config['secondary_color']}]https://gemini.google.com[/] in your browser")
895
+ console.print(f" [{theme_config['accent_color']}]2.[/] Press [{theme_config['secondary_color']}]F12[/] to open Developer Tools")
896
+ console.print(f" [{theme_config['accent_color']}]3.[/] Go to: [{theme_config['secondary_color']}]Application[/] → [{theme_config['secondary_color']}]Cookies[/] → [{theme_config['secondary_color']}]https://gemini.google.com[/]")
897
+ console.print(f" [{theme_config['accent_color']}]4.[/] Copy the cookie values below:\n")
898
+
899
+ console.rule(style=theme_config['border_color'])
900
+
901
+ try:
902
+ # Get __Secure-1PSID
903
+ psid = get_text_input("◆ __Secure-1PSID (required)", "Paste cookie value here")
904
+
905
+ if not psid:
906
+ console.print(f"\n[red]✗ No value entered[/]")
907
+ return None, None
908
+
909
+ # Get __Secure-1PSIDTS
910
+ psidts = get_text_input("◆ __Secure-1PSIDTS (optional)", "Press Enter to skip") or None
911
+
912
+ console.print(f"\n[bold {theme_config['success_color']}]✓ Cookies received successfully[/]\n")
913
+
914
+ return psid, psidts
915
+
916
+ except (KeyboardInterrupt, EOFError):
917
+ console.print(f"\n\n[{theme_config['warning_color']}]⚠ Cancelled[/]")
918
+ return None, None
919
+
920
+
921
+ # =============================================================================
922
+ # Gemini Client
923
+ # =============================================================================
924
+
925
+ async def initialize_client(psid: str, psidts: Optional[str] = None) -> Optional[GeminiClient]:
926
+ """Initialize Gemini client."""
927
+ try:
928
+ with console.status(f"[bold {theme_config['primary_color']}]◆ Gemini is connecting...[/]", spinner="dots"):
929
+ client = GeminiClient(secure_1psid=psid, secure_1psidts=psidts)
930
+ await client.init(timeout=45)
931
+
932
+ console.print(f"[bold {theme_config['success_color']}]✓ Connected to Gemini[/]\n")
933
+ return client
934
+
935
+ except Exception as e:
936
+ console.print(f"[bold red]✗ Failed to connect[/]")
937
+ console.print(f"[red]Error: {e}[/]")
938
+ return None
939
+
940
+
941
+ # =============================================================================
942
+ # Workspace Search (for Agent mode)
943
+ # =============================================================================
944
+
945
+ def search_workspace(query: str, file_pattern: str = "**/*", max_results: int = 20) -> List[dict]:
946
+ """Search workspace for files matching query. Returns metadata only (paths, not content)."""
947
+ workspace_root = Path.cwd()
948
+ results = []
949
+
950
+ try:
951
+ # Search through all files
952
+ for file_path in workspace_root.rglob(file_pattern):
953
+ # Skip directories
954
+ if not file_path.is_file():
955
+ continue
956
+
957
+ # Skip large files and common ignore patterns
958
+ if any(ignore in str(file_path) for ignore in ['__pycache__', '.git', 'venv', 'node_modules', '.pyc']):
959
+ continue
960
+
961
+ try:
962
+ if file_path.stat().st_size > 500_000: # Skip files > 500KB
963
+ continue
964
+
965
+ content = file_path.read_text(errors='ignore')
966
+
967
+ # Check if query matches (case-insensitive, supports regex-like patterns)
968
+ patterns = query.split('|') # Support multiple patterns
969
+ if any(re.search(pattern, content, re.IGNORECASE) for pattern in patterns):
970
+ relative_path = file_path.relative_to(workspace_root)
971
+ results.append({
972
+ 'path': str(relative_path).replace('\\', '/'),
973
+ 'size': f"{file_path.stat().st_size / 1024:.1f}kb",
974
+ 'lines': content.count('\n') + 1
975
+ })
976
+ except Exception:
977
+ continue
978
+
979
+ # Sort by size (smaller files first - easier to digest)
980
+ results.sort(key=lambda x: float(x['size'].replace('kb', '')))
981
+ return results[:max_results]
982
+ except Exception:
983
+ return []
984
+
985
+
986
+ def read_workspace_files(file_paths: List[str], max_size_per_file: int = 15000) -> dict:
987
+ """Read specific files from workspace. Returns dict of {path: content}."""
988
+ workspace_root = Path.cwd()
989
+ file_contents = {}
990
+
991
+ for path_str in file_paths:
992
+ try:
993
+ # Normalize path
994
+ path_str = path_str.lstrip('/')
995
+ file_path = workspace_root / path_str
996
+
997
+ if file_path.exists() and file_path.is_file():
998
+ content = file_path.read_text(errors='ignore')
999
+ # Limit content size to avoid token overflow
1000
+ if len(content) > max_size_per_file:
1001
+ content = content[:max_size_per_file] + f"\n\n... [truncated, file is {len(content)} chars total]"
1002
+ file_contents[path_str] = content
1003
+ else:
1004
+ file_contents[path_str] = f"Error: File not found or not accessible"
1005
+ except Exception as e:
1006
+ file_contents[path_str] = f"Error reading file: {e}"
1007
+
1008
+ return file_contents
1009
+
1010
+
1011
+ # =============================================================================
1012
+ # System Command Execution (for Agent/Semi-Agent mode)
1013
+ # =============================================================================
1014
+
1015
+ def execute_system_command(command: str) -> Tuple[bool, str]:
1016
+ """
1017
+ Execute system commands like opening applications, adjusting settings, etc.
1018
+ Returns (success: bool, output: str)
1019
+ """
1020
+ try:
1021
+ if os.name == 'nt': # Windows
1022
+ # Replace bash-style && with PowerShell-style ; for command chaining
1023
+ command = command.replace(' && ', '; ')
1024
+
1025
+ # Use PowerShell for Windows commands
1026
+ result = subprocess.run(
1027
+ ['powershell', '-Command', command],
1028
+ capture_output=True,
1029
+ text=True,
1030
+ timeout=30,
1031
+ creationflags=subprocess.CREATE_NO_WINDOW
1032
+ )
1033
+ else: # Unix/Linux/Mac
1034
+ result = subprocess.run(
1035
+ command,
1036
+ shell=True,
1037
+ capture_output=True,
1038
+ text=True,
1039
+ timeout=30
1040
+ )
1041
+
1042
+ output = result.stdout if result.stdout else result.stderr
1043
+ success = result.returncode == 0
1044
+
1045
+ return success, output.strip() if output else "Command executed"
1046
+
1047
+ except subprocess.TimeoutExpired:
1048
+ return False, "Command timed out after 30 seconds"
1049
+ except Exception as e:
1050
+ return False, f"Error executing command: {str(e)}"
1051
+
1052
+
1053
+ # =============================================================================
1054
+ # Git Integration
1055
+ # =============================================================================
1056
+
1057
+ def get_git_status() -> dict:
1058
+ """Get current git status."""
1059
+ try:
1060
+ workspace_root = Path.cwd()
1061
+
1062
+ # Check if git repo
1063
+ result = subprocess.run(
1064
+ ['git', 'rev-parse', '--git-dir'],
1065
+ cwd=workspace_root,
1066
+ capture_output=True,
1067
+ text=True,
1068
+ creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0
1069
+ )
1070
+
1071
+ if result.returncode != 0:
1072
+ return {'is_repo': False}
1073
+
1074
+ # Get status
1075
+ status_result = subprocess.run(
1076
+ ['git', 'status', '--porcelain'],
1077
+ cwd=workspace_root,
1078
+ capture_output=True,
1079
+ text=True,
1080
+ creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0
1081
+ )
1082
+
1083
+ # Get current branch
1084
+ branch_result = subprocess.run(
1085
+ ['git', 'branch', '--show-current'],
1086
+ cwd=workspace_root,
1087
+ capture_output=True,
1088
+ text=True,
1089
+ creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0
1090
+ )
1091
+
1092
+ return {
1093
+ 'is_repo': True,
1094
+ 'status': status_result.stdout,
1095
+ 'branch': branch_result.stdout.strip(),
1096
+ 'has_changes': bool(status_result.stdout.strip())
1097
+ }
1098
+ except Exception as e:
1099
+ return {'is_repo': False, 'error': str(e)}
1100
+
1101
+
1102
+ async def generate_commit_message(chat, modified_files: List[str]) -> str:
1103
+ """Generate a commit message based on modified files."""
1104
+ try:
1105
+ # Get diff for context
1106
+ workspace_root = Path.cwd()
1107
+ diff_result = subprocess.run(
1108
+ ['git', 'diff', '--staged'] if modified_files else ['git', 'diff'],
1109
+ cwd=workspace_root,
1110
+ capture_output=True,
1111
+ text=True,
1112
+ creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0
1113
+ )
1114
+
1115
+ # Get diff text safely
1116
+ diff_text = diff_result.stdout[:3000] if diff_result.stdout else "No diff available"
1117
+
1118
+ # Build file list string
1119
+ files_str = ', '.join(modified_files) if modified_files else "all changes"
1120
+
1121
+ prompt = f"""Generate a concise git commit message for these changes.
1122
+ Files modified: {files_str}
1123
+
1124
+ Diff:
1125
+ {diff_text}
1126
+
1127
+ Return ONLY the commit message (one line summary, then optional detailed description). No explanations."""
1128
+
1129
+ response = await chat.send_message(prompt)
1130
+ return response.text.strip() if response and response.text else "Update files"
1131
+ except Exception as e:
1132
+ # Safe fallback message
1133
+ if modified_files:
1134
+ return f"Update {', '.join(modified_files)}"
1135
+ else:
1136
+ return "Update files"
1137
+
1138
+
1139
+ def git_commit(message: str, files: List[str] = None) -> tuple:
1140
+ """Commit changes with message."""
1141
+ try:
1142
+ workspace_root = Path.cwd()
1143
+
1144
+ # Add files
1145
+ if files:
1146
+ for file in files:
1147
+ subprocess.run(
1148
+ ['git', 'add', file],
1149
+ cwd=workspace_root,
1150
+ check=True,
1151
+ creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0
1152
+ )
1153
+ else:
1154
+ subprocess.run(
1155
+ ['git', 'add', '-A'],
1156
+ cwd=workspace_root,
1157
+ check=True,
1158
+ creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0
1159
+ )
1160
+
1161
+ # Commit
1162
+ result = subprocess.run(
1163
+ ['git', 'commit', '-m', message],
1164
+ cwd=workspace_root,
1165
+ capture_output=True,
1166
+ text=True,
1167
+ creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0
1168
+ )
1169
+
1170
+ return True, result.stdout
1171
+ except Exception as e:
1172
+ return False, str(e)
1173
+
1174
+
1175
+ def git_push() -> tuple:
1176
+ """Push commits to remote."""
1177
+ try:
1178
+ workspace_root = Path.cwd()
1179
+
1180
+ result = subprocess.run(
1181
+ ['git', 'push'],
1182
+ cwd=workspace_root,
1183
+ capture_output=True,
1184
+ text=True,
1185
+ creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0
1186
+ )
1187
+
1188
+ if result.returncode == 0:
1189
+ return True, result.stdout
1190
+ else:
1191
+ return False, result.stderr
1192
+ except Exception as e:
1193
+ return False, str(e)
1194
+
1195
+
1196
+ # =============================================================================
1197
+ # Image Settings Management
1198
+ # =============================================================================
1199
+
1200
+ # Global image settings
1201
+ image_settings = {
1202
+ 'save_path': Path('gemini_images') # Default path
1203
+ }
1204
+
1205
+
1206
+ async def configure_image_settings():
1207
+ """Configure image save settings."""
1208
+ clear_screen()
1209
+ print_banner()
1210
+
1211
+ console.print(f"\n[bold {theme_config['primary_color']}]◆ Image Save Settings ◆[/]")
1212
+ console.rule(style=theme_config['border_color'])
1213
+
1214
+ console.print()
1215
+ console.print(f"[{theme_config['text_color']}]Current save path:[/] [{theme_config['accent_color']}]{image_settings['save_path']}[/]")
1216
+ console.print(f"[dim]Type to see directory suggestions. Press Enter to keep current.[/]")
1217
+ console.print()
1218
+
1219
+ # Get new path from user with directory autocomplete
1220
+ new_path = await get_directory_input_async("Enter save directory path")
1221
+
1222
+ if new_path:
1223
+ # Remove trailing separator if present
1224
+ new_path = new_path.rstrip(os.sep)
1225
+ image_settings['save_path'] = Path(new_path)
1226
+ console.print(f"\n[bold {theme_config['success_color']}]✓ Save path updated to: {image_settings['save_path']}[/]\n")
1227
+ else:
1228
+ console.print(f"\n[{theme_config['text_color']}]Keeping current path[/]\n")
1229
+
1230
+ await asyncio.sleep(1)
1231
+
1232
+
1233
+ # =============================================================================
1234
+ # Git Settings Management
1235
+ # =============================================================================
1236
+
1237
+ async def configure_git_settings():
1238
+ """Configure Git workflow preferences."""
1239
+ clear_screen()
1240
+ print_banner()
1241
+
1242
+ console.print(f"\n[bold {theme_config['primary_color']}]◆ Git Workflow Settings ◆[/]")
1243
+ console.rule(style=theme_config['border_color'])
1244
+
1245
+ custom_style = Style([
1246
+ ('pointer', f'fg:{theme_config["accent_color"]} bold'),
1247
+ ('highlighted', f'fg:{theme_config["primary_color"]} bold'),
1248
+ ('question', f'fg:{theme_config["secondary_color"]} bold'),
1249
+ ])
1250
+
1251
+ console.print()
1252
+ console.print(f"[bold {theme_config['secondary_color']}]Commit Mode:[/]")
1253
+ console.print(f"[dim]When should changes be committed?[/]\n")
1254
+
1255
+ commit_mode_choice = await questionary.select(
1256
+ "",
1257
+ choices=[
1258
+ '◆ Ask on exit (recommended)',
1259
+ '◆ After every change'
1260
+ ],
1261
+ pointer="❯",
1262
+ style=custom_style,
1263
+ qmark="",
1264
+ show_selected=False,
1265
+ use_shortcuts=False,
1266
+ default='◆ Ask on exit (recommended)' if git_preferences['commit_mode'] == 'on_exit' else '◆ After every change'
1267
+ ).ask_async()
1268
+
1269
+ if commit_mode_choice == '◆ Ask on exit (recommended)':
1270
+ git_preferences['commit_mode'] = 'on_exit'
1271
+ else:
1272
+ git_preferences['commit_mode'] = 'immediate'
1273
+
1274
+ console.print()
1275
+ console.print(f"[bold {theme_config['secondary_color']}]Auto Push:[/]")
1276
+ console.print(f"[dim]Automatically push after committing?[/]\n")
1277
+
1278
+ auto_push_choice = await questionary.select(
1279
+ "",
1280
+ choices=['◆ Yes', '◆ No'],
1281
+ pointer="❯",
1282
+ style=custom_style,
1283
+ qmark="",
1284
+ show_selected=False,
1285
+ use_shortcuts=False,
1286
+ default='◆ Yes' if git_preferences['auto_push'] else '◆ No'
1287
+ ).ask_async()
1288
+
1289
+ git_preferences['auto_push'] = (auto_push_choice == '◆ Yes')
1290
+
1291
+ console.print()
1292
+ console.print(f"[bold {theme_config['secondary_color']}]Branch Selection:[/]")
1293
+ console.print(f"[dim]Ask to create/select branch in Semi-Agent mode?[/]\n")
1294
+
1295
+ branch_choice = await questionary.select(
1296
+ "",
1297
+ choices=['◆ Yes', '◆ No'],
1298
+ pointer="❯",
1299
+ style=custom_style,
1300
+ qmark="",
1301
+ show_selected=False,
1302
+ use_shortcuts=False,
1303
+ default='◆ Yes' if git_preferences['ask_branch'] else '◆ No'
1304
+ ).ask_async()
1305
+
1306
+ git_preferences['ask_branch'] = (branch_choice == '◆ Yes')
1307
+
1308
+ console.print()
1309
+ console.print(f"[bold {theme_config['success_color']}]✓ Git settings saved[/]")
1310
+ console.print()
1311
+ console.print(f"[dim]Commit mode: {git_preferences['commit_mode']}[/]")
1312
+ console.print(f"[dim]Auto push: {'Yes' if git_preferences['auto_push'] else 'No'}[/]")
1313
+ console.print(f"[dim]Ask branch: {'Yes' if git_preferences['ask_branch'] else 'No'}[/]\n")
1314
+
1315
+ await asyncio.sleep(2)
1316
+
1317
+
1318
+ # =============================================================================
1319
+ # Settings Menu
1320
+ # =============================================================================
1321
+
1322
+ async def settings_menu():
1323
+ """Main settings menu."""
1324
+ while True:
1325
+ clear_screen()
1326
+ print_banner()
1327
+
1328
+ console.print(f"\n[bold {theme_config['primary_color']}]◆ Settings ◆[/]")
1329
+ console.rule(style=theme_config['border_color'])
1330
+
1331
+ custom_style = Style([
1332
+ ('pointer', f'fg:{theme_config["accent_color"]} bold'),
1333
+ ('highlighted', f'fg:{theme_config["primary_color"]} bold'),
1334
+ ('question', f'fg:{theme_config["secondary_color"]} bold'),
1335
+ ('instruction', 'fg:#555555'),
1336
+ ])
1337
+
1338
+ console.print()
1339
+ choice = await questionary.select(
1340
+ "Choose a setting to configure:",
1341
+ choices=["◆ GitHub Integration", "◆ View Diff Settings", "◆ Back to Main Menu"],
1342
+ pointer="❯",
1343
+ style=custom_style,
1344
+ qmark="",
1345
+ instruction="(Use arrow keys)",
1346
+ show_selected=False,
1347
+ use_shortcuts=False
1348
+ ).ask_async()
1349
+
1350
+ if choice == "◆ Back to Main Menu":
1351
+ return
1352
+ elif choice == "◆ GitHub Integration":
1353
+ await configure_github_integration()
1354
+ elif choice == "◆ View Diff Settings":
1355
+ await configure_diff_settings()
1356
+
1357
+
1358
+ async def configure_github_integration():
1359
+ """Configure GitHub integration settings."""
1360
+ clear_screen()
1361
+ print_banner()
1362
+
1363
+ console.print(f"\n[bold {theme_config['primary_color']}]◆ GitHub Integration ◆[/]")
1364
+ console.rule(style=theme_config['border_color'])
1365
+
1366
+ custom_style = Style([
1367
+ ('pointer', f'fg:{theme_config["accent_color"]} bold'),
1368
+ ('highlighted', f'fg:{theme_config["primary_color"]} bold'),
1369
+ ('question', f'fg:{theme_config["secondary_color"]} bold'),
1370
+ ])
1371
+
1372
+ console.print()
1373
+ console.print(f"[bold {theme_config['secondary_color']}]Enable Git Integration:[/]")
1374
+ console.print(f"[dim]Enable automatic git operations (commits, pushes)?[/]\n")
1375
+
1376
+ enable_choice = await questionary.select(
1377
+ "",
1378
+ choices=['◆ Yes', '◆ No'],
1379
+ pointer="❯",
1380
+ style=custom_style,
1381
+ qmark="",
1382
+ show_selected=False,
1383
+ use_shortcuts=False,
1384
+ default='◆ Yes' if git_preferences['enabled'] else '◆ No'
1385
+ ).ask_async()
1386
+
1387
+ git_preferences['enabled'] = (enable_choice == '◆ Yes')
1388
+
1389
+ if git_preferences['enabled']:
1390
+ # Ask for commit mode
1391
+ console.print()
1392
+ console.print(f"[bold {theme_config['secondary_color']}]Commit Mode:[/]")
1393
+ console.print(f"[dim]When should changes be committed?[/]\n")
1394
+
1395
+ commit_mode_choice = await questionary.select(
1396
+ "",
1397
+ choices=[
1398
+ '◆ Ask on exit (recommended)',
1399
+ '◆ After every change'
1400
+ ],
1401
+ pointer="❯",
1402
+ style=custom_style,
1403
+ qmark="",
1404
+ show_selected=False,
1405
+ use_shortcuts=False,
1406
+ default='◆ Ask on exit (recommended)' if git_preferences['commit_mode'] == 'on_exit' else '◆ After every change'
1407
+ ).ask_async()
1408
+
1409
+ if commit_mode_choice == '◆ Ask on exit (recommended)':
1410
+ git_preferences['commit_mode'] = 'on_exit'
1411
+ else:
1412
+ git_preferences['commit_mode'] = 'immediate'
1413
+
1414
+ # Ask for auto push
1415
+ console.print()
1416
+ console.print(f"[bold {theme_config['secondary_color']}]Auto Push:[/]")
1417
+ console.print(f"[dim]Automatically push after committing?[/]\n")
1418
+
1419
+ auto_push_choice = await questionary.select(
1420
+ "",
1421
+ choices=['◆ Yes', '◆ No'],
1422
+ pointer="❯",
1423
+ style=custom_style,
1424
+ qmark="",
1425
+ show_selected=False,
1426
+ use_shortcuts=False,
1427
+ default='◆ Yes' if git_preferences['auto_push'] else '◆ No'
1428
+ ).ask_async()
1429
+
1430
+ git_preferences['auto_push'] = (auto_push_choice == '◆ Yes')
1431
+
1432
+ # Ask for branch selection
1433
+ console.print()
1434
+ console.print(f"[bold {theme_config['secondary_color']}]Branch Selection:[/]")
1435
+ console.print(f"[dim]Ask to create/select branch in Semi-Agent mode?[/]\n")
1436
+
1437
+ branch_choice = await questionary.select(
1438
+ "",
1439
+ choices=['◆ Yes', '◆ No'],
1440
+ pointer="❯",
1441
+ style=custom_style,
1442
+ qmark="",
1443
+ show_selected=False,
1444
+ use_shortcuts=False,
1445
+ default='◆ Yes' if git_preferences['ask_branch'] else '◆ No'
1446
+ ).ask_async()
1447
+
1448
+ git_preferences['ask_branch'] = (branch_choice == '◆ Yes')
1449
+
1450
+ console.print()
1451
+ console.print(f"[bold {theme_config['success_color']}]✓ GitHub integration settings saved[/]")
1452
+ console.print()
1453
+ console.print(f"[dim]Git integration: {'Enabled' if git_preferences['enabled'] else 'Disabled'}[/]")
1454
+ if git_preferences['enabled']:
1455
+ console.print(f"[dim]Commit mode: {git_preferences['commit_mode']}[/]")
1456
+ console.print(f"[dim]Auto push: {'Yes' if git_preferences['auto_push'] else 'No'}[/]")
1457
+ console.print(f"[dim]Ask branch: {'Yes' if git_preferences['ask_branch'] else 'No'}[/]")
1458
+ console.print()
1459
+
1460
+ await asyncio.sleep(2)
1461
+
1462
+
1463
+ async def configure_diff_settings():
1464
+ """Configure diff viewing settings."""
1465
+ clear_screen()
1466
+ print_banner()
1467
+
1468
+ console.print(f"\n[bold {theme_config['primary_color']}]◆ View Diff Settings ◆[/]")
1469
+ console.rule(style=theme_config['border_color'])
1470
+
1471
+ custom_style = Style([
1472
+ ('pointer', f'fg:{theme_config["accent_color"]} bold'),
1473
+ ('highlighted', f'fg:{theme_config["primary_color"]} bold'),
1474
+ ('question', f'fg:{theme_config["secondary_color"]} bold'),
1475
+ ])
1476
+
1477
+ console.print()
1478
+ console.print(f"[bold {theme_config['secondary_color']}]Enable Diff Viewer:[/]")
1479
+ console.print(f"[{theme_config['text_color']}]Show file changes in a diff viewer before applying?[/]\n")
1480
+
1481
+ enable_choice = await questionary.select(
1482
+ "",
1483
+ choices=['◆ Yes', '◆ No'],
1484
+ pointer="❯",
1485
+ style=custom_style,
1486
+ qmark="",
1487
+ show_selected=False,
1488
+ use_shortcuts=False,
1489
+ default='◆ Yes' if diff_preferences['enabled'] else '◆ No'
1490
+ ).ask_async()
1491
+
1492
+ diff_preferences['enabled'] = (enable_choice == '◆ Yes')
1493
+
1494
+ if diff_preferences['enabled']:
1495
+ console.print()
1496
+ console.print(f"[bold {theme_config['secondary_color']}]Diff Editor:[/]")
1497
+ console.print(f"[{theme_config['text_color']}]Choose your preferred diff viewer:[/]\n")
1498
+
1499
+ editor_choice = await questionary.select(
1500
+ "",
1501
+ choices=['◆ VS Code', '◆ System Default', '◆ None (terminal only)'],
1502
+ pointer="❯",
1503
+ style=custom_style,
1504
+ qmark="",
1505
+ show_selected=False,
1506
+ use_shortcuts=False,
1507
+ default='◆ VS Code' if diff_preferences['editor'] == 'vscode' else ('◆ System Default' if diff_preferences['editor'] == 'default' else '◆ None (terminal only)')
1508
+ ).ask_async()
1509
+
1510
+ if editor_choice == '◆ VS Code':
1511
+ diff_preferences['editor'] = 'vscode'
1512
+ elif editor_choice == '◆ System Default':
1513
+ diff_preferences['editor'] = 'default'
1514
+ else:
1515
+ diff_preferences['editor'] = 'none'
1516
+
1517
+ console.print()
1518
+ console.print(f"[bold {theme_config['success_color']}]✓ Diff viewer settings saved[/]")
1519
+ console.print()
1520
+ console.print(f"[dim]Diff viewer: {'Enabled' if diff_preferences['enabled'] else 'Disabled'}[/]")
1521
+ if diff_preferences['enabled']:
1522
+ console.print(f"[dim]Editor: {diff_preferences['editor']}[/]")
1523
+ console.print()
1524
+
1525
+ await asyncio.sleep(2)
1526
+
1527
+
1528
+ async def handle_exit_git_commit(chat):
1529
+ """Handle git commit when exiting semi-agent mode."""
1530
+ git_info = get_git_status()
1531
+ if git_info['is_repo'] and git_info['has_changes']:
1532
+ console.print()
1533
+ console.print(f"[bold {theme_config['warning_color']}]⚠ You have uncommitted changes[/]\n")
1534
+
1535
+ custom_style = Style([
1536
+ ('pointer', f'fg:{theme_config["accent_color"]} bold'),
1537
+ ('highlighted', f'fg:{theme_config["primary_color"]} bold'),
1538
+ ])
1539
+
1540
+ # Build choices based on auto_push preference
1541
+ choices = []
1542
+ if git_preferences['auto_push']:
1543
+ choices.extend(['◆ Yes, commit and push', '◆ Yes, commit only'])
1544
+ else:
1545
+ choices.append('◆ Yes, commit')
1546
+ choices.append('◆ No, exit without committing')
1547
+
1548
+ commit_choice = await questionary.select(
1549
+ "Would you like to commit before exiting?",
1550
+ choices=choices,
1551
+ pointer="❯",
1552
+ style=custom_style,
1553
+ qmark="",
1554
+ show_selected=False,
1555
+ use_shortcuts=False
1556
+ ).ask_async()
1557
+
1558
+ if commit_choice and 'Yes' in commit_choice:
1559
+ console.print()
1560
+ console.print(f"[bold {theme_config['primary_color']}]◆ Generating commit message...[/]")
1561
+ commit_msg = await generate_commit_message(chat, [])
1562
+
1563
+ if commit_msg:
1564
+ console.print()
1565
+ console.print(f"[bold {theme_config['secondary_color']}]◆ Suggested commit message:[/]")
1566
+ console.print(f"[{theme_config['accent_color']}]{commit_msg}[/]")
1567
+ console.print()
1568
+
1569
+ confirm = await questionary.select(
1570
+ "Commit with this message?",
1571
+ choices=['◆ Yes, commit', '◆ No, cancel'],
1572
+ pointer="❯",
1573
+ style=custom_style,
1574
+ qmark="",
1575
+ show_selected=False,
1576
+ use_shortcuts=False
1577
+ ).ask_async()
1578
+
1579
+ if confirm == '◆ Yes, commit':
1580
+ success, output = git_commit(commit_msg)
1581
+ if success:
1582
+ console.print(f"\n[bold {theme_config['success_color']}]✓ Committed successfully[/]")
1583
+ console.print(f"[dim]{output}[/]")
1584
+
1585
+ # Push if user selected commit and push
1586
+ if 'push' in commit_choice.lower():
1587
+ console.print(f"[bold {theme_config['primary_color']}]◆ Pushing to remote...[/]")
1588
+ push_success, push_output = git_push()
1589
+ if push_success:
1590
+ console.print(f"[bold {theme_config['success_color']}]✓ Pushed successfully[/]")
1591
+ else:
1592
+ console.print(f"[bold red]✗ Push failed:[/] {push_output}")
1593
+ console.print()
1594
+ else:
1595
+ console.print(f"\n[bold red]✗ Commit failed:[/] {output}\n")
1596
+
1597
+
1598
+ def get_git_branches():
1599
+ """Get list of git branches."""
1600
+ try:
1601
+ # Get local branches only
1602
+ result = subprocess.run(
1603
+ ['git', 'branch'],
1604
+ capture_output=True,
1605
+ text=True,
1606
+ creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0
1607
+ )
1608
+
1609
+ if result.returncode == 0:
1610
+ branches = []
1611
+ current_branch = None
1612
+ for line in result.stdout.splitlines():
1613
+ line = line.strip()
1614
+ if line.startswith('*'):
1615
+ # Current branch
1616
+ current_branch = line[2:].strip()
1617
+ branches.append(current_branch)
1618
+ elif line:
1619
+ # Other local branches
1620
+ branches.append(line)
1621
+ return branches, current_branch
1622
+ return [], None
1623
+ except Exception:
1624
+ return [], None
1625
+
1626
+
1627
+ async def handle_branch_selection():
1628
+ """Handle branch creation or selection for Semi-Agent mode."""
1629
+ git_info = get_git_status()
1630
+
1631
+ if not git_info['is_repo']:
1632
+ return None
1633
+
1634
+ if not git_preferences['ask_branch']:
1635
+ return None
1636
+
1637
+ branches, current_branch = get_git_branches()
1638
+
1639
+ if not branches:
1640
+ return None
1641
+
1642
+ console.print()
1643
+ console.print(f"[bold {theme_config['primary_color']}]◆ Git Branch Management ◆[/]")
1644
+ console.print(f"[{theme_config['text_color']}]Current branch:[/] [{theme_config['accent_color']}]{current_branch}[/]\n")
1645
+
1646
+ custom_style = Style([
1647
+ ('pointer', f'fg:{theme_config["accent_color"]} bold'),
1648
+ ('highlighted', f'fg:{theme_config["primary_color"]} bold'),
1649
+ ])
1650
+
1651
+ choice = await questionary.select(
1652
+ "Would you like to work on a different branch?",
1653
+ choices=[
1654
+ '◆ Stay on current branch',
1655
+ '◆ Create new branch',
1656
+ '◆ Switch to existing branch'
1657
+ ],
1658
+ pointer="❯",
1659
+ style=custom_style,
1660
+ qmark="",
1661
+ show_selected=False,
1662
+ use_shortcuts=False
1663
+ ).ask_async()
1664
+
1665
+ if choice == '◆ Create new branch':
1666
+ console.print()
1667
+ branch_name = await questionary.text(
1668
+ "Enter new branch name:",
1669
+ style=custom_style,
1670
+ qmark=""
1671
+ ).ask_async()
1672
+
1673
+ if branch_name:
1674
+ result = subprocess.run(
1675
+ ['git', 'checkout', '-b', branch_name],
1676
+ capture_output=True,
1677
+ text=True,
1678
+ creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0
1679
+ )
1680
+
1681
+ if result.returncode == 0:
1682
+ console.print(f"\n[bold {theme_config['success_color']}]✓ Created and switched to branch: {branch_name}[/]\n")
1683
+ return branch_name
1684
+ else:
1685
+ console.print(f"\n[bold red]✗ Failed to create branch: {result.stderr}[/]\n")
1686
+ return None
1687
+
1688
+ elif choice == '◆ Switch to existing branch':
1689
+ console.print()
1690
+ branch_choices = [f'◆ {b}' for b in branches if b != current_branch]
1691
+
1692
+ if not branch_choices:
1693
+ console.print(f"[{theme_config['warning_color']}]No other branches available[/]\n")
1694
+ return None
1695
+
1696
+ selected = await questionary.select(
1697
+ "Select branch:",
1698
+ choices=branch_choices,
1699
+ pointer="❯",
1700
+ style=custom_style,
1701
+ qmark="",
1702
+ show_selected=False,
1703
+ use_shortcuts=False
1704
+ ).ask_async()
1705
+
1706
+ if selected:
1707
+ branch_name = selected.replace('◆ ', '')
1708
+ result = subprocess.run(
1709
+ ['git', 'checkout', branch_name],
1710
+ capture_output=True,
1711
+ text=True,
1712
+ creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0
1713
+ )
1714
+
1715
+ if result.returncode == 0:
1716
+ console.print(f"\n[bold {theme_config['success_color']}]✓ Switched to branch: {branch_name}[/]\n")
1717
+ return branch_name
1718
+ else:
1719
+ console.print(f"\n[bold red]✗ Failed to switch branch: {result.stderr}[/]\n")
1720
+ return None
1721
+
1722
+ return None
1723
+
1724
+
1725
+ # =============================================================================
1726
+ # Chat Interface
1727
+ # =============================================================================
1728
+
1729
+ async def send_message(chat, message: str) -> tuple:
1730
+ """Send message to Gemini chat session. Returns (text, images_list)."""
1731
+ try:
1732
+ response = await chat.send_message(message)
1733
+
1734
+ text = response.text if response and response.text else ""
1735
+ images = []
1736
+
1737
+ # Check for images in response
1738
+ if response and hasattr(response, 'images') and response.images:
1739
+ images = response.images
1740
+
1741
+ # If no text but has images, show placeholder
1742
+ if not text and images:
1743
+ text = "_Image generated successfully_"
1744
+ elif not text and not images:
1745
+ text = "[No response]"
1746
+
1747
+ return text, images
1748
+ except asyncio.TimeoutError:
1749
+ return "[red][Timeout - Please try again][/]", []
1750
+ except Exception as e:
1751
+ return f"[red][Error: {str(e)}][/]", []
1752
+
1753
+
1754
+ async def chat_loop(client: GeminiClient, mode: str = "ask"):
1755
+ """Main chat loop."""
1756
+
1757
+ # Start a chat session for continuous conversation
1758
+ chat = client.start_chat()
1759
+
1760
+ # For agent mode or semi-agent mode, handle branch selection first
1761
+ if mode in ["semi-agent", "agent"] and git_preferences['enabled']:
1762
+ await handle_branch_selection()
1763
+
1764
+ # For agent mode, show instructions
1765
+ if mode == "agent":
1766
+ clear_screen()
1767
+ print_banner()
1768
+
1769
+ console.print(f"[bold {theme_config['primary_color']}]◆ Agent Mode ◆[/]")
1770
+ console.rule(style=theme_config['border_color'])
1771
+ console.print()
1772
+ console.print(f"[{theme_config['text_color']}]In this mode, Gemini can:[/]")
1773
+ console.print(f" [{theme_config['accent_color']}]•[/] Search your entire workspace for relevant files")
1774
+ console.print(f" [{theme_config['accent_color']}]•[/] Read specific files it needs")
1775
+ console.print(f" [{theme_config['accent_color']}]•[/] Make coordinated changes across multiple files")
1776
+ console.print(f" [{theme_config['accent_color']}]•[/] Work autonomously without you specifying files")
1777
+ console.print()
1778
+ console.print(f"[{theme_config['secondary_color']}]Just describe what you want to accomplish![/]")
1779
+ console.print()
1780
+ console.rule(style=theme_config['border_color'])
1781
+ console.print()
1782
+
1783
+ # For image mode, show menu first
1784
+ if mode == "image":
1785
+ while True:
1786
+ clear_screen()
1787
+ print_banner()
1788
+
1789
+ console.print()
1790
+ console.print(f"[bold {theme_config['accent_color']}]◆ Image Generation Mode[/]")
1791
+ console.print()
1792
+ console.print(f"[{theme_config['text_color']}]Current save path:[/] [{theme_config['accent_color']}]{image_settings['save_path']}[/]")
1793
+ console.print()
1794
+
1795
+ # Create custom style
1796
+ custom_style = Style([
1797
+ ('pointer', f'fg:{theme_config["accent_color"]} bold'),
1798
+ ('highlighted', f'fg:{theme_config["primary_color"]} bold'),
1799
+ ('question', f'fg:{theme_config["secondary_color"]} bold'),
1800
+ ('instruction', 'fg:#555555'),
1801
+ ])
1802
+
1803
+ image_choice = await questionary.select(
1804
+ "Choose an option:",
1805
+ choices=["◆ Generate Images", "◆ Save Settings", "◆ Back to Main Menu"],
1806
+ pointer="❯",
1807
+ style=custom_style,
1808
+ qmark="",
1809
+ instruction="(Use arrow keys)",
1810
+ show_selected=False,
1811
+ use_shortcuts=False
1812
+ ).ask_async()
1813
+
1814
+ if image_choice == "◆ Back to Main Menu":
1815
+ return
1816
+ elif image_choice == "◆ Save Settings":
1817
+ await configure_image_settings()
1818
+ continue
1819
+ elif image_choice == "◆ Generate Images":
1820
+ break
1821
+
1822
+ # Display mode indicator with diamonds
1823
+ mode_display = {
1824
+ "ask": "◆ Ask Mode",
1825
+ "semi-agent": "◆ Semi-Agent Mode (AI Coding Assistant)",
1826
+ "agent": "◆ Agent Mode (Autonomous)",
1827
+ "image": "◆ Image Generation Mode"
1828
+ }
1829
+ console.print(f"\n[bold {theme_config['accent_color']}]{mode_display.get(mode, '◆ Ask')}[/]")
1830
+ if mode in ["ask", "semi-agent"]:
1831
+ console.print(f"[dim]◆ Commands: /exit /clear /mode /status /commit /push[/]")
1832
+ console.print(f"[dim]◆ File paths: Type [bold {theme_config['accent_color']}]/[/dim][dim] to see workspace files (e.g., [/dim][{theme_config['accent_color']}]/gemini_cli.py[/][dim])[/]\n")
1833
+ if mode == "semi-agent":
1834
+ console.print(f"[dim italic]Semi-Agent Mode: I can read files, suggest modifications, and apply changes to your code.[/]\n")
1835
+ elif mode == "image":
1836
+ console.print(f"[dim]◆ Commands: /exit /clear /mode[/]")
1837
+ console.print(f"[dim]◆ Example: [/dim][{theme_config['text_color']}]Create a beautiful sunset over mountains[/]\n")
1838
+ else:
1839
+ console.print(f"[dim]◆ Commands: /exit /clear /mode[/]\n")
1840
+
1841
+ while True:
1842
+ try:
1843
+ # Get input with file path autocomplete - Claude-style
1844
+ console.print()
1845
+ except KeyboardInterrupt:
1846
+ console.print(f"\n\n[bold {theme_config['primary_color']}]◆ Goodbye! ◆[/]\n")
1847
+ break
1848
+
1849
+ try:
1850
+ user_input = await get_text_input_async(f"You")
1851
+ except (EOFError, KeyboardInterrupt):
1852
+ console.print(f"\n\n[bold {theme_config['primary_color']}]◆ Goodbye! ◆[/]\n")
1853
+ break
1854
+
1855
+ try:
1856
+ if not user_input:
1857
+ continue
1858
+
1859
+ # Handle commands
1860
+ if user_input.lower() in ('/exit', '/quit', '/q'):
1861
+ # Check for uncommitted changes before exiting
1862
+ if mode == "semi-agent" and git_preferences['enabled'] and git_preferences['commit_mode'] == 'on_exit':
1863
+ await handle_exit_git_commit(chat)
1864
+
1865
+ console.print(f"\n[bold {theme_config['primary_color']}]◆ Goodbye! ◆[/]\n")
1866
+ break
1867
+
1868
+ elif user_input.lower() == '/mode':
1869
+ console.print(f"\n[bold {theme_config['primary_color']}]◆ Returning to mode selection... ◆[/]\n")
1870
+ return
1871
+
1872
+ elif user_input.lower() == '/clear':
1873
+ clear_screen()
1874
+ print_banner()
1875
+ continue
1876
+
1877
+ elif user_input.lower() == '/help':
1878
+ console.print()
1879
+ console.print(f"[bold {theme_config['primary_color']}]◆ GemCLI Help ◆[/]")
1880
+ console.rule(style=theme_config['border_color'])
1881
+ console.print()
1882
+
1883
+ # Mode capabilities
1884
+ console.print(f"[bold {theme_config['secondary_color']}]MODES & CAPABILITIES[/]")
1885
+ console.print()
1886
+ from rich.table import Table
1887
+ table = Table(show_header=True, header_style=f"bold {theme_config['accent_color']}", box=box.SIMPLE)
1888
+ table.add_column("Mode", style=theme_config['primary_color'], width=15)
1889
+ table.add_column("Ask", justify="center", width=8)
1890
+ table.add_column("Read Files", justify="center", width=12)
1891
+ table.add_column("Edit Files", justify="center", width=12)
1892
+ table.add_column("Search", justify="center", width=10)
1893
+ table.add_column("Auto Mode", justify="center", width=10)
1894
+ table.add_column("Sys Cmds", justify="center", width=10)
1895
+
1896
+ table.add_row("Chat", "✓", "✗", "✗", "✗", "✗", "✗")
1897
+ table.add_row("Semi-Agent", "✓", "✓", "✓", "✗", "✗", "✓")
1898
+ table.add_row("Agent", "✓", "✓", "✓", "✓", "✓", "✓")
1899
+ table.add_row("Image Gen", "✓", "✗", "✗", "✗", "✗", "✗")
1900
+
1901
+ console.print(table)
1902
+ console.print()
1903
+
1904
+ # System Commands info for Semi-Agent and Agent modes
1905
+ if mode in ["semi-agent", "agent"]:
1906
+ console.print(f"[bold {theme_config['secondary_color']}]SYSTEM COMMANDS[/]")
1907
+ console.print()
1908
+ console.print(f" [{theme_config['text_color']}]In this mode, Gemini can execute system commands:[/]")
1909
+ console.print(f" [{theme_config['accent_color']}]•[/] Open/close applications (Chrome, Notepad, etc.)")
1910
+ console.print(f" [{theme_config['accent_color']}]•[/] Adjust brightness and volume")
1911
+ console.print(f" [{theme_config['accent_color']}]•[/] Open file explorer")
1912
+ console.print(f" [{theme_config['accent_color']}]•[/] Control media playback")
1913
+ console.print(f" [{theme_config['accent_color']}]•[/] System shutdown (when explicitly requested)")
1914
+ console.print()
1915
+ console.print(f" [dim]Example: 'lower the brightness' or 'open chrome'[/]")
1916
+ console.print()
1917
+
1918
+ # Commands
1919
+ console.print(f"[bold {theme_config['secondary_color']}]AVAILABLE COMMANDS[/]")
1920
+ console.print()
1921
+ console.print(f" [{theme_config['accent_color']}]/help[/] - Show this help message")
1922
+ console.print(f" [{theme_config['accent_color']}]/exit[/] - Exit the current mode")
1923
+ console.print(f" [{theme_config['accent_color']}]/quit[/] - Same as /exit")
1924
+ console.print(f" [{theme_config['accent_color']}]/clear[/] - Clear the screen")
1925
+ console.print(f" [{theme_config['accent_color']}]/mode[/] - Switch between modes")
1926
+
1927
+ if mode in ["semi-agent", "agent"]:
1928
+ console.print(f" [{theme_config['accent_color']}]/status[/] - Show git repository status")
1929
+ console.print(f" [{theme_config['accent_color']}]/commit[/] - Commit changes with AI-generated message")
1930
+ console.print(f" [{theme_config['accent_color']}]/push[/] - Push commits to remote")
1931
+
1932
+ console.print()
1933
+
1934
+ # Settings
1935
+ console.print(f"[bold {theme_config['secondary_color']}]SETTINGS[/]")
1936
+ console.print()
1937
+ console.print(f" [{theme_config['text_color']}]• Theme customization (6 color schemes)[/]")
1938
+ console.print(f" [{theme_config['text_color']}]• Git integration (auto-commit, push)[/]")
1939
+ console.print(f" [{theme_config['text_color']}]• Diff viewer preferences[/]")
1940
+ console.print(f" [{theme_config['text_color']}]• Image generation settings[/]")
1941
+ console.print()
1942
+ console.print(f"[dim]Access via main menu > Settings[/]")
1943
+ console.print()
1944
+ console.rule(style=theme_config['border_color'])
1945
+ console.print()
1946
+ continue
1947
+
1948
+ # Git commands
1949
+ elif user_input.lower() == '/status':
1950
+ console.print()
1951
+ git_info = get_git_status()
1952
+
1953
+ if not git_info['is_repo']:
1954
+ console.print(f"[{theme_config['warning_color']}]⚠ Not a git repository[/]\n")
1955
+ else:
1956
+ console.print(f"[bold {theme_config['primary_color']}]◆ Git Status[/]")
1957
+ console.print(f"[{theme_config['text_color']}]Branch:[/] [{theme_config['accent_color']}]{git_info['branch']}[/]")
1958
+ console.print()
1959
+
1960
+ if git_info['has_changes']:
1961
+ console.print(git_info['status'])
1962
+ else:
1963
+ console.print(f"[{theme_config['success_color']}]✓ Working tree clean[/]")
1964
+ console.print()
1965
+ continue
1966
+
1967
+ elif user_input.lower() == '/commit':
1968
+ console.print()
1969
+ git_info = get_git_status()
1970
+
1971
+ if not git_info['is_repo']:
1972
+ console.print(f"[{theme_config['warning_color']}]⚠ Not a git repository[/]\n")
1973
+ continue
1974
+
1975
+ if not git_info['has_changes']:
1976
+ console.print(f"[{theme_config['warning_color']}]⚠ No changes to commit[/]\n")
1977
+ continue
1978
+
1979
+ # Generate commit message using AI
1980
+ console.print(f"[bold {theme_config['primary_color']}]◆ Generating commit message...[/]")
1981
+ commit_msg = await generate_commit_message(chat, None)
1982
+
1983
+ if not commit_msg:
1984
+ console.print(f"[{theme_config['warning_color']}]⚠ Failed to generate commit message[/]\n")
1985
+ continue
1986
+
1987
+ console.print()
1988
+ console.print(f"[bold {theme_config['secondary_color']}]◆ Suggested commit message:[/]")
1989
+ console.print(f"[{theme_config['accent_color']}]{commit_msg}[/]")
1990
+ console.print()
1991
+
1992
+ # Ask for confirmation
1993
+ custom_style = Style([
1994
+ ('pointer', f'fg:{theme_config["accent_color"]} bold'),
1995
+ ('highlighted', f'fg:{theme_config["primary_color"]} bold'),
1996
+ ])
1997
+
1998
+ confirm = await questionary.select(
1999
+ "Commit with this message?",
2000
+ choices=['◆ Yes, commit', '◆ No, cancel'],
2001
+ pointer="❯",
2002
+ style=custom_style,
2003
+ qmark="",
2004
+ show_selected=False,
2005
+ use_shortcuts=False
2006
+ ).ask_async()
2007
+
2008
+ if confirm == '◆ Yes, commit':
2009
+ success, output = git_commit(commit_msg)
2010
+ if success:
2011
+ console.print(f"\n[bold {theme_config['success_color']}]✓ Committed successfully[/]")
2012
+ console.print(f"[dim]{output}[/]")
2013
+ else:
2014
+ console.print(f"\n[bold red]✗ Commit failed:[/] {output}")
2015
+ else:
2016
+ console.print(f"\n[{theme_config['warning_color']}]⚠ Commit cancelled[/]")
2017
+
2018
+ console.print()
2019
+ continue
2020
+
2021
+ elif user_input.lower() == '/push':
2022
+ console.print()
2023
+ git_info = get_git_status()
2024
+
2025
+ if not git_info['is_repo']:
2026
+ console.print(f"[{theme_config['warning_color']}]⚠ Not a git repository[/]\n")
2027
+ continue
2028
+
2029
+ console.print(f"[bold {theme_config['primary_color']}]◆ Pushing to remote...[/]")
2030
+ success, output = git_push()
2031
+
2032
+ if success:
2033
+ console.print(f"[bold {theme_config['success_color']}]✓ Pushed successfully[/]")
2034
+ console.print(f"[dim]{output}[/]")
2035
+ else:
2036
+ console.print(f"\n[bold red]✗ Push failed:[/] {output}")
2037
+
2038
+ console.print()
2039
+ continue
2040
+
2041
+ # Handle file paths
2042
+ modified_input = user_input
2043
+ referenced_files = []
2044
+ if mode in ["ask", "semi-agent"]:
2045
+ import re
2046
+ path_pattern = r'/([^\s/][^\s]*)'
2047
+ paths = re.findall(path_pattern, user_input)
2048
+
2049
+ if paths:
2050
+ file_contents = []
2051
+ workspace_root = Path.cwd()
2052
+ for path in paths:
2053
+ try:
2054
+ p = workspace_root / path.replace('/', os.sep)
2055
+
2056
+ # Check if it's a directory
2057
+ if p.exists() and p.is_dir():
2058
+ # Get all files in the directory (non-recursive)
2059
+ dir_files = [f for f in p.iterdir() if f.is_file()]
2060
+ if dir_files:
2061
+ for file_path in dir_files:
2062
+ try:
2063
+ content = file_path.read_text(encoding='utf-8', errors='ignore')
2064
+ relative_path = str(file_path.relative_to(workspace_root)).replace(os.sep, '/')
2065
+ file_contents.append(f"\n--- File: {relative_path} ---\n{content}\n--- End of {relative_path} ---")
2066
+ # Track referenced files for semi-agent mode
2067
+ if mode == "semi-agent":
2068
+ referenced_files.append({'path': relative_path, 'full_path': file_path})
2069
+ except Exception as e:
2070
+ console.print(f"[dim]Could not read {file_path.name}: {e}[/]")
2071
+
2072
+ # Check if it's a file
2073
+ elif p.exists() and p.is_file():
2074
+ content = p.read_text(encoding='utf-8', errors='ignore')
2075
+ file_contents.append(f"\n--- File: {path} ---\n{content}\n--- End of {path} ---")
2076
+ # Track referenced files for semi-agent mode
2077
+ if mode == "semi-agent":
2078
+ referenced_files.append({'path': path, 'full_path': p})
2079
+ except Exception as e:
2080
+ console.print(f"[{theme_config['warning_color']}]⚠ Could not access {path}: {e}[/]")
2081
+
2082
+ if file_contents:
2083
+ modified_input = user_input + "\n\n" + "\n".join(file_contents)
2084
+
2085
+ # Add semi-agent or agent mode instructions
2086
+ if mode == "semi-agent":
2087
+ if referenced_files:
2088
+ # Build list of file paths for the prompt
2089
+ file_paths = [ref['path'] for ref in referenced_files]
2090
+ files_list = ', '.join(file_paths)
2091
+
2092
+ modified_input = f"{modified_input}\n\n[STRICT INSTRUCTION - YOU MUST FOLLOW THIS EXACTLY]\nYou are an AI coding agent. Respond with a JSON object containing file modifications/creations AND/OR text replies AND/OR system commands.\n\n⚠️ CRITICAL SAFETY RULES:\n- NEVER delete files or use delete/rm commands\n- NEVER execute destructive system commands\n- Only modify files that are specified or clearly needed\n- For system commands, only use safe operations (open apps, adjust brightness/volume, etc.)\n\nFormat (EXACTLY like this):\n```json\n{{\n \"reply\": \"your text response if user asked a question (optional)\",\n \"{file_paths[0]}\": \"complete file content here\",\n \"new_file.py\": \"content for new file\",\n \"SysPrmpt\": \"system command to execute (optional)\"\n}}\n```\n\nRules:\n- Respond with ONE code block containing ONLY valid JSON\n- Use \"reply\" key ONLY for conversational responses (e.g., answering 'how are you', explanations)\n- Use file paths as keys for file operations (e.g., \"{file_paths[0]}\" for existing, \"new_file.py\" for new)\n- Use \"SysPrmpt\" key for SYSTEM-LEVEL commands like:\n * Opening applications: \"start chrome\" (Windows) or \"open -a 'Google Chrome'\" (Mac)\n * Opening file explorer: \"explorer .\" (Windows) or \"open .\" (Mac)\n * Adjusting brightness (Windows): \"(Get-WmiObject -Namespace root/WMI -Class WmiMonitorBrightnessMethods).WmiSetBrightness(1,50)\"\n * Adjusting volume: Use system volume controls\n * Launching media players for music\n * System shutdown ONLY when explicitly requested: \"shutdown /s /t 0\" (Windows)\n * Any other SAFE system commands\n- Values are COMPLETE file contents as strings for files, text for \"reply\", or command string for \"SysPrmpt\"\n- Use \\n for newlines in the JSON strings\n- You can include \"reply\", file operations, AND \"SysPrmpt\" in the same response\n- NO explanations before or after the JSON block\n- NO additional text outside the code block\n\nFiles available: {files_list}\n\nExamples:\n- User: \"lower the brightness\" → {{\"SysPrmpt\": \"powershell -Command '(Get-WmiObject -Namespace root/WMI -Class WmiMonitorBrightnessMethods).WmiSetBrightness(1,30)'\", \"reply\": \"Lowering brightness to 30%\"}}\n- User: \"open chrome\" → {{\"SysPrmpt\": \"start chrome\", \"reply\": \"Opening Chrome browser\"}}\n- User: \"open file explorer\" → {{\"SysPrmpt\": \"explorer .\", \"reply\": \"Opening File Explorer\"}}"
2093
+ else:
2094
+ modified_input = f"{modified_input}\n\n[STRICT INSTRUCTION - YOU MUST FOLLOW THIS EXACTLY]\nYou are an AI coding agent. Respond with a JSON object containing file creations AND/OR text replies AND/OR system commands.\n\n⚠️ CRITICAL SAFETY RULES:\n- NEVER delete files or use delete/rm commands\n- NEVER execute destructive system commands\n- Only create/modify files when clearly requested\n- For system commands, only use safe operations (open apps, adjust brightness/volume, etc.)\n\nFormat (EXACTLY like this):\n```json\n{{\n \"reply\": \"your text response if user asked a question (optional)\",\n \"filename.py\": \"complete file content here\",\n \"another_file.py\": \"another complete file content\",\n \"SysPrmpt\": \"system command to execute (optional)\"\n}}\n```\n\nRules:\n- Respond with ONE code block containing ONLY valid JSON\n- Use \"reply\" key ONLY for conversational responses (e.g., answering 'how are you', explanations)\n- Use filenames as keys for file operations (e.g., \"hello.py\", \"script.py\")\n- Use \"SysPrmpt\" key for SYSTEM-LEVEL commands like:\n * Opening applications: \"start chrome\" (Windows) or \"open -a 'Google Chrome'\" (Mac)\n * Opening file explorer: \"explorer .\" (Windows) or \"open .\" (Mac)\n * Adjusting brightness (Windows): \"(Get-WmiObject -Namespace root/WMI -Class WmiMonitorBrightnessMethods).WmiSetBrightness(1,50)\"\n * Adjusting volume: Use system volume controls\n * Launching media players for music\n * System shutdown ONLY when explicitly requested: \"shutdown /s /t 0\" (Windows)\n * Any other SAFE system commands\n- Values are COMPLETE file contents as strings for files, text for \"reply\", or command string for \"SysPrmpt\"\n- Use \\n for newlines in the JSON strings\n- You can include \"reply\", file operations, AND \"SysPrmpt\" in the same response\n- NO explanations before or after the JSON block\n- NO additional text outside the code block\n\nExamples:\n- User: \"lower the brightness\" → {{\"SysPrmpt\": \"powershell -Command '(Get-WmiObject -Namespace root/WMI -Class WmiMonitorBrightnessMethods).WmiSetBrightness(1,30)'\", \"reply\": \"Lowering brightness to 30%\"}}\n- User: \"open chrome\" → {{\"SysPrmpt\": \"start chrome\", \"reply\": \"Opening Chrome browser\"}}\n- User: \"open file explorer\" → {{\"SysPrmpt\": \"explorer .\", \"reply\": \"Opening File Explorer\"}}"
2095
+
2096
+ elif mode == "agent":
2097
+ # Agent mode: Give full autonomy with search/read/modify capabilities
2098
+ modified_input = f"{modified_input}\n\n[STRICT INSTRUCTION - AUTONOMOUS AGENT MODE]\nYou are an autonomous AI coding agent with workspace access. You can search files, read them, make modifications, AND execute system commands.\n\n⚠️ CRITICAL SAFETY RULES:\n- NEVER delete files or use delete/rm commands\n- NEVER execute destructive system commands\n- Only modify files when clearly needed for the task\n- For system commands, only use safe operations (open apps, adjust brightness/volume, etc.)\n\nAvailable commands (respond with JSON in code block):\n\n1. SEARCH workspace:\n```json\n{{\n \"search\": \"keyword|pattern|regex\",\n \"file_pattern\": \"**/*\",\n \"reply\": \"Searching for database functions...\"\n}}\n```\n⚠️ CRITICAL SEARCH RULES:\n- ALWAYS extract keywords from the user's ACTUAL question\n- If user says \"help bob\" → search for \"bob\", NOT generic \"TODO|FIXME\"\n- If user says \"find apples\" → search for \"apples\", NOT \"error|bug\"\n- Use the EXACT entities/names/topics the user mentioned\n- Only use generic patterns (TODO, FIXME, error) if user EXPLICITLY asks for them\n- file_pattern defaults to \"**/*\" (searches ALL file types: .py, .txt, .md, .json, etc.)\n\n2. READ specific files:\n```json\n{{\n \"read\": [\"/path/to/file1.py\", \"/path/to/file2.py\"],\n \"reply\": \"Reading database connection files...\"\n}}\n```\n\n3. MODIFY/CREATE files:\n```json\n{{\n \"reply\": \"Added error handling to 3 files\",\n \"/path/to/file1.py\": \"complete file content\",\n \"/new_file.py\": \"new file content\"\n}}\n```\n\n4. EXECUTE system commands:\n```json\n{{\n \"SysPrmpt\": \"system command to execute\",\n \"reply\": \"Executing your request...\"\n}}\n```\n\nSystem Command Examples:\n- Open Chrome: {{\"SysPrmpt\": \"start chrome\", \"reply\": \"Opening Chrome\"}}\n- Open File Explorer: {{\"SysPrmpt\": \"explorer .\", \"reply\": \"Opening File Explorer\"}}\n- Lower Brightness (Win): {{\"SysPrmpt\": \"powershell -Command '(Get-WmiObject -Namespace root/WMI -Class WmiMonitorBrightnessMethods).WmiSetBrightness(1,30)'\", \"reply\": \"Lowering brightness\"}}\n- Adjust Volume: Use system volume controls\n- Play Music: Launch media player apps\n- System Shutdown (ONLY if explicitly requested): {{\"SysPrmpt\": \"shutdown /s /t 0\", \"reply\": \"Shutting down system\"}}\n\nWorkflow:\n- If you need to find files, use \"search\" command first\n- Extract ACTUAL keywords from user's question for search\n- If you need file contents, use \"read\" command\n- When ready to modify, provide complete file contents\n- For system-level operations (open apps, adjust settings), use \"SysPrmpt\"\n- You can combine commands as needed\n- ONLY search/read if you actually need to - don't search if the task is simple\n\nRules:\n- ONE code block with ONLY valid JSON per response\n- \"reply\" key for status updates (optional)\n- \"SysPrmpt\" key for system commands\n- NO explanations outside the JSON\n- Be efficient - only search/read what you need"
2099
+
2100
+ # Send to Gemini
2101
+ console.print()
2102
+
2103
+ with console.status(f"[bold {theme_config['primary_color']}]◆ Gemini is thinking...[/]", spinner="dots"):
2104
+ response_text, response_images = await send_message(chat, modified_input)
2105
+
2106
+ # Semi-agent or Agent mode: Check for file modifications and extract reply
2107
+ if mode in ["semi-agent", "agent"]:
2108
+ import re
2109
+ import json
2110
+
2111
+ # Look for JSON code block
2112
+ file_modifications = []
2113
+ reply_text = None
2114
+ workspace_root = Path.cwd()
2115
+
2116
+ # Try to find JSON code block
2117
+ json_pattern = r'```json\s*\n(.*?)\n```'
2118
+ json_match = re.search(json_pattern, response_text, re.DOTALL | re.IGNORECASE)
2119
+
2120
+ if json_match:
2121
+ try:
2122
+ json_str = json_match.group(1).strip()
2123
+ response_json = json.loads(json_str)
2124
+
2125
+ # AGENT MODE: Handle search/read commands in a loop
2126
+ if mode == "agent":
2127
+ # Keep processing commands until we get a final reply or file modifications
2128
+ while "search" in response_json or "read" in response_json:
2129
+ # Handle SEARCH command
2130
+ if "search" in response_json:
2131
+ # Extract reply for debugging
2132
+ if "reply" in response_json:
2133
+ reply_text = response_json.get("reply")
2134
+ if reply_text:
2135
+ console.print()
2136
+ console.print(f"[bold {theme_config['secondary_color']}]◆ Gemini[/]")
2137
+ console.print()
2138
+ console.print(f"[dim]{reply_text}[/]")
2139
+
2140
+ search_query = response_json["search"]
2141
+ file_pattern = response_json.get("file_pattern", "**/*")
2142
+
2143
+ console.print()
2144
+ console.print(f"[bold {theme_config['accent_color']}]◆ Searching workspace...[/]")
2145
+ console.print(f"[dim]Query: {search_query}, Pattern: {file_pattern}[/]")
2146
+
2147
+ # Search workspace
2148
+ search_results = search_workspace(search_query, file_pattern)
2149
+
2150
+ if search_results:
2151
+ console.print(f"[{theme_config['success_color']}]✓ Found {len(search_results)} file(s)[/]")
2152
+ console.print()
2153
+
2154
+ # Show results to user
2155
+ for r in search_results:
2156
+ console.print(f" [{theme_config['text_color']}]• {r['path']}[/] [dim]({r['size']}, {r['lines']} lines)[/]")
2157
+
2158
+ # Automatically read all found files
2159
+ file_paths_list = [r['path'] for r in search_results]
2160
+
2161
+ console.print()
2162
+ console.print(f"[bold {theme_config['accent_color']}]◆ Reading {len(file_paths_list)} file(s)...[/]")
2163
+
2164
+ # Read files
2165
+ file_contents_dict = read_workspace_files(file_paths_list)
2166
+
2167
+ # Format contents
2168
+ context = "File contents:\n\n"
2169
+ for path, content in file_contents_dict.items():
2170
+ console.print(f"[dim]✓ Read: {path}[/]")
2171
+ context += f"--- {path} ---\n{content}\n\n"
2172
+
2173
+ # Send contents directly to Gemini - allow iterative search
2174
+ follow_up_prompt = f"{context}\n\n[MANDATORY] Respond with JSON in code block.\n\nYou just read the files above. Now decide:\n\n1. Need MORE info? → {{\"search\": \"keyword\"}} to search again\n2. Have ENOUGH info? → {{\"reply\": \"your answer to user\"}}\n3. Need to UPDATE files? → {{\"filename.txt\": \"content\", \"reply\": \"explanation\"}}\n\nExtract keywords from what you learned. Example: If file mentions 'apple', search for 'apple' or 'apples'.\n\nRespond with ONLY JSON:"
2175
+
2176
+ console.print()
2177
+ with console.status(f"[bold {theme_config['primary_color']}]◆ Gemini is analyzing files...[/]", spinner="dots"):
2178
+ response_text, response_images = await send_message(chat, follow_up_prompt)
2179
+
2180
+ # Process the final response - loop back for more commands
2181
+ json_match = re.search(json_pattern, response_text, re.DOTALL | re.IGNORECASE)
2182
+ if json_match:
2183
+ json_str = json_match.group(1).strip()
2184
+ response_json = json.loads(json_str)
2185
+
2186
+ # Loop back to process search/read/modify commands
2187
+ # The while loop will check if there's another search/read command
2188
+ else:
2189
+ console.print(f"[{theme_config['warning_color']}]⚠ No JSON found in response after reading files[/]")
2190
+ console.print(f"[dim]Full response: {response_text}[/]")
2191
+ console.print()
2192
+ break # Exit if no JSON
2193
+ else:
2194
+ console.print(f"[{theme_config['warning_color']}]⚠ No files found matching query[/]")
2195
+ console.print()
2196
+ break # Exit the command loop
2197
+
2198
+ # Handle READ command
2199
+ elif "read" in response_json:
2200
+ files_to_read = response_json["read"]
2201
+
2202
+ console.print()
2203
+ console.print(f"[bold {theme_config['accent_color']}]◆ Reading {len(files_to_read)} file(s)...[/]")
2204
+
2205
+ # Read files
2206
+ file_contents_dict = read_workspace_files(files_to_read)
2207
+
2208
+ # Format contents
2209
+ context = "File contents:\n\n"
2210
+ for path, content in file_contents_dict.items():
2211
+ console.print(f"[dim]✓ Read: {path}[/]")
2212
+ context += f"--- {path} ---\n{content}\n\n"
2213
+
2214
+ # Send contents back to Gemini - force JSON response
2215
+ follow_up_prompt = f"{context}\n\n[MANDATORY] You can ONLY respond with JSON in a code block. NO other text allowed.\n\nJSON Format Rules:\n```json\n{{\n \"search\": \"keyword\" → Search workspace for this keyword, we return filenames\n \"reply\": \"text\" → Your text response to user\n \"filename.txt\": \"content\" → Create or modify this file with content\n}}\n```\n\nWorkflow:\n1. If you need MORE info → send {{\"search\": \"keyword\"}}\n2. We find files → return filenames to you → you can search AGAIN if needed\n3. When you have ALL info → send {{\"reply\": \"final answer\"}} or {{\"filename\": \"content\"}}\n\nRespond NOW with ONLY JSON:"
2216
+
2217
+ console.print()
2218
+ with console.status(f"[bold {theme_config['primary_color']}]◆ Gemini is deciding next action...[/]", spinner="dots"):
2219
+ response_text, response_images = await send_message(chat, follow_up_prompt)
2220
+
2221
+ # Process the new response - loop back to check for more commands
2222
+ json_match = re.search(json_pattern, response_text, re.DOTALL | re.IGNORECASE)
2223
+ if json_match:
2224
+ json_str = json_match.group(1).strip()
2225
+ response_json = json.loads(json_str)
2226
+
2227
+ # Continue the while loop to process any new search/read/modify commands
2228
+ else:
2229
+ console.print(f"[{theme_config['warning_color']}]⚠ No JSON after read command[/]")
2230
+ break # Exit command loop
2231
+
2232
+ # If we reach here, no more search/read commands to process
2233
+ if "reply" in response_json:
2234
+ reply_text = response_json.pop("reply")
2235
+ else:
2236
+ # Semi-agent mode - extract reply normally
2237
+ if "reply" in response_json:
2238
+ reply_text = response_json.pop("reply")
2239
+
2240
+ # Check for system command execution
2241
+ system_command = None
2242
+ if "SysPrmpt" in response_json:
2243
+ system_command = response_json.pop("SysPrmpt")
2244
+
2245
+ # Process file modifications (works for both semi-agent and agent)
2246
+ for file_path, new_content in response_json.items():
2247
+ # Skip non-file keys
2248
+ if file_path in ["search", "read", "file_pattern"]:
2249
+ continue
2250
+
2251
+ # Normalize the path (remove leading slash if present)
2252
+ normalized_path = file_path.lstrip('/')
2253
+
2254
+ # Check if it's an existing referenced file (semi-agent mode)
2255
+ matched = False
2256
+ if mode == "semi-agent":
2257
+ for ref_file in referenced_files:
2258
+ ref_path = ref_file['path'].lstrip('/')
2259
+ if ref_path == normalized_path or ref_file['path'] == file_path:
2260
+ file_modifications.append({
2261
+ 'path': ref_file['path'],
2262
+ 'full_path': ref_file['full_path'],
2263
+ 'new_content': new_content,
2264
+ 'is_new': False
2265
+ })
2266
+ matched = True
2267
+ break
2268
+
2269
+ # For agent mode or unmatched files, treat as new/existing file
2270
+ if not matched:
2271
+ file_full_path = workspace_root / normalized_path
2272
+ is_new = not file_full_path.exists()
2273
+ file_modifications.append({
2274
+ 'path': normalized_path,
2275
+ 'full_path': file_full_path,
2276
+ 'new_content': new_content,
2277
+ 'is_new': is_new
2278
+ })
2279
+ except json.JSONDecodeError as e:
2280
+ console.print(f"\\n[{theme_config['warning_color']}]⚠ Failed to parse JSON response: {e}[/]")
2281
+
2282
+ # Display response - either reply text or full response
2283
+ console.print()
2284
+ console.print(f"[bold {theme_config['secondary_color']}]◆ Gemini[/]")
2285
+ console.print()
2286
+
2287
+ if reply_text:
2288
+ # Display extracted reply text
2289
+ console.print(Markdown(reply_text))
2290
+ elif not file_modifications:
2291
+ # No reply key and no file modifications - show full response
2292
+ console.print(Markdown(response_text))
2293
+ # If only file modifications without reply, skip displaying full response
2294
+
2295
+ # Execute system command if present
2296
+ if 'system_command' in locals() and system_command:
2297
+ console.print()
2298
+ console.print(f"[bold {theme_config['accent_color']}]◆ Executing System Command[/]")
2299
+ console.print(f"[dim]Command: {system_command}[/]")
2300
+ console.print()
2301
+
2302
+ # Execute the command
2303
+ success, output = execute_system_command(system_command)
2304
+
2305
+ if success:
2306
+ console.print(f"[bold {theme_config['success_color']}]✓ Command executed successfully[/]")
2307
+ if output:
2308
+ console.print(f"[dim]{output}[/]")
2309
+ else:
2310
+ console.print(f"[bold red]✗ Command failed[/]")
2311
+ console.print(f"[dim]{output}[/]")
2312
+ console.print()
2313
+
2314
+ # If modifications were found, show diffs and ask to apply them
2315
+ if file_modifications:
2316
+ console.print()
2317
+ console.print(f"[bold {theme_config['accent_color']}]◆ File Modifications Detected[/]")
2318
+ console.print(f"[dim]Found {len(file_modifications)} file(s) to modify:[/]")
2319
+ for mod in file_modifications:
2320
+ status = "NEW" if mod['is_new'] else "MODIFIED"
2321
+ console.print(f" [{theme_config['primary_color']}]•[/] {mod['path']} [{theme_config['success_color'] if mod['is_new'] else theme_config['accent_color']}]({status})[/]")
2322
+ console.print()
2323
+
2324
+ # Open VS Code diff editors for all files
2325
+ temp_files = []
2326
+ for mod in file_modifications:
2327
+ tmp_path = open_vscode_diff(
2328
+ mod['full_path'],
2329
+ mod['new_content'],
2330
+ mod['path'],
2331
+ mod['is_new']
2332
+ )
2333
+ if tmp_path:
2334
+ temp_files.append(tmp_path)
2335
+
2336
+ # Create custom style for confirmation
2337
+ custom_style = Style([
2338
+ ('pointer', f'fg:{theme_config["accent_color"]} bold'),
2339
+ ('highlighted', f'fg:{theme_config["primary_color"]} bold'),
2340
+ ('question', f'fg:{theme_config["secondary_color"]} bold'),
2341
+ ('instruction', 'fg:#555555'),
2342
+ ])
2343
+
2344
+ apply_changes = await questionary.select(
2345
+ "Apply these changes to the files?",
2346
+ choices=["◆ Yes, apply changes", "◆ No, skip"],
2347
+ pointer="❯",
2348
+ style=custom_style,
2349
+ qmark="",
2350
+ instruction="(Use arrow keys)",
2351
+ show_selected=False,
2352
+ use_shortcuts=False
2353
+ ).ask_async()
2354
+
2355
+ # Clean up temp files
2356
+ for tmp_file in temp_files:
2357
+ try:
2358
+ os.unlink(tmp_file)
2359
+ except:
2360
+ pass
2361
+
2362
+ if apply_changes == "◆ Yes, apply changes":
2363
+ modified_files = []
2364
+ for mod in file_modifications:
2365
+ try:
2366
+ # Create parent directories if needed for new files
2367
+ if mod['is_new']:
2368
+ mod['full_path'].parent.mkdir(parents=True, exist_ok=True)
2369
+
2370
+ # Write new content
2371
+ mod['full_path'].write_text(mod['new_content'], encoding='utf-8')
2372
+
2373
+ action = "Created" if mod['is_new'] else "Modified"
2374
+ console.print(f"[bold {theme_config['success_color']}]✓ {action}: {mod['path']}[/]")
2375
+ modified_files.append(mod['path'])
2376
+ except Exception as e:
2377
+ console.print(f"[bold red]✗[/] Failed to {'create' if mod['is_new'] else 'modify'} {mod['path']}: {e}")
2378
+ console.print()
2379
+
2380
+ # Offer to commit changes based on preferences
2381
+ if git_preferences['enabled'] and git_preferences['commit_mode'] == 'immediate':
2382
+ git_info = get_git_status()
2383
+ if git_info['is_repo'] and git_info['has_changes']:
2384
+ console.print(f"[bold {theme_config['primary_color']}]◆ Would you like to commit these changes?[/]\n")
2385
+
2386
+ custom_style = Style([
2387
+ ('pointer', f'fg:{theme_config["accent_color"]} bold'),
2388
+ ('highlighted', f'fg:{theme_config["primary_color"]} bold'),
2389
+ ])
2390
+
2391
+ commit_choice = await questionary.select(
2392
+ "",
2393
+ choices=['◆ Yes, generate commit message', '◆ No, skip'],
2394
+ pointer="❯",
2395
+ style=custom_style,
2396
+ qmark="",
2397
+ show_selected=False,
2398
+ use_shortcuts=False
2399
+ ).ask_async()
2400
+
2401
+ if commit_choice == '◆ Yes, generate commit message':
2402
+ console.print()
2403
+ console.print(f"[bold {theme_config['primary_color']}]◆ Generating commit message...[/]")
2404
+ commit_msg = await generate_commit_message(chat, modified_files)
2405
+
2406
+ if commit_msg:
2407
+ console.print()
2408
+ console.print(f"[bold {theme_config['secondary_color']}]◆ Suggested commit message:[/]")
2409
+ console.print(f"[{theme_config['accent_color']}]{commit_msg}[/]")
2410
+ console.print()
2411
+
2412
+ confirm = await questionary.select(
2413
+ "Commit with this message?",
2414
+ choices=['◆ Yes, commit', '◆ No, cancel'],
2415
+ pointer="❯",
2416
+ style=custom_style,
2417
+ qmark="",
2418
+ show_selected=False,
2419
+ use_shortcuts=False
2420
+ ).ask_async()
2421
+
2422
+ if confirm == '◆ Yes, commit':
2423
+ success, output = git_commit(commit_msg, modified_files)
2424
+ if success:
2425
+ console.print(f"\n[bold {theme_config['success_color']}]✓ Committed successfully[/]")
2426
+ console.print(f"[dim]{output}[/]")
2427
+
2428
+ # Auto-push if enabled
2429
+ if git_preferences['auto_push']:
2430
+ console.print(f"[bold {theme_config['primary_color']}]◆ Pushing to remote...[/]")
2431
+ push_success, push_output = git_push()
2432
+ if push_success:
2433
+ console.print(f"[bold {theme_config['success_color']}]✓ Pushed successfully[/]")
2434
+ else:
2435
+ console.print(f"[bold red]✗ Push failed:[/] {push_output}")
2436
+ console.print()
2437
+ else:
2438
+ console.print(f"\n[bold red]✗ Commit failed:[/] {output}\n")
2439
+ else:
2440
+ console.print(f"\n[{theme_config['warning_color']}]⚠ Commit cancelled[/]\n")
2441
+ else:
2442
+ console.print(f"\n[{theme_config['warning_color']}]⚠ Failed to generate commit message[/]\n")
2443
+ else:
2444
+ console.print(f"[{theme_config['warning_color']}]⚠ Changes not applied[/]\n")
2445
+ else:
2446
+ # Not in semi-agent mode - display response normally
2447
+ console.print()
2448
+ console.print(f"[bold {theme_config['secondary_color']}]◆ Gemini[/]")
2449
+ console.print()
2450
+ console.print(Markdown(response_text))
2451
+
2452
+ # Display images if any
2453
+ if response_images:
2454
+ images_dir = image_settings['save_path']
2455
+ images_dir.mkdir(exist_ok=True)
2456
+
2457
+ for idx, img_data in enumerate(response_images, 1):
2458
+ try:
2459
+ # Generate unique filename
2460
+ import time
2461
+ img_filename = f'image_{int(time.time())}_{idx}.png'
2462
+ img_path = images_dir / img_filename
2463
+
2464
+ # Download from URL with cookies (this method works)
2465
+ if hasattr(img_data, 'url'):
2466
+ import requests
2467
+ url = img_data.url
2468
+ cookies = img_data.cookies if hasattr(img_data, 'cookies') else {}
2469
+
2470
+ resp = requests.get(url, cookies=cookies, stream=True)
2471
+ resp.raise_for_status()
2472
+
2473
+ img_path.write_bytes(resp.content)
2474
+ img_abs_path = img_path.absolute()
2475
+
2476
+ # Open image in VS Code/IDE viewer
2477
+ console.print(f"[{theme_config['accent_color']}]✓ Saved:[/] {img_abs_path}\n")
2478
+ webbrowser.open(str(img_abs_path))
2479
+
2480
+ except Exception as e:
2481
+ console.print(f"[red]Failed to process image {idx}: {e}[/]\n")
2482
+
2483
+ console.print() # Just add some spacing
2484
+
2485
+ except KeyboardInterrupt:
2486
+ # User pressed Ctrl+C in the main loop - handle git commits for semi-agent/agent
2487
+ if mode in ["semi-agent", "agent"] and git_preferences['enabled'] and git_preferences['commit_mode'] == 'on_exit':
2488
+ await handle_exit_git_commit(chat)
2489
+ console.print(f"\n\n[bold {theme_config['primary_color']}]◆ Goodbye! ◆[/]\n")
2490
+ break
2491
+
2492
+ except Exception as e:
2493
+ # Catch any other errors but don't exit
2494
+ import traceback
2495
+ console.print(f"\n[{theme_config['warning_color']}]⚠ Unexpected Error: {e}[/]")
2496
+ console.print(f"[dim]Traceback:\n{traceback.format_exc()}[/]")
2497
+ continue
2498
+
2499
+
2500
+ async def customize_theme():
2501
+ """Allow user to customize theme colors."""
2502
+ clear_screen()
2503
+ print_banner()
2504
+
2505
+ console.print(f"\n[bold {theme_config['primary_color']}]Theme Customization[/]")
2506
+ console.rule(style=theme_config['border_color'])
2507
+
2508
+ # Vibrant color options
2509
+ colors = [
2510
+ ('#FFFFFF', '◆ White (Default)'),
2511
+ ('#00FFD4', '◆ Cyan/Turquoise'),
2512
+ ('#FF6B9D', '◆ Vibrant Pink'),
2513
+ ('#FFD700', '◆ Gold'),
2514
+ ('#00FF88', '◆ Neon Green'),
2515
+ ('#9D4EDD', '◆ Purple'),
2516
+ ('#FF006E', '◆ Hot Pink')
2517
+ ]
2518
+
2519
+ console.print()
2520
+ console.print(f"[#FFFFFF]◆ White[/] [#00FFD4]◆ Cyan[/] [#FF6B9D]◆ Pink[/] [#FFD700]◆ Gold[/] [#00FF88]◆ Green[/] [#9D4EDD]◆ Purple[/] [#FF006E]◆ Hot Pink[/]")
2521
+ console.print()
2522
+
2523
+ # Create custom style
2524
+ custom_style = Style([
2525
+ ('pointer', f'fg:{theme_config["accent_color"]} bold'),
2526
+ ('highlighted', f'fg:{theme_config["primary_color"]} bold'),
2527
+ ('question', f'fg:{theme_config["secondary_color"]} bold'),
2528
+ ('instruction', 'fg:#555555'), # Dim gray for instruction text
2529
+ ])
2530
+
2531
+ choice_list = [name for _, name in colors]
2532
+
2533
+ selected_name = await questionary.select(
2534
+ "Select Theme Color:",
2535
+ choices=choice_list,
2536
+ pointer="❯",
2537
+ style=custom_style,
2538
+ qmark="",
2539
+ instruction="(Use arrow keys)",
2540
+ show_selected=False,
2541
+ use_shortcuts=False
2542
+ ).ask_async()
2543
+
2544
+ if selected_name:
2545
+ for color_code, color_name in colors:
2546
+ if color_name == selected_name:
2547
+ theme_config['primary_color'] = color_code
2548
+ theme_config['accent_color'] = color_code
2549
+ break
2550
+
2551
+
2552
+ # =============================================================================
2553
+ # Main
2554
+ # =============================================================================
2555
+
2556
+ async def main():
2557
+ """Main entry point."""
2558
+
2559
+ print_banner()
2560
+
2561
+ # Create custom style with vibrant colors
2562
+ custom_style = Style([
2563
+ ('pointer', f'fg:{theme_config["accent_color"]} bold'),
2564
+ ('highlighted', f'fg:{theme_config["primary_color"]} bold'),
2565
+ ('question', f'fg:{theme_config["secondary_color"]} bold'),
2566
+ ('selected', f'fg:{theme_config["accent_color"]} bold'),
2567
+ ('instruction', 'fg:#555555'), # Dim gray for instruction text
2568
+ ])
2569
+
2570
+ # Initial menu with diamond indicators
2571
+ console.print()
2572
+ choice = await questionary.select(
2573
+ "",
2574
+ choices=["◆ Connect to Gemini", "◆ Customize Theme", "◆ Settings", "◆ Exit"],
2575
+ pointer="❯",
2576
+ style=custom_style,
2577
+ qmark="",
2578
+ instruction="Choose an option (Use arrow keys)",
2579
+ show_selected=False,
2580
+ use_shortcuts=False
2581
+ ).ask_async()
2582
+
2583
+ if choice == "◆ Exit":
2584
+ console.print(f"\n[bold {theme_config['primary_color']}]◆ Goodbye! ◆[/]\n")
2585
+ return
2586
+ elif choice == "◆ Customize Theme":
2587
+ await customize_theme()
2588
+ # Restart from beginning with updated theme
2589
+ await main()
2590
+ return
2591
+ elif choice == "◆ Settings":
2592
+ await settings_menu()
2593
+ # Restart from beginning
2594
+ await main()
2595
+ return
2596
+
2597
+ # Safety message after choosing to connect
2598
+ clear_screen()
2599
+ print_banner()
2600
+
2601
+ # Display big safety message with ASCII art and gradient
2602
+ safety_lines = [
2603
+ "███████╗ █████╗ ███████╗███████╗",
2604
+ "██╔════╝██╔══██╗██╔════╝██╔════╝",
2605
+ "███████╗███████║█████╗ █████╗ ",
2606
+ "╚════██║██╔══██║██╔══╝ ██╔══╝ ",
2607
+ "███████║██║ ██║██║ ███████╗",
2608
+ "╚══════╝╚═╝ ╚═╝╚═╝ ╚══════╝"
2609
+ ]
2610
+
2611
+ # Gradient colors from white to black
2612
+ safety_gradient = ['#FFFFFF', '#DDDDDD', '#BBBBBB', '#999999', '#777777', '#555555']
2613
+
2614
+ console.print()
2615
+ for i, line in enumerate(safety_lines):
2616
+ console.print(line, style=f"bold {safety_gradient[i]}", justify="center")
2617
+
2618
+ console.print()
2619
+ from rich.text import Text
2620
+ line1 = Text("This is a CLIENT-SIDE application with NO SERVER.", style=theme_config['text_color'])
2621
+ line2 = Text("Your data stays on YOUR machine. 100% Private & Secure.", style=theme_config['text_color'])
2622
+ console.print(line1, justify="center")
2623
+ console.print(line2, justify="center")
2624
+ console.print()
2625
+
2626
+ # Check for saved cookies
2627
+ saved_psid, saved_psidts = load_cookies()
2628
+
2629
+ if saved_psid:
2630
+ console.print(f"\n[bold {theme_config['primary_color']}]Found saved cookies[/]", justify="center")
2631
+ console.print()
2632
+
2633
+ use_saved = await questionary.select(
2634
+ "Use saved cookies?",
2635
+ choices=["◆ Yes", "◆ No"],
2636
+ pointer="❯",
2637
+ style=custom_style,
2638
+ qmark="",
2639
+ instruction="(Use arrow keys)",
2640
+ show_selected=False,
2641
+ use_shortcuts=False
2642
+ ).ask_async()
2643
+
2644
+ if use_saved == "◆ Yes":
2645
+ psid, psidts = saved_psid, saved_psidts
2646
+ console.print(f"\n[bold {theme_config['success_color']}]✓ Using saved cookies[/]\n")
2647
+ else:
2648
+ psid, psidts = get_cookies()
2649
+ if psid:
2650
+ save_cookies(psid, psidts)
2651
+ else:
2652
+ psid, psidts = get_cookies()
2653
+ if psid:
2654
+ save_cookies(psid, psidts)
2655
+
2656
+ if not psid:
2657
+ return
2658
+
2659
+ # Initialize client
2660
+ client = await initialize_client(psid, psidts)
2661
+
2662
+ if not client:
2663
+ console.print(f"\n[bold red]Failed to connect to Gemini[/]")
2664
+ return
2665
+
2666
+ # Mode selection loop
2667
+ while True:
2668
+ clear_screen()
2669
+ print_banner()
2670
+
2671
+ console.print()
2672
+ mode_choice = await questionary.select(
2673
+ "Select mode:",
2674
+ choices=["◆ Ask", "◆ Semi-Agent", "◆ Agent (Autonomous)", "◆ Generate Images", "◆ Exit"],
2675
+ pointer="❯",
2676
+ style=custom_style,
2677
+ qmark="",
2678
+ instruction="Choose a mode (Use arrow keys)",
2679
+ show_selected=False,
2680
+ use_shortcuts=False
2681
+ ).ask_async()
2682
+
2683
+ if mode_choice == "◆ Exit":
2684
+ console.print(f"\n[bold {theme_config['primary_color']}]◆ Goodbye! ◆[/]\n")
2685
+ break
2686
+
2687
+ # Map choice to mode
2688
+ mode_map = {
2689
+ "◆ Ask": "ask",
2690
+ "◆ Semi-Agent": "semi-agent",
2691
+ "◆ Agent (Autonomous)": "agent",
2692
+ "◆ Generate Images": "image"
2693
+ }
2694
+ mode = mode_map.get(mode_choice, "ask")
2695
+
2696
+ # Start chat in selected mode
2697
+ clear_screen()
2698
+ print_banner()
2699
+ await chat_loop(client, mode)
2700
+
2701
+ try:
2702
+ await client.close()
2703
+ except:
2704
+ pass
2705
+
2706
+
2707
+ def run_cli():
2708
+ """Entry point for the CLI tool."""
2709
+ try:
2710
+ asyncio.run(main())
2711
+ except KeyboardInterrupt:
2712
+ console.print(f"\n[bold {theme_config['primary_color']}]◆ Goodbye! ◆[/]")
2713
+ sys.exit(0)
2714
+
2715
+
2716
+ if __name__ == "__main__":
2717
+ run_cli()