kopipasta 0.27.0__py3-none-any.whl → 0.29.0__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 kopipasta might be problematic. Click here for more details.

kopipasta/main.py CHANGED
@@ -1,16 +1,12 @@
1
1
  #!/usr/bin/env python3
2
- import csv
3
- import io
4
- import json
5
2
  import os
6
3
  import argparse
7
- import sys
8
4
  import re
9
5
  import subprocess
10
6
  import tempfile
7
+ import shutil
11
8
  from typing import Dict, List, Optional, Set, Tuple
12
9
  import pyperclip
13
- import fnmatch
14
10
  from pygments import highlight
15
11
  from pygments.lexers import get_lexer_for_filename, TextLexer
16
12
  from pygments.formatters import TerminalFormatter
@@ -18,104 +14,10 @@ import pygments.util
18
14
 
19
15
  import requests
20
16
 
21
- from pydantic import BaseModel, Field
22
- import traceback
23
- from google import genai
24
- from google.genai.types import GenerateContentConfig
25
- from prompt_toolkit import prompt # Added for multiline input
26
-
17
+ from kopipasta.file import FileTuple, get_human_readable_size, is_binary, is_ignored, is_large_file, read_file_contents
27
18
  import kopipasta.import_parser as import_parser
28
-
29
- FileTuple = Tuple[str, bool, Optional[List[str]], str]
30
-
31
- class SimplePatchItem(BaseModel):
32
- """A single change described by reasoning, file path, original text, and new text."""
33
- reasoning: str = Field(..., description="Explanation for why this specific change is proposed.")
34
- file_path: str = Field(..., description="Relative path to the file to be modified.")
35
- original_text: str = Field(..., description="The exact, unique block of text to be replaced.")
36
- new_text: str = Field(..., description="The text to replace the original_text with.")
37
-
38
- class SimplePatchArgs(BaseModel):
39
- """A list of proposed code changes."""
40
- patches: List[SimplePatchItem] = Field(..., description="A list of patches to apply.")
41
-
42
- def apply_simple_patch(patch_item: SimplePatchItem) -> bool:
43
- """
44
- Applies a single patch defined by replacing original_text with new_text in file_path.
45
-
46
- Validates that the file exists and the original_text is unique.
47
- """
48
- print(f"\nApplying patch to: {patch_item.file_path}")
49
- print(f"Reasoning: {patch_item.reasoning}")
50
- print("-" * 20)
51
-
52
- file_path = patch_item.file_path
53
- original_text = patch_item.original_text
54
- new_text = patch_item.new_text
55
-
56
- # --- Validation ---
57
- if not os.path.exists(file_path):
58
- print(f"❌ Error: File not found: {file_path}")
59
- print("-" * 20)
60
- return False
61
-
62
- try:
63
- # Read the file content, attempting to preserve line endings implicitly
64
- with open(file_path, 'r', encoding='utf-8', newline='') as f:
65
- content = f.read()
66
-
67
- # Check for unique occurrence of original_text
68
- occurrences = content.count(original_text)
69
- if occurrences == 0:
70
- print(f"❌ Error: Original text not found in {file_path}.")
71
- # Optional: print a snippet of the expected text for debugging
72
- # print(f" Expected original text snippet: '{original_text[:100]}...'")
73
- print("-" * 20)
74
- return False
75
- elif occurrences > 1:
76
- print(f"❌ Error: Original text is not unique in {file_path} (found {occurrences} times).")
77
- print(f" Patch cannot be applied automatically due to ambiguity.")
78
- # print(f" Ambiguous original text snippet: '{original_text[:100]}...'")
79
- print("-" * 20)
80
- return False
81
-
82
- # --- Application ---
83
- # Replace the single unique occurrence
84
- new_content = content.replace(original_text, new_text, 1)
85
-
86
- # Heuristic to check if a newline might be needed at the end
87
- original_ends_with_newline = content.endswith(('\n', '\r'))
88
- new_ends_with_newline = new_content.endswith(('\n', '\r'))
89
-
90
- if original_ends_with_newline and not new_ends_with_newline and new_content:
91
- # Try to determine the original newline type
92
- if content.endswith('\r\n'):
93
- new_content += '\r\n'
94
- else: # Assume '\n' otherwise
95
- new_content += '\n'
96
- elif not original_ends_with_newline and new_ends_with_newline:
97
- # If original didn't end with newline, remove the one added by replacement
98
- # This is less common but possible if new_text ends with \n and original_text didn't
99
- new_content = new_content.rstrip('\r\n')
100
-
101
-
102
- # Write the modified content back
103
- with open(file_path, 'w', encoding='utf-8', newline='') as f:
104
- f.write(new_content)
105
-
106
- print(f"✅ Patch applied successfully to {file_path}.")
107
- print("-" * 20)
108
- return True
109
-
110
- except IOError as e:
111
- print(f"❌ Error reading or writing file {file_path}: {e}")
112
- print("-" * 20)
113
- return False
114
- except Exception as e:
115
- print(f"❌ An unexpected error occurred during patch application: {e}")
116
- traceback.print_exc()
117
- print("-" * 20)
118
- return False
19
+ from kopipasta.tree_selector import TreeSelector
20
+ from kopipasta.prompt import generate_prompt_template, get_file_snippet, get_language_for_file
119
21
 
120
22
  def _propose_and_add_dependencies(
121
23
  file_just_added: str,
@@ -130,7 +32,7 @@ def _propose_and_add_dependencies(
130
32
  if language not in ['python', 'typescript', 'javascript', 'tsx', 'jsx']:
131
33
  return [], 0 # Only analyze languages we can parse
132
34
 
133
- print(f"Analyzing {get_relative_path(file_just_added)} for local dependencies...")
35
+ print(f"Analyzing {os.path.relpath(file_just_added)} for local dependencies...")
134
36
 
135
37
  try:
136
38
  file_content = read_file_contents(file_just_added)
@@ -156,7 +58,7 @@ def _propose_and_add_dependencies(
156
58
 
157
59
  print(f"\nFound {len(suggested_deps)} new local {'dependency' if len(suggested_deps) == 1 else 'dependencies'}:")
158
60
  for i, dep_path in enumerate(suggested_deps):
159
- print(f" ({i+1}) {get_relative_path(dep_path)}")
61
+ print(f" ({i+1}) {os.path.relpath(dep_path)}")
160
62
 
161
63
  while True:
162
64
  choice = input("\nAdd dependencies? (a)ll, (n)one, or enter numbers (e.g. 1, 3-4): ").lower()
@@ -212,12 +114,12 @@ def _propose_and_add_dependencies(
212
114
  file_size = os.path.getsize(dep_path)
213
115
  newly_added_files.append((dep_path, False, None, get_language_for_file(dep_path)))
214
116
  char_count_delta += file_size
215
- print(f"Added dependency: {get_relative_path(dep_path)} ({get_human_readable_size(file_size)})")
117
+ print(f"Added dependency: {os.path.relpath(dep_path)} ({get_human_readable_size(file_size)})")
216
118
 
217
119
  return newly_added_files, char_count_delta
218
120
 
219
121
  except Exception as e:
220
- print(f"Warning: Could not analyze dependencies for {get_relative_path(file_just_added)}: {e}")
122
+ print(f"Warning: Could not analyze dependencies for {os.path.relpath(file_just_added)}: {e}")
221
123
  return [], 0
222
124
 
223
125
  def get_colored_code(file_path, code):
@@ -252,85 +154,6 @@ def read_gitignore():
252
154
  gitignore_patterns.append(line)
253
155
  return gitignore_patterns
254
156
 
255
- def is_ignored(path, ignore_patterns):
256
- path = os.path.normpath(path)
257
- for pattern in ignore_patterns:
258
- if fnmatch.fnmatch(os.path.basename(path), pattern) or fnmatch.fnmatch(path, pattern):
259
- return True
260
- return False
261
-
262
- def is_binary(file_path):
263
- try:
264
- with open(file_path, 'rb') as file:
265
- chunk = file.read(1024)
266
- if b'\0' in chunk: # null bytes indicate binary file
267
- return True
268
- if file_path.lower().endswith(('.json', '.csv')):
269
- return False
270
- return False
271
- except IOError:
272
- return False
273
-
274
- def get_human_readable_size(size):
275
- for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
276
- if size < 1024.0:
277
- return f"{size:.2f} {unit}"
278
- size /= 1024.0
279
-
280
- def is_large_file(file_path, threshold=102400): # 100 KB threshold
281
- return os.path.getsize(file_path) > threshold
282
-
283
- def get_project_structure(ignore_patterns):
284
- tree = []
285
- for root, dirs, files in os.walk('.'):
286
- dirs[:] = [d for d in dirs if not is_ignored(os.path.join(root, d), ignore_patterns)]
287
- files = [f for f in files if not is_ignored(os.path.join(root, f), ignore_patterns)]
288
- level = root.replace('.', '').count(os.sep)
289
- indent = ' ' * 4 * level + '|-- '
290
- tree.append(f"{indent}{os.path.basename(root)}/")
291
- subindent = ' ' * 4 * (level + 1) + '|-- '
292
- for f in files:
293
- tree.append(f"{subindent}{f}")
294
- return '\n'.join(tree)
295
-
296
- def read_file_contents(file_path):
297
- try:
298
- with open(file_path, 'r') as file:
299
- return file.read()
300
- except Exception as e:
301
- print(f"Error reading {file_path}: {e}")
302
- return ""
303
-
304
- def get_relative_path(file_path):
305
- return os.path.relpath(file_path)
306
-
307
- def get_language_for_file(file_path):
308
- extension = os.path.splitext(file_path)[1].lower()
309
- language_map = {
310
- '.py': 'python',
311
- '.js': 'javascript',
312
- '.jsx': 'jsx',
313
- '.ts': 'typescript',
314
- '.tsx': 'tsx',
315
- '.html': 'html',
316
- '.htm': 'html',
317
- '.css': 'css',
318
- '.json': 'json',
319
- '.md': 'markdown',
320
- '.sql': 'sql',
321
- '.sh': 'bash',
322
- '.yml': 'yaml',
323
- '.yaml': 'yaml',
324
- '.go': 'go',
325
- '.toml': 'toml',
326
- '.c': 'c',
327
- '.cpp': 'cpp',
328
- '.cc': 'cpp',
329
- '.h': 'cpp',
330
- '.hpp': 'cpp',
331
- }
332
- return language_map.get(extension, '')
333
-
334
157
  def split_python_file(file_content):
335
158
  """
336
159
  Splits Python code into logical chunks using the AST module.
@@ -626,17 +449,6 @@ def get_placeholder_comment(language):
626
449
  }
627
450
  return comments.get(language, comments['default'])
628
451
 
629
- def get_file_snippet(file_path, max_lines=50, max_bytes=4096):
630
- snippet = ""
631
- byte_count = 0
632
- with open(file_path, 'r') as file:
633
- for i, line in enumerate(file):
634
- if i >= max_lines or byte_count >= max_bytes:
635
- break
636
- snippet += line
637
- byte_count += len(line.encode('utf-8'))
638
- return snippet
639
-
640
452
  def get_colored_file_snippet(file_path, max_lines=50, max_bytes=4096):
641
453
  snippet = get_file_snippet(file_path, max_lines, max_bytes)
642
454
  return get_colored_code(file_path, snippet)
@@ -645,7 +457,211 @@ def print_char_count(count):
645
457
  token_estimate = count // 4
646
458
  print(f"\rCurrent prompt size: {count} characters (~ {token_estimate} tokens)", flush=True)
647
459
 
648
- def select_files_in_directory(directory: str, ignore_patterns: List[str], project_root_abs: str, current_char_count: int = 0) -> Tuple[List[FileTuple], int]:
460
+ def grep_files_in_directory(pattern: str, directory: str, ignore_patterns: List[str]) -> List[Tuple[str, List[str], int]]:
461
+ """
462
+ Search for files containing a pattern using ag (silver searcher).
463
+ Returns list of (filepath, preview_lines, match_count).
464
+ """
465
+ # Check if ag is available
466
+ if not shutil.which('ag'):
467
+ print("Silver Searcher (ag) not found. Install it for grep functionality:")
468
+ print(" - Mac: brew install the_silver_searcher")
469
+ print(" - Ubuntu/Debian: apt-get install silversearcher-ag")
470
+ print(" - Other: https://github.com/ggreer/the_silver_searcher")
471
+ return []
472
+
473
+ try:
474
+ # First get files with matches
475
+ cmd = [
476
+ 'ag',
477
+ '--files-with-matches',
478
+ '--nocolor',
479
+ '--ignore-case',
480
+ pattern,
481
+ directory
482
+ ]
483
+
484
+ result = subprocess.run(cmd, capture_output=True, text=True)
485
+ if result.returncode != 0 or not result.stdout.strip():
486
+ return []
487
+
488
+ files = result.stdout.strip().split('\n')
489
+ grep_results = []
490
+
491
+ for file in files:
492
+ if is_ignored(file, ignore_patterns) or is_binary(file):
493
+ continue
494
+
495
+ # Get match count and preview lines
496
+ count_cmd = ['ag', '--count', '--nocolor', pattern, file]
497
+ count_result = subprocess.run(count_cmd, capture_output=True, text=True)
498
+ match_count = 0
499
+ if count_result.stdout:
500
+ # ag --count outputs "filename:count"
501
+ # We need to handle filenames that might contain colons
502
+ stdout_line = count_result.stdout.strip()
503
+ # Find the last colon to separate filename from count
504
+ last_colon_idx = stdout_line.rfind(':')
505
+ if last_colon_idx > 0:
506
+ try:
507
+ match_count = int(stdout_line[last_colon_idx + 1:])
508
+ except ValueError:
509
+ match_count = 1
510
+ else:
511
+ match_count = 1
512
+
513
+ # Get preview of matches (up to 3 lines)
514
+ preview_cmd = [
515
+ 'ag',
516
+ '--max-count=3',
517
+ '--nocolor',
518
+ '--noheading',
519
+ '--numbers',
520
+ pattern,
521
+ file
522
+ ]
523
+ preview_result = subprocess.run(preview_cmd, capture_output=True, text=True)
524
+ preview_lines = []
525
+ if preview_result.stdout:
526
+ for line in preview_result.stdout.strip().split('\n')[:3]:
527
+ # Format: "line_num:content"
528
+ if ':' in line:
529
+ line_num, content = line.split(':', 1)
530
+ preview_lines.append(f" {line_num}: {content.strip()}")
531
+ else:
532
+ preview_lines.append(f" {line.strip()}")
533
+
534
+ grep_results.append((file, preview_lines, match_count))
535
+
536
+ return sorted(grep_results)
537
+
538
+ except Exception as e:
539
+ print(f"Error running ag: {e}")
540
+ return []
541
+
542
+ def select_from_grep_results(
543
+ grep_results: List[Tuple[str, List[str], int]],
544
+ current_char_count: int
545
+ ) -> Tuple[List[FileTuple], int]:
546
+ """
547
+ Let user select from grep results.
548
+ Returns (selected_files, new_char_count).
549
+ """
550
+ if not grep_results:
551
+ return [], current_char_count
552
+
553
+ print(f"\nFound {len(grep_results)} files:")
554
+ for i, (file_path, preview_lines, match_count) in enumerate(grep_results):
555
+ file_size = os.path.getsize(file_path)
556
+ file_size_readable = get_human_readable_size(file_size)
557
+ print(f"\n{i+1}. {os.path.relpath(file_path)} ({file_size_readable}) - {match_count} {'match' if match_count == 1 else 'matches'}")
558
+ for preview_line in preview_lines[:3]:
559
+ print(preview_line)
560
+ if match_count > 3:
561
+ print(f" ... and {match_count - 3} more matches")
562
+
563
+ while True:
564
+ print_char_count(current_char_count)
565
+ choice = input("\nSelect grep results: (a)ll / (n)one / (s)elect individually / numbers (e.g. 1,3-4) / (q)uit? ").lower()
566
+
567
+ selected_files: List[FileTuple] = []
568
+ char_count_delta = 0
569
+
570
+ if choice == 'a':
571
+ for file_path, _, _ in grep_results:
572
+ file_size = os.path.getsize(file_path)
573
+ selected_files.append((file_path, False, None, get_language_for_file(file_path)))
574
+ char_count_delta += file_size
575
+ print(f"Added all {len(grep_results)} files from grep results.")
576
+ return selected_files, current_char_count + char_count_delta
577
+
578
+ elif choice == 'n':
579
+ print("Skipped all grep results.")
580
+ return [], current_char_count
581
+
582
+ elif choice == 'q':
583
+ print("Cancelled grep selection.")
584
+ return [], current_char_count
585
+
586
+ elif choice == 's':
587
+ for i, (file_path, preview_lines, match_count) in enumerate(grep_results):
588
+ file_size = os.path.getsize(file_path)
589
+ file_size_readable = get_human_readable_size(file_size)
590
+ file_char_estimate = file_size
591
+ file_token_estimate = file_char_estimate // 4
592
+
593
+ print(f"\n{os.path.relpath(file_path)} ({file_size_readable}, ~{file_char_estimate} chars, ~{file_token_estimate} tokens)")
594
+ print(f"{match_count} {'match' if match_count == 1 else 'matches'} for search pattern")
595
+
596
+ while True:
597
+ print_char_count(current_char_count + char_count_delta)
598
+ file_choice = input("(y)es / (n)o / (q)uit? ").lower()
599
+
600
+ if file_choice == 'y':
601
+ if is_large_file(file_path):
602
+ while True:
603
+ snippet_choice = input(f"File is large. Use (f)ull content or (s)nippet? ").lower()
604
+ if snippet_choice in ['f', 's']:
605
+ break
606
+ print("Invalid choice. Please enter 'f' or 's'.")
607
+ if snippet_choice == 's':
608
+ selected_files.append((file_path, True, None, get_language_for_file(file_path)))
609
+ char_count_delta += len(get_file_snippet(file_path))
610
+ else:
611
+ selected_files.append((file_path, False, None, get_language_for_file(file_path)))
612
+ char_count_delta += file_size
613
+ else:
614
+ selected_files.append((file_path, False, None, get_language_for_file(file_path)))
615
+ char_count_delta += file_size
616
+ print(f"Added: {os.path.relpath(file_path)}")
617
+ break
618
+ elif file_choice == 'n':
619
+ break
620
+ elif file_choice == 'q':
621
+ print(f"Added {len(selected_files)} files from grep results.")
622
+ return selected_files, current_char_count + char_count_delta
623
+ else:
624
+ print("Invalid choice. Please enter 'y', 'n', or 'q'.")
625
+
626
+ print(f"Added {len(selected_files)} files from grep results.")
627
+ return selected_files, current_char_count + char_count_delta
628
+
629
+ else:
630
+ # Try to parse number selection
631
+ try:
632
+ selected_indices = set()
633
+ parts = choice.replace(' ', '').split(',')
634
+ if all(p.strip() for p in parts):
635
+ for part in parts:
636
+ if '-' in part:
637
+ start_str, end_str = part.split('-', 1)
638
+ start = int(start_str)
639
+ end = int(end_str)
640
+ if start > end:
641
+ start, end = end, start
642
+ selected_indices.update(range(start - 1, end))
643
+ else:
644
+ selected_indices.add(int(part) - 1)
645
+
646
+ if all(0 <= i < len(grep_results) for i in selected_indices):
647
+ for i in sorted(selected_indices):
648
+ file_path, _, _ = grep_results[i]
649
+ file_size = os.path.getsize(file_path)
650
+ selected_files.append((file_path, False, None, get_language_for_file(file_path)))
651
+ char_count_delta += file_size
652
+ print(f"Added {len(selected_files)} files from grep results.")
653
+ return selected_files, current_char_count + char_count_delta
654
+ else:
655
+ print(f"Error: Invalid number selection. Please choose numbers between 1 and {len(grep_results)}.")
656
+ else:
657
+ raise ValueError("Empty part detected in input.")
658
+ except ValueError:
659
+ print("Invalid choice. Please enter 'a', 'n', 's', 'q', or a list/range of numbers.")
660
+
661
+ def select_files_in_directory(directory: str, ignore_patterns: List[str], project_root_abs: str, current_char_count: int = 0, selected_files_set: Optional[Set[str]] = None) -> Tuple[List[FileTuple], int]:
662
+ if selected_files_set is None:
663
+ selected_files_set = set()
664
+
649
665
  files = [f for f in os.listdir(directory)
650
666
  if os.path.isfile(os.path.join(directory, f)) and not is_ignored(os.path.join(directory, f), ignore_patterns) and not is_binary(os.path.join(directory, f))]
651
667
 
@@ -660,17 +676,129 @@ def select_files_in_directory(directory: str, ignore_patterns: List[str], projec
660
676
  file_size_readable = get_human_readable_size(file_size)
661
677
  file_char_estimate = file_size # Assuming 1 byte ≈ 1 character for text files
662
678
  file_token_estimate = file_char_estimate // 4
663
- print(f"- {file} ({file_size_readable}, ~{file_char_estimate} chars, ~{file_token_estimate} tokens)")
679
+
680
+ # Show if already selected
681
+ if os.path.abspath(file_path) in selected_files_set:
682
+ print(f"✓ {file} ({file_size_readable}, ~{file_char_estimate} chars, ~{file_token_estimate} tokens) [already selected]")
683
+ else:
684
+ print(f"- {file} ({file_size_readable}, ~{file_char_estimate} chars, ~{file_token_estimate} tokens)")
664
685
 
665
686
  while True:
666
687
  print_char_count(current_char_count)
667
- choice = input("(y)es add all / (n)o ignore all / (s)elect individually / (q)uit? ").lower()
688
+ choice = input("(y)es add all / (n)o ignore all / (s)elect individually / (g)rep / (q)uit? ").lower()
668
689
  selected_files: List[FileTuple] = []
669
690
  char_count_delta = 0
691
+
692
+ if choice == 'g':
693
+ # Grep functionality
694
+ pattern = input("\nEnter search pattern: ")
695
+ if pattern:
696
+ print(f"\nSearching in {directory} for '{pattern}'...")
697
+ grep_results = grep_files_in_directory(pattern, directory, ignore_patterns)
698
+
699
+ if not grep_results:
700
+ print(f"No files found matching '{pattern}'")
701
+ continue
702
+
703
+ grep_selected, new_char_count = select_from_grep_results(grep_results, current_char_count)
704
+
705
+ if grep_selected:
706
+ selected_files.extend(grep_selected)
707
+ current_char_count = new_char_count
708
+
709
+ # Update selected files set
710
+ for file_tuple in grep_selected:
711
+ selected_files_set.add(os.path.abspath(file_tuple[0]))
712
+
713
+ # Analyze dependencies for grep-selected files
714
+ files_to_analyze = [f[0] for f in grep_selected]
715
+ for file_path in files_to_analyze:
716
+ new_deps, deps_char_count = _propose_and_add_dependencies(
717
+ file_path, project_root_abs, selected_files, current_char_count
718
+ )
719
+ selected_files.extend(new_deps)
720
+ current_char_count += deps_char_count
721
+
722
+ # Update selected files set with dependencies
723
+ for dep_tuple in new_deps:
724
+ selected_files_set.add(os.path.abspath(dep_tuple[0]))
725
+
726
+ print(f"\nReturning to directory: {directory}")
727
+ # Re-show the directory with updated selections
728
+ print("Files:")
729
+ for file in files:
730
+ file_path = os.path.join(directory, file)
731
+ file_size = os.path.getsize(file_path)
732
+ file_size_readable = get_human_readable_size(file_size)
733
+ if os.path.abspath(file_path) in selected_files_set:
734
+ print(f"✓ {file} ({file_size_readable}) [already selected]")
735
+ else:
736
+ print(f"- {file} ({file_size_readable})")
737
+
738
+ # Ask what to do with remaining files
739
+ remaining_files = [f for f in files if os.path.abspath(os.path.join(directory, f)) not in selected_files_set]
740
+ if remaining_files:
741
+ while True:
742
+ print_char_count(current_char_count)
743
+ remaining_choice = input("(y)es add remaining / (n)o skip remaining / (s)elect more / (g)rep again / (q)uit? ").lower()
744
+ if remaining_choice == 'y':
745
+ # Add all remaining files
746
+ for file in remaining_files:
747
+ file_path = os.path.join(directory, file)
748
+ file_size = os.path.getsize(file_path)
749
+ selected_files.append((file_path, False, None, get_language_for_file(file_path)))
750
+ current_char_count += file_size
751
+ selected_files_set.add(os.path.abspath(file_path))
752
+
753
+ # Analyze dependencies for remaining files
754
+ for file in remaining_files:
755
+ file_path = os.path.join(directory, file)
756
+ new_deps, deps_char_count = _propose_and_add_dependencies(
757
+ file_path, project_root_abs, selected_files, current_char_count
758
+ )
759
+ selected_files.extend(new_deps)
760
+ current_char_count += deps_char_count
761
+
762
+ print(f"Added all remaining files from {directory}")
763
+ return selected_files, current_char_count
764
+ elif remaining_choice == 'n':
765
+ print(f"Skipped remaining files from {directory}")
766
+ return selected_files, current_char_count
767
+ elif remaining_choice == 's':
768
+ # Continue to individual selection
769
+ choice = 's'
770
+ break
771
+ elif remaining_choice == 'g':
772
+ # Continue to grep again
773
+ choice = 'g'
774
+ break
775
+ elif remaining_choice == 'q':
776
+ return selected_files, current_char_count
777
+ else:
778
+ print("Invalid choice. Please try again.")
779
+
780
+ if choice == 's':
781
+ # Fall through to individual selection
782
+ pass
783
+ elif choice == 'g':
784
+ # Loop back to grep
785
+ continue
786
+ else:
787
+ # No remaining files
788
+ return selected_files, current_char_count
789
+ else:
790
+ # No files selected from grep, continue
791
+ continue
792
+ else:
793
+ continue
794
+
670
795
  if choice == 'y':
671
796
  files_to_add_after_loop = []
672
797
  for file in files:
673
798
  file_path = os.path.join(directory, file)
799
+ if os.path.abspath(file_path) in selected_files_set:
800
+ continue # Skip already selected files
801
+
674
802
  if is_large_file(file_path):
675
803
  while True:
676
804
  snippet_choice = input(f"{file} is large. Use (f)ull content or (s)nippet? ").lower()
@@ -697,12 +825,17 @@ def select_files_in_directory(directory: str, ignore_patterns: List[str], projec
697
825
 
698
826
  print(f"Added all files from {directory}")
699
827
  return selected_files, current_char_count
828
+
700
829
  elif choice == 'n':
701
830
  print(f"Ignored all files from {directory}")
702
831
  return [], current_char_count
832
+
703
833
  elif choice == 's':
704
834
  for file in files:
705
835
  file_path = os.path.join(directory, file)
836
+ if os.path.abspath(file_path) in selected_files_set:
837
+ continue # Skip already selected files
838
+
706
839
  file_size = os.path.getsize(file_path)
707
840
  file_size_readable = get_human_readable_size(file_size)
708
841
  file_char_estimate = file_size
@@ -731,6 +864,7 @@ def select_files_in_directory(directory: str, ignore_patterns: List[str], projec
731
864
 
732
865
  if file_to_add:
733
866
  selected_files.append(file_to_add)
867
+ selected_files_set.add(os.path.abspath(file_path))
734
868
  # Analyze dependencies immediately after adding
735
869
  new_deps, deps_char_count = _propose_and_add_dependencies(file_path, project_root_abs, selected_files, current_char_count)
736
870
  selected_files.extend(new_deps)
@@ -743,6 +877,7 @@ def select_files_in_directory(directory: str, ignore_patterns: List[str], projec
743
877
  if chunks:
744
878
  selected_files.append((file_path, False, chunks, get_language_for_file(file_path)))
745
879
  current_char_count += char_count
880
+ selected_files_set.add(os.path.abspath(file_path))
746
881
  break
747
882
  elif file_choice == 'q':
748
883
  print(f"Quitting selection for {directory}")
@@ -751,13 +886,18 @@ def select_files_in_directory(directory: str, ignore_patterns: List[str], projec
751
886
  print("Invalid choice. Please enter 'y', 'n', 'p', or 'q'.")
752
887
  print(f"Added {len(selected_files)} files from {directory}")
753
888
  return selected_files, current_char_count
889
+
754
890
  elif choice == 'q':
755
891
  print(f"Quitting selection for {directory}")
756
892
  return [], current_char_count
757
893
  else:
758
894
  print("Invalid choice. Please try again.")
759
895
 
760
- def process_directory(directory: str, ignore_patterns: List[str], project_root_abs: str, current_char_count: int = 0) -> Tuple[List[FileTuple], Set[str], int]:
896
+
897
+ def process_directory(directory: str, ignore_patterns: List[str], project_root_abs: str, current_char_count: int = 0, selected_files_set: Optional[Set[str]] = None) -> Tuple[List[FileTuple], Set[str], int]:
898
+ if selected_files_set is None:
899
+ selected_files_set = set()
900
+
761
901
  files_to_include: List[FileTuple] = []
762
902
  processed_dirs: Set[str] = set()
763
903
 
@@ -771,10 +911,16 @@ def process_directory(directory: str, ignore_patterns: List[str], project_root_a
771
911
  print(f"\nExploring directory: {root}")
772
912
  choice = input("(y)es explore / (n)o skip / (q)uit? ").lower()
773
913
  if choice == 'y':
774
- # Pass project_root_abs down
775
- selected_files, current_char_count = select_files_in_directory(root, ignore_patterns, project_root_abs, current_char_count)
776
- # The paths in selected_files are already absolute now
914
+ # Pass selected_files_set to track already selected files
915
+ selected_files, current_char_count = select_files_in_directory(
916
+ root, ignore_patterns, project_root_abs, current_char_count, selected_files_set
917
+ )
777
918
  files_to_include.extend(selected_files)
919
+
920
+ # Update selected_files_set
921
+ for file_tuple in selected_files:
922
+ selected_files_set.add(os.path.abspath(file_tuple[0]))
923
+
778
924
  processed_dirs.add(root)
779
925
  elif choice == 'n':
780
926
  dirs[:] = [] # Skip all subdirectories
@@ -819,122 +965,6 @@ def read_env_file():
819
965
  env_vars[key.strip()] = value.strip()
820
966
  return env_vars
821
967
 
822
- def detect_env_variables(content, env_vars):
823
- detected_vars = []
824
- for key, value in env_vars.items():
825
- if value in content:
826
- detected_vars.append((key, value))
827
- return detected_vars
828
-
829
- def handle_env_variables(content, env_vars):
830
- detected_vars = detect_env_variables(content, env_vars)
831
- if not detected_vars:
832
- return content
833
-
834
- print("Detected environment variables:")
835
- for key, value in detected_vars:
836
- print(f"- {key}={value}")
837
-
838
- for key, value in detected_vars:
839
- while True:
840
- choice = input(f"How would you like to handle {key}? (m)ask / (s)kip / (k)eep: ").lower()
841
- if choice in ['m', 's', 'k']:
842
- break
843
- print("Invalid choice. Please enter 'm', 's', or 'k'.")
844
-
845
- if choice == 'm':
846
- content = content.replace(value, '*' * len(value))
847
- elif choice == 's':
848
- content = content.replace(value, "[REDACTED]")
849
- # If 'k', we don't modify the content
850
-
851
- return content
852
-
853
- def generate_prompt_template(files_to_include: List[FileTuple], ignore_patterns: List[str], web_contents: Dict[str, Tuple[FileTuple, str]], env_vars: Dict[str, str]) -> Tuple[str, int]:
854
- prompt = "# Project Overview\n\n"
855
- prompt += "## Project Structure\n\n"
856
- prompt += "```\n"
857
- prompt += get_project_structure(ignore_patterns)
858
- prompt += "\n```\n\n"
859
- prompt += "## File Contents\n\n"
860
- for file, use_snippet, chunks, content_type in files_to_include:
861
- relative_path = get_relative_path(file)
862
- language = content_type if content_type else get_language_for_file(file)
863
-
864
- if chunks is not None:
865
- prompt += f"### {relative_path} (selected patches)\n\n```{language}\n"
866
- for chunk in chunks:
867
- prompt += f"{chunk}\n"
868
- prompt += "```\n\n"
869
- elif use_snippet:
870
- file_content = get_file_snippet(file)
871
- prompt += f"### {relative_path} (snippet)\n\n```{language}\n{file_content}\n```\n\n"
872
- else:
873
- file_content = read_file_contents(file)
874
- file_content = handle_env_variables(file_content, env_vars)
875
- prompt += f"### {relative_path}\n\n```{language}\n{file_content}\n```\n\n"
876
-
877
- if web_contents:
878
- prompt += "## Web Content\n\n"
879
- for url, (file_tuple, content) in web_contents.items():
880
- _, is_snippet, _, content_type = file_tuple
881
- content = handle_env_variables(content, env_vars)
882
- language = content_type if content_type in ['json', 'csv'] else ''
883
- prompt += f"### {url}{' (snippet)' if is_snippet else ''}\n\n```{language}\n{content}\n```\n\n"
884
-
885
- prompt += "## Task Instructions\n\n"
886
- cursor_position = len(prompt)
887
- prompt += "\n\n"
888
- prompt += "## Instructions for Achieving the Task\n\n"
889
- analysis_text = (
890
- "### Your Operating Model: The Expert Council\n\n"
891
- "You will operate as a council of expert personas in a direct, collaborative partnership with me, the user. Your entire thinking process should be externalized as a dialogue between these personas. Any persona can and should interact directly with me.\n\n"
892
- "#### Core Personas\n"
893
- "- **Facilitator**: The lead coordinator. You summarize discussions, ensure the team stays on track, and are responsible for presenting the final, consolidated plans and code to me.\n"
894
- "- **Architect**: Focuses on high-level design, system structure, dependencies, scalability, and long-term maintainability. Asks 'Is this well-designed?'\n"
895
- "- **Builder**: The hands-on engineer. Focuses on implementation details, writing clean and functional code, and debugging. Asks 'How do we build this?' **When presenting code obey Rules for Presenting Code**\n"
896
- "- **Critique**: The quality advocate. Focuses on identifying edge cases, potential bugs, and security vulnerabilities. Asks 'What could go wrong?' and provides constructive critique on both the team's proposals and my (the user's) requests.\n\n"
897
- "#### Dynamic Personas\n"
898
- "When the task requires it, introduce other specialist personas. For example: UX Designer, DevOps Engineer, Data Scientist, etc.\n\n"
899
- "### Development Workflow: A Structured Dialogue\n\n"
900
- "Follow this conversational workflow. Prefix direct communication to me with the persona's name (e.g., 'Critique to User: ...').\n\n"
901
- "1. **Understand & Clarify (Dialogue)**\n"
902
- " - **Facilitator**: Start by stating the goal as you understand it.\n"
903
- " - **All**: Discuss the requirements. Any persona can directly ask me for clarification. Identify and list any missing information ([MISSING]) or assumptions ([ASSUMPTION]).\n"
904
- " - **Critique**: Directly challenge any part of my request that seems ambiguous, risky, or suboptimal.\n\n"
905
- "2. **Plan (Dialogue)**\n"
906
- " - **Architect**: Propose one or more high-level plans. Directly ask me for input on tradeoffs if necessary.\n"
907
- " - **Critique**: Challenge the plans. Point out risks or edge cases.\n"
908
- " - **Builder**: Comment on the implementation feasibility.\n"
909
- " - **Facilitator**: Synthesize the discussion, select the best plan, and present a clear, step-by-step summary to me for approval.\n\n"
910
- "3. **Implement (Code & Dialogue)**\n"
911
- " - **Facilitator**: State which step of the plan is being implemented.\n"
912
- " - **Builder**: Write the code. If you encounter an issue, you can ask me directly for more context.\n"
913
- " - **Critique**: Review the code and the underlying assumptions.\n"
914
- " - **Facilitator**: Present the final, reviewed code for that step. Ask me to test it if appropriate.\n\n"
915
- "4. **Present E2E Draft (Consolidation)**\n"
916
- " - When I ask you to consolidate, your goal is to provide a clear, unambiguous set of changes that I can directly copy and paste into my editor.\n"
917
- " - **Present changes grouped by file.** For each change, provide the **entire updated function, class, or logical block** to make replacement easy.\n"
918
- " - Use context comments to show where the new code block fits.\n"
919
- " - **Do not use a diff format (`+` or `-` lines).** Provide the final, clean code.\n\n"
920
- "5. **Validate & Iterate (Feedback Loop)**\n"
921
- " - After presenting the consolidated draft, you will explicitly prompt me for testing and wait for my feedback.\n"
922
- " - Upon receiving my feedback, the **Facilitator** will announce the next step.\n"
923
- " - If the feedback indicates a minor bug, you will return to **Step 3 (Implement)** to fix it.\n"
924
- " - If the feedback reveals a fundamental design flaw or misunderstanding, you will return to **Step 2 (Plan)** or even **Step 1 (Understand)** to re-evaluate the approach.\n\n"
925
- "### Rules for Presenting Code\n\n"
926
- "- **Always specify the filename** at the start of a code block (e.g., `// FILE: kopipasta/main.py`).\n"
927
- "- **Incremental Changes**: During implementation, show only the changed functions or classes. Use comments like `// ... existing code ...` to represent unchanged parts.\n"
928
- "- **Missing Files**: Never write placeholder code. Instead, state it's missing and request it.\n\n"
929
- "### Your Permissions\n\n"
930
- "- **You are empowered to interact with me directly.** Any persona can ask me questions or provide constructive feedback on my requests, assumptions, or the provided context.\n"
931
- "- You MUST request any file shown in the project tree that you need but was not provided.\n"
932
- "- You MUST ask me to run code, test integrations (APIs, databases), and share the output.\n"
933
- "- You MUST ask for clarification instead of making risky assumptions.\n"
934
- )
935
- prompt += analysis_text
936
- return prompt, cursor_position
937
-
938
968
  def open_editor_for_input(template: str, cursor_position: int) -> str:
939
969
  editor = os.environ.get('EDITOR', 'vim')
940
970
  with tempfile.NamedTemporaryFile(mode='w+', suffix='.md', delete=False) as temp_file:
@@ -961,232 +991,26 @@ def open_editor_for_input(template: str, cursor_position: int) -> str:
961
991
  finally:
962
992
  os.unlink(temp_file_path)
963
993
 
964
- def start_chat_session(initial_prompt: str):
965
- """Starts an interactive chat session with the Gemini API using google-genai."""
966
- if not genai:
967
- # Error message already printed during import if it failed
968
- sys.exit(1)
969
-
970
- # The google-genai library automatically uses GOOGLE_API_KEY env var if set
971
- # We still check if it's set to provide a clearer error message upfront
972
- if not os.environ.get('GOOGLE_API_KEY'):
973
- print("Error: GOOGLE_API_KEY environment variable not set.")
974
- print("Please set the GOOGLE_API_KEY environment variable with your API key.")
975
- sys.exit(1)
976
-
977
- try:
978
- # Create the client - it will use the env var automatically
979
- client = genai.Client()
980
- print("Google GenAI Client created (using GOOGLE_API_KEY).")
981
- # You could add a check here like listing models to verify the key early
982
- # print("Available models:", [m.name for m in client.models.list()])
983
- except Exception as e:
984
- print(f"Error creating Google GenAI client: {e}")
985
- print("Please ensure your GOOGLE_API_KEY is valid and has permissions.")
986
- sys.exit(1)
987
-
988
- model_name = 'gemini-2.5-pro-exp-03-25'
989
- config = GenerateContentConfig(temperature=0.0)
990
- print(f"Using model: {model_name}")
991
-
992
- try:
993
- # Create a chat session using the client
994
- chat = client.chats.create(model=model_name, config=config)
995
- # Note: History is managed by the chat object itself
996
-
997
- print("\n--- Starting Interactive Chat with Gemini ---")
998
- print("Type /q to quit, /help or /? for help, /review to make clear summary, /patch to request a diff patch.")
999
-
1000
- # Send the initial prompt using send_message_stream
1001
- print("\n🤖 Gemini:")
1002
- full_response_text = ""
1003
- # Use send_message_stream for streaming responses
1004
- response_stream = chat.send_message_stream(initial_prompt, config=config)
1005
- for chunk in response_stream:
1006
- print(chunk.text, end="", flush=True)
1007
- full_response_text += chunk.text
1008
- print("\n" + "-"*20)
1009
-
1010
- while True:
1011
- is_patch_request = False
1012
- try:
1013
- # Print the header on a separate line
1014
- print("👤 You (Submit with Esc+Enter):")
1015
- # Get input using prompt_toolkit with a minimal indicator
1016
- user_input = prompt(">> ", multiline=True)
1017
- # prompt_toolkit raises EOFError on Ctrl+D, so this handler remains correct.
1018
- except EOFError:
1019
- print("\nExiting...")
1020
- break
1021
- except KeyboardInterrupt: # Handle Ctrl+C
1022
- print("\nExiting...")
1023
- break
1024
-
1025
- if user_input.lower() == '/q':
1026
- break
1027
- elif user_input.endswith('/patch'):
1028
- is_patch_request = True
1029
- # Extract message before /patch
1030
- user_message = user_input[:-len('/patch')].strip()
1031
- print(f"\n🛠️ Requesting patches... (Context: '{user_message}' if provided)")
1032
- elif user_input.lower() == '/review':
1033
- user_message = user_input = "Review and reflect on the solution. Summarize and write a minimal, complete set of changes needed for the solution. Do not use + and - style diff. Instead use comments to point where to place the code. Make it easy to copy and paste the solution."
1034
- elif not user_input:
1035
- continue # Ignore empty input
1036
- else:
1037
- user_message = user_input # Regular message
1038
-
1039
-
1040
- # --- Handle Patch Request ---
1041
- if is_patch_request:
1042
- print("🤖 Gemini: Thinking... (generating code changes)")
1043
- # Include user message part if it exists
1044
- patch_context = f"Based on our conversation and specifically: \"{user_message}\"\n\n" if user_message else "Based on our conversation,\n\n"
1045
-
1046
- patch_request_prompt = (
1047
- patch_context +
1048
- "Generate the necessary code changes to fulfill the request. Provide the changes as a JSON list, where each item "
1049
- "is an object with the following keys:\n"
1050
- "- 'reasoning': Explain why this specific change is needed.\n"
1051
- "- 'file_path': The relative path to the file to modify.\n"
1052
- "- 'original_text': The exact, unique block of text to replace.\n"
1053
- "- 'new_text': The text to replace original_text with. Do not include any temporary comments like '// CHANGE BEGINS' or '/* PATCH START */'.\n"
1054
- "Ensure 'original_text' is unique within the specified 'file_path'. "
1055
- "Respond ONLY with the JSON object conforming to this structure: { \"patches\": [ { patch_item_1 }, { patch_item_2 }, ... ] }"
1056
- )
1057
-
1058
- try:
1059
- # Request the response using the new schema
1060
- response = chat.send_message(
1061
- patch_request_prompt,
1062
- config=GenerateContentConfig(
1063
- response_schema=SimplePatchArgs.model_json_schema(),
1064
- response_mime_type='application/json',
1065
- temperature=0.0
1066
- )
1067
- )
1068
-
1069
- print("🤖 Gemini: Received potential patches.")
1070
- try:
1071
- # Validate and parse args using the Pydantic model
1072
- # Explicitly validate the dictionary returned by response.parsed
1073
- if isinstance(response.parsed, dict):
1074
- patch_args = SimplePatchArgs.model_validate(response.parsed)
1075
- else:
1076
- # Handle unexpected type if response.parsed isn't a dict
1077
- print(f"❌ Error: Expected a dictionary for patches, but got type {type(response.parsed)}")
1078
- print(f" Content: {response.parsed}")
1079
- continue # Skip further processing for this response
1080
-
1081
- if not patch_args or not patch_args.patches:
1082
- print("🤖 Gemini: No patches were proposed in the response.")
1083
- print("-" * 20)
1084
- continue
1085
-
1086
- print("\nProposed Patches:")
1087
- print("=" * 30)
1088
- for i, patch_item in enumerate(patch_args.patches):
1089
- print(f"Patch {i+1}/{len(patch_args.patches)}:")
1090
- print(f" File: {patch_item.file_path}")
1091
- print(f" Reasoning: {patch_item.reasoning}")
1092
- # Optionally show snippets of original/new text for review
1093
- print(f" Original (snippet): '{patch_item.original_text[:80].strip()}...'")
1094
- print(f" New (snippet): '{patch_item.new_text[:80].strip()}...'")
1095
- print("-" * 20)
1096
-
1097
- confirm = input(f"Apply these {len(patch_args.patches)} patches? (y/N): ").lower()
1098
- if confirm == 'y':
1099
- applied_count = 0
1100
- failed_count = 0
1101
- for patch_item in patch_args.patches:
1102
- # Call the new apply function for each patch
1103
- success = apply_simple_patch(patch_item)
1104
- if success:
1105
- applied_count += 1
1106
- else:
1107
- failed_count += 1
1108
-
1109
- print("\nPatch Application Summary:")
1110
- if applied_count > 0:
1111
- print(f"✅ Successfully applied {applied_count} patches.")
1112
- if failed_count > 0:
1113
- print(f"❌ Failed to apply {failed_count} patches.")
1114
- if applied_count == 0 and failed_count == 0: # Should not happen if list wasn't empty
1115
- print("⚪ No patches were applied.")
1116
- print("=" * 30)
1117
- else:
1118
- print("🤖 Gemini: Patches not applied by user.")
1119
- print("-" * 20)
1120
-
1121
- except Exception as e: # Catch Pydantic validation errors or other issues
1122
- print(f"❌ Error processing patch response: {e}")
1123
- # Attempt to show the raw response text if parsing failed
1124
- raw_text = ""
1125
- try:
1126
- if response.parts:
1127
- raw_text = "".join(part.text for part in response.parts if hasattr(part, 'text'))
1128
- elif hasattr(response, 'text'):
1129
- raw_text = response.text
1130
- except Exception:
1131
- pass # Ignore errors getting raw text
1132
- if raw_text:
1133
- print(f" Received response text:\n{raw_text}")
1134
- else:
1135
- print(f" Received response content: {response}") # Fallback representation
1136
-
1137
- except Exception as e:
1138
- print(f"\n❌ An error occurred while requesting patches from Gemini: {e}")
1139
- print(" Please check your connection, API key, and model permissions/capabilities.")
1140
- print("-" * 20)
1141
-
1142
- continue # Go to next loop iteration after handling /patch
1143
- elif user_input.strip() in ['/help', '/?']:
1144
- print("🤖 Gemini: Available commands:")
1145
- print(" /q - Quit the chat session.")
1146
- print(" /patch - Request a diff patch (not fully implemented yet).")
1147
- print(" /review - Pre-fill input with a review/summary prompt template.")
1148
- print(" /help or /? - Show this help message.")
1149
- print("-" * 20)
1150
- continue
1151
- elif not user_input.strip(): # Ignore empty input
1152
- continue
1153
-
1154
- print("\n🤖 Gemini:")
1155
- full_response_text = ""
1156
- try:
1157
- # Use send_message_stream for subsequent messages
1158
- response_stream = chat.send_message_stream(user_input, config=config)
1159
- for chunk in response_stream:
1160
- print(chunk.text, end="", flush=True)
1161
- full_response_text += chunk.text
1162
- print("\n" + "-"*20)
1163
- except Exception as e:
1164
- print(f"\nAn unexpected error occurred: {e}")
1165
- print("Try again or type 'exit'.")
1166
-
1167
- except Exception as e:
1168
- # Catch other potential errors
1169
- print(f"\nAn error occurred setting up the chat session: {e}")
1170
-
1171
994
  def main():
1172
995
  parser = argparse.ArgumentParser(description="Generate a prompt with project structure, file contents, and web content.")
1173
996
  parser.add_argument('inputs', nargs='+', help='Files, directories, or URLs to include in the prompt')
1174
997
  parser.add_argument('-t', '--task', help='Task description for the AI prompt')
1175
- parser.add_argument('-I', '--interactive', action='store_true', help='Start an interactive chat session after generating the prompt.')
1176
998
  args = parser.parse_args()
1177
999
 
1178
1000
  ignore_patterns = read_gitignore()
1179
1001
  env_vars = read_env_file()
1180
1002
  project_root_abs = os.path.abspath(os.getcwd())
1181
1003
 
1182
-
1183
1004
  files_to_include: List[FileTuple] = []
1184
- processed_dirs = set()
1185
1005
  web_contents: Dict[str, Tuple[FileTuple, str]] = {}
1186
1006
  current_char_count = 0
1187
-
1007
+
1008
+ # Separate URLs from file/directory paths
1009
+ paths_for_tree = []
1010
+
1188
1011
  for input_path in args.inputs:
1189
1012
  if input_path.startswith(('http://', 'https://')):
1013
+ # Handle web content as before
1190
1014
  result = fetch_web_content(input_path)
1191
1015
  if result:
1192
1016
  file_tuple, full_content, snippet = result
@@ -1220,133 +1044,68 @@ def main():
1220
1044
  current_char_count += len(content)
1221
1045
  print(f"Added {'snippet of ' if is_snippet else ''}web content from: {input_path}")
1222
1046
  print_char_count(current_char_count)
1223
- elif os.path.isfile(input_path):
1224
- abs_input_path = os.path.abspath(input_path)
1225
- if not is_ignored(abs_input_path, ignore_patterns) and not is_binary(abs_input_path):
1226
- file_size = os.path.getsize(abs_input_path)
1227
- file_size_readable = get_human_readable_size(file_size)
1228
- file_char_estimate = file_size
1229
- language = get_language_for_file(abs_input_path)
1230
- file_to_add = None
1231
-
1232
- if is_large_file(abs_input_path):
1233
- print(f"\nFile {get_relative_path(abs_input_path)} ({file_size_readable}, ~{file_char_estimate} chars) is large.")
1234
- print("Preview (first ~50 lines or 4KB):")
1235
- print(get_colored_file_snippet(abs_input_path))
1236
- print("-" * 40)
1237
- while True:
1238
- print_char_count(current_char_count)
1239
- choice = input(f"How to include large file {get_relative_path(abs_input_path)}? (f)ull / (s)nippet / (p)atches / (n)o skip: ").lower()
1240
- if choice == 'f':
1241
- file_to_add = (abs_input_path, False, None, language)
1242
- current_char_count += file_char_estimate
1243
- print(f"Added full file: {get_relative_path(abs_input_path)}")
1244
- break
1245
- elif choice == 's':
1246
- snippet_content = get_file_snippet(abs_input_path)
1247
- file_to_add = (abs_input_path, True, None, language)
1248
- current_char_count += len(snippet_content)
1249
- print(f"Added snippet of file: {get_relative_path(abs_input_path)}")
1250
- break
1251
- elif choice == 'p':
1252
- chunks, char_count = select_file_patches(abs_input_path)
1253
- if chunks:
1254
- file_to_add = (abs_input_path, False, chunks, language)
1255
- current_char_count += char_count
1256
- print(f"Added selected patches from file: {get_relative_path(abs_input_path)}")
1257
- else:
1258
- print(f"No patches selected for {get_relative_path(abs_input_path)}. Skipping file.")
1259
- break
1260
- elif choice == 'n':
1261
- print(f"Skipped large file: {get_relative_path(abs_input_path)}")
1262
- break
1263
- else:
1264
- print("Invalid choice. Please enter 'f', 's', 'p', or 'n'.")
1265
- else:
1266
- file_to_add = (abs_input_path, False, None, language)
1267
- current_char_count += file_char_estimate
1268
- print(f"Added file: {get_relative_path(abs_input_path)} ({file_size_readable})")
1269
-
1270
- if file_to_add:
1271
- files_to_include.append(file_to_add)
1272
- # --- NEW: Call dependency analysis ---
1273
- new_deps, deps_char_count = _propose_and_add_dependencies(abs_input_path, project_root_abs, files_to_include, current_char_count)
1274
- files_to_include.extend(new_deps)
1275
- current_char_count += deps_char_count
1276
-
1277
- print_char_count(current_char_count)
1278
-
1279
- else:
1280
- if is_ignored(input_path, ignore_patterns):
1281
- print(f"Ignoring file based on ignore patterns: {input_path}")
1282
- elif is_binary(input_path):
1283
- print(f"Ignoring binary file: {input_path}")
1284
- else:
1285
- print(f"Ignoring file: {input_path}")
1286
- elif os.path.isdir(input_path):
1287
- print(f"\nProcessing directory specified directly: {input_path}")
1288
- # Pass project_root_abs to process_directory
1289
- dir_files, dir_processed, current_char_count = process_directory(input_path, ignore_patterns, project_root_abs, current_char_count)
1290
- files_to_include.extend(dir_files)
1291
- processed_dirs.update(dir_processed)
1292
1047
  else:
1293
- print(f"Warning: {input_path} is not a valid file, directory, or URL. Skipping.")
1048
+ # Add to paths for tree selector
1049
+ if os.path.exists(input_path):
1050
+ paths_for_tree.append(input_path)
1051
+ else:
1052
+ print(f"Warning: {input_path} does not exist. Skipping.")
1053
+
1054
+ # Use tree selector for file/directory selection
1055
+ if paths_for_tree:
1056
+ print("\nStarting interactive file selection...")
1057
+ print("Use arrow keys to navigate, Space to select, 'q' to finish. Press 'h' for help.\n")
1058
+
1059
+ tree_selector = TreeSelector(ignore_patterns, project_root_abs)
1060
+ try:
1061
+ selected_files, file_char_count = tree_selector.run(paths_for_tree)
1062
+ files_to_include.extend(selected_files)
1063
+ current_char_count += file_char_count
1064
+ except KeyboardInterrupt:
1065
+ print("\nSelection cancelled.")
1066
+ return
1294
1067
 
1295
1068
  if not files_to_include and not web_contents:
1296
1069
  print("No files or web content were selected. Exiting.")
1297
1070
  return
1298
1071
 
1299
1072
  print("\nFile and web content selection complete.")
1300
- print_char_count(current_char_count) # Print final count before prompt generation
1073
+ print_char_count(current_char_count)
1301
1074
 
1302
1075
  added_files_count = len(files_to_include)
1303
- added_dirs_count = len(processed_dirs)
1304
1076
  added_web_count = len(web_contents)
1305
- print(f"Summary: Added {added_files_count} files/patches from {added_dirs_count} directories and {added_web_count} web sources.")
1077
+ print(f"Summary: Added {added_files_count} files/patches and {added_web_count} web sources.")
1306
1078
 
1307
1079
  prompt_template, cursor_position = generate_prompt_template(files_to_include, ignore_patterns, web_contents, env_vars)
1308
1080
 
1309
- if args.interactive:
1310
- print("\nPreparing initial prompt for editing...")
1311
- if args.task:
1312
- editor_initial_content = prompt_template[:cursor_position] + args.task + prompt_template[cursor_position:]
1313
- print("Pre-populating editor with task provided via --task argument.")
1081
+ if args.task:
1082
+ task_description = args.task
1083
+ task_marker = "## Task Instructions\n\n"
1084
+ insertion_point = prompt_template.find(task_marker)
1085
+ if insertion_point != -1:
1086
+ final_prompt = prompt_template[:insertion_point + len(task_marker)] + task_description + "\n\n" + prompt_template[insertion_point + len(task_marker):]
1314
1087
  else:
1315
- editor_initial_content = prompt_template
1316
- print("Opening editor for you to add the task instructions.")
1317
-
1318
- initial_chat_prompt = open_editor_for_input(editor_initial_content, cursor_position)
1319
- print("Editor closed. Starting interactive chat session...")
1320
- start_chat_session(initial_chat_prompt)
1088
+ final_prompt = prompt_template[:cursor_position] + task_description + prompt_template[cursor_position:]
1089
+ print("\nUsing task description from -t argument.")
1321
1090
  else:
1322
- if args.task:
1323
- task_description = args.task
1324
- task_marker = "## Task Instructions\n\n"
1325
- insertion_point = prompt_template.find(task_marker)
1326
- if insertion_point != -1:
1327
- final_prompt = prompt_template[:insertion_point + len(task_marker)] + task_description + "\n\n" + prompt_template[insertion_point + len(task_marker):]
1328
- else:
1329
- final_prompt = prompt_template[:cursor_position] + task_description + prompt_template[cursor_position:]
1330
- print("\nUsing task description from -t argument.")
1331
- else:
1332
- print("\nOpening editor for task instructions...")
1333
- final_prompt = open_editor_for_input(prompt_template, cursor_position)
1091
+ print("\nOpening editor for task instructions...")
1092
+ final_prompt = open_editor_for_input(prompt_template, cursor_position)
1334
1093
 
1335
- print("\n\nGenerated prompt:")
1336
- print("-" * 80)
1337
- print(final_prompt)
1338
- print("-" * 80)
1094
+ print("\n\nGenerated prompt:")
1095
+ print("-" * 80)
1096
+ print(final_prompt)
1097
+ print("-" * 80)
1339
1098
 
1340
- try:
1341
- pyperclip.copy(final_prompt)
1342
- separator = "\n" + "=" * 40 + "\n☕🍝 Kopipasta Complete! 🍝☕\n" + "=" * 40 + "\n"
1343
- print(separator)
1344
- final_char_count = len(final_prompt)
1345
- final_token_estimate = final_char_count // 4
1346
- print(f"Prompt has been copied to clipboard. Final size: {final_char_count} characters (~ {final_token_estimate} tokens)")
1347
- except pyperclip.PyperclipException as e:
1348
- print(f"\nWarning: Failed to copy to clipboard: {e}")
1349
- print("You can manually copy the prompt above.")
1099
+ try:
1100
+ pyperclip.copy(final_prompt)
1101
+ separator = "\n" + "=" * 40 + "\n☕🍝 Kopipasta Complete! 🍝☕\n" + "=" * 40 + "\n"
1102
+ print(separator)
1103
+ final_char_count = len(final_prompt)
1104
+ final_token_estimate = final_char_count // 4
1105
+ print(f"Prompt has been copied to clipboard. Final size: {final_char_count} characters (~ {final_token_estimate} tokens)")
1106
+ except pyperclip.PyperclipException as e:
1107
+ print(f"\nWarning: Failed to copy to clipboard: {e}")
1108
+ print("You can manually copy the prompt above.")
1350
1109
 
1351
1110
  if __name__ == "__main__":
1352
1111
  main()