kopipasta 0.28.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,14 +1,12 @@
1
1
  #!/usr/bin/env python3
2
- import csv
3
- import io
4
2
  import os
5
3
  import argparse
6
4
  import re
7
5
  import subprocess
8
6
  import tempfile
7
+ import shutil
9
8
  from typing import Dict, List, Optional, Set, Tuple
10
9
  import pyperclip
11
- import fnmatch
12
10
  from pygments import highlight
13
11
  from pygments.lexers import get_lexer_for_filename, TextLexer
14
12
  from pygments.formatters import TerminalFormatter
@@ -16,9 +14,10 @@ import pygments.util
16
14
 
17
15
  import requests
18
16
 
17
+ from kopipasta.file import FileTuple, get_human_readable_size, is_binary, is_ignored, is_large_file, read_file_contents
19
18
  import kopipasta.import_parser as import_parser
20
-
21
- FileTuple = Tuple[str, bool, Optional[List[str]], str]
19
+ from kopipasta.tree_selector import TreeSelector
20
+ from kopipasta.prompt import generate_prompt_template, get_file_snippet, get_language_for_file
22
21
 
23
22
  def _propose_and_add_dependencies(
24
23
  file_just_added: str,
@@ -33,7 +32,7 @@ def _propose_and_add_dependencies(
33
32
  if language not in ['python', 'typescript', 'javascript', 'tsx', 'jsx']:
34
33
  return [], 0 # Only analyze languages we can parse
35
34
 
36
- 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...")
37
36
 
38
37
  try:
39
38
  file_content = read_file_contents(file_just_added)
@@ -59,7 +58,7 @@ def _propose_and_add_dependencies(
59
58
 
60
59
  print(f"\nFound {len(suggested_deps)} new local {'dependency' if len(suggested_deps) == 1 else 'dependencies'}:")
61
60
  for i, dep_path in enumerate(suggested_deps):
62
- print(f" ({i+1}) {get_relative_path(dep_path)}")
61
+ print(f" ({i+1}) {os.path.relpath(dep_path)}")
63
62
 
64
63
  while True:
65
64
  choice = input("\nAdd dependencies? (a)ll, (n)one, or enter numbers (e.g. 1, 3-4): ").lower()
@@ -115,12 +114,12 @@ def _propose_and_add_dependencies(
115
114
  file_size = os.path.getsize(dep_path)
116
115
  newly_added_files.append((dep_path, False, None, get_language_for_file(dep_path)))
117
116
  char_count_delta += file_size
118
- 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)})")
119
118
 
120
119
  return newly_added_files, char_count_delta
121
120
 
122
121
  except Exception as e:
123
- 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}")
124
123
  return [], 0
125
124
 
126
125
  def get_colored_code(file_path, code):
@@ -155,85 +154,6 @@ def read_gitignore():
155
154
  gitignore_patterns.append(line)
156
155
  return gitignore_patterns
157
156
 
158
- def is_ignored(path, ignore_patterns):
159
- path = os.path.normpath(path)
160
- for pattern in ignore_patterns:
161
- if fnmatch.fnmatch(os.path.basename(path), pattern) or fnmatch.fnmatch(path, pattern):
162
- return True
163
- return False
164
-
165
- def is_binary(file_path):
166
- try:
167
- with open(file_path, 'rb') as file:
168
- chunk = file.read(1024)
169
- if b'\0' in chunk: # null bytes indicate binary file
170
- return True
171
- if file_path.lower().endswith(('.json', '.csv')):
172
- return False
173
- return False
174
- except IOError:
175
- return False
176
-
177
- def get_human_readable_size(size):
178
- for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
179
- if size < 1024.0:
180
- return f"{size:.2f} {unit}"
181
- size /= 1024.0
182
-
183
- def is_large_file(file_path, threshold=102400): # 100 KB threshold
184
- return os.path.getsize(file_path) > threshold
185
-
186
- def get_project_structure(ignore_patterns):
187
- tree = []
188
- for root, dirs, files in os.walk('.'):
189
- dirs[:] = [d for d in dirs if not is_ignored(os.path.join(root, d), ignore_patterns)]
190
- files = [f for f in files if not is_ignored(os.path.join(root, f), ignore_patterns)]
191
- level = root.replace('.', '').count(os.sep)
192
- indent = ' ' * 4 * level + '|-- '
193
- tree.append(f"{indent}{os.path.basename(root)}/")
194
- subindent = ' ' * 4 * (level + 1) + '|-- '
195
- for f in files:
196
- tree.append(f"{subindent}{f}")
197
- return '\n'.join(tree)
198
-
199
- def read_file_contents(file_path):
200
- try:
201
- with open(file_path, 'r') as file:
202
- return file.read()
203
- except Exception as e:
204
- print(f"Error reading {file_path}: {e}")
205
- return ""
206
-
207
- def get_relative_path(file_path):
208
- return os.path.relpath(file_path)
209
-
210
- def get_language_for_file(file_path):
211
- extension = os.path.splitext(file_path)[1].lower()
212
- language_map = {
213
- '.py': 'python',
214
- '.js': 'javascript',
215
- '.jsx': 'jsx',
216
- '.ts': 'typescript',
217
- '.tsx': 'tsx',
218
- '.html': 'html',
219
- '.htm': 'html',
220
- '.css': 'css',
221
- '.json': 'json',
222
- '.md': 'markdown',
223
- '.sql': 'sql',
224
- '.sh': 'bash',
225
- '.yml': 'yaml',
226
- '.yaml': 'yaml',
227
- '.go': 'go',
228
- '.toml': 'toml',
229
- '.c': 'c',
230
- '.cpp': 'cpp',
231
- '.cc': 'cpp',
232
- '.h': 'cpp',
233
- '.hpp': 'cpp',
234
- }
235
- return language_map.get(extension, '')
236
-
237
157
  def split_python_file(file_content):
238
158
  """
239
159
  Splits Python code into logical chunks using the AST module.
@@ -529,17 +449,6 @@ def get_placeholder_comment(language):
529
449
  }
530
450
  return comments.get(language, comments['default'])
531
451
 
532
- def get_file_snippet(file_path, max_lines=50, max_bytes=4096):
533
- snippet = ""
534
- byte_count = 0
535
- with open(file_path, 'r') as file:
536
- for i, line in enumerate(file):
537
- if i >= max_lines or byte_count >= max_bytes:
538
- break
539
- snippet += line
540
- byte_count += len(line.encode('utf-8'))
541
- return snippet
542
-
543
452
  def get_colored_file_snippet(file_path, max_lines=50, max_bytes=4096):
544
453
  snippet = get_file_snippet(file_path, max_lines, max_bytes)
545
454
  return get_colored_code(file_path, snippet)
@@ -548,7 +457,211 @@ def print_char_count(count):
548
457
  token_estimate = count // 4
549
458
  print(f"\rCurrent prompt size: {count} characters (~ {token_estimate} tokens)", flush=True)
550
459
 
551
- 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
+
552
665
  files = [f for f in os.listdir(directory)
553
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))]
554
667
 
@@ -563,17 +676,129 @@ def select_files_in_directory(directory: str, ignore_patterns: List[str], projec
563
676
  file_size_readable = get_human_readable_size(file_size)
564
677
  file_char_estimate = file_size # Assuming 1 byte ≈ 1 character for text files
565
678
  file_token_estimate = file_char_estimate // 4
566
- 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)")
567
685
 
568
686
  while True:
569
687
  print_char_count(current_char_count)
570
- 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()
571
689
  selected_files: List[FileTuple] = []
572
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
+
573
795
  if choice == 'y':
574
796
  files_to_add_after_loop = []
575
797
  for file in files:
576
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
+
577
802
  if is_large_file(file_path):
578
803
  while True:
579
804
  snippet_choice = input(f"{file} is large. Use (f)ull content or (s)nippet? ").lower()
@@ -600,12 +825,17 @@ def select_files_in_directory(directory: str, ignore_patterns: List[str], projec
600
825
 
601
826
  print(f"Added all files from {directory}")
602
827
  return selected_files, current_char_count
828
+
603
829
  elif choice == 'n':
604
830
  print(f"Ignored all files from {directory}")
605
831
  return [], current_char_count
832
+
606
833
  elif choice == 's':
607
834
  for file in files:
608
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
+
609
839
  file_size = os.path.getsize(file_path)
610
840
  file_size_readable = get_human_readable_size(file_size)
611
841
  file_char_estimate = file_size
@@ -634,6 +864,7 @@ def select_files_in_directory(directory: str, ignore_patterns: List[str], projec
634
864
 
635
865
  if file_to_add:
636
866
  selected_files.append(file_to_add)
867
+ selected_files_set.add(os.path.abspath(file_path))
637
868
  # Analyze dependencies immediately after adding
638
869
  new_deps, deps_char_count = _propose_and_add_dependencies(file_path, project_root_abs, selected_files, current_char_count)
639
870
  selected_files.extend(new_deps)
@@ -646,6 +877,7 @@ def select_files_in_directory(directory: str, ignore_patterns: List[str], projec
646
877
  if chunks:
647
878
  selected_files.append((file_path, False, chunks, get_language_for_file(file_path)))
648
879
  current_char_count += char_count
880
+ selected_files_set.add(os.path.abspath(file_path))
649
881
  break
650
882
  elif file_choice == 'q':
651
883
  print(f"Quitting selection for {directory}")
@@ -654,13 +886,18 @@ def select_files_in_directory(directory: str, ignore_patterns: List[str], projec
654
886
  print("Invalid choice. Please enter 'y', 'n', 'p', or 'q'.")
655
887
  print(f"Added {len(selected_files)} files from {directory}")
656
888
  return selected_files, current_char_count
889
+
657
890
  elif choice == 'q':
658
891
  print(f"Quitting selection for {directory}")
659
892
  return [], current_char_count
660
893
  else:
661
894
  print("Invalid choice. Please try again.")
662
895
 
663
- 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
+
664
901
  files_to_include: List[FileTuple] = []
665
902
  processed_dirs: Set[str] = set()
666
903
 
@@ -674,10 +911,16 @@ def process_directory(directory: str, ignore_patterns: List[str], project_root_a
674
911
  print(f"\nExploring directory: {root}")
675
912
  choice = input("(y)es explore / (n)o skip / (q)uit? ").lower()
676
913
  if choice == 'y':
677
- # Pass project_root_abs down
678
- selected_files, current_char_count = select_files_in_directory(root, ignore_patterns, project_root_abs, current_char_count)
679
- # 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
+ )
680
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
+
681
924
  processed_dirs.add(root)
682
925
  elif choice == 'n':
683
926
  dirs[:] = [] # Skip all subdirectories
@@ -722,112 +965,6 @@ def read_env_file():
722
965
  env_vars[key.strip()] = value.strip()
723
966
  return env_vars
724
967
 
725
- def detect_env_variables(content, env_vars):
726
- detected_vars = []
727
- for key, value in env_vars.items():
728
- if value in content:
729
- detected_vars.append((key, value))
730
- return detected_vars
731
-
732
- def handle_env_variables(content, env_vars):
733
- detected_vars = detect_env_variables(content, env_vars)
734
- if not detected_vars:
735
- return content
736
-
737
- print("Detected environment variables:")
738
- for key, value in detected_vars:
739
- print(f"- {key}={value}")
740
-
741
- for key, value in detected_vars:
742
- while True:
743
- choice = input(f"How would you like to handle {key}? (m)ask / (s)kip / (k)eep: ").lower()
744
- if choice in ['m', 's', 'k']:
745
- break
746
- print("Invalid choice. Please enter 'm', 's', or 'k'.")
747
-
748
- if choice == 'm':
749
- content = content.replace(value, '*' * len(value))
750
- elif choice == 's':
751
- content = content.replace(value, "[REDACTED]")
752
- # If 'k', we don't modify the content
753
-
754
- return content
755
-
756
- 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]:
757
- prompt = "# Project Overview\n\n"
758
- prompt += "## Project Structure\n\n"
759
- prompt += "```\n"
760
- prompt += get_project_structure(ignore_patterns)
761
- prompt += "\n```\n\n"
762
- prompt += "## File Contents\n\n"
763
- for file, use_snippet, chunks, content_type in files_to_include:
764
- relative_path = get_relative_path(file)
765
- language = content_type if content_type else get_language_for_file(file)
766
-
767
- if chunks is not None:
768
- prompt += f"### {relative_path} (selected patches)\n\n```{language}\n"
769
- for chunk in chunks:
770
- prompt += f"{chunk}\n"
771
- prompt += "```\n\n"
772
- elif use_snippet:
773
- file_content = get_file_snippet(file)
774
- prompt += f"### {relative_path} (snippet)\n\n```{language}\n{file_content}\n```\n\n"
775
- else:
776
- file_content = read_file_contents(file)
777
- file_content = handle_env_variables(file_content, env_vars)
778
- prompt += f"### {relative_path}\n\n```{language}\n{file_content}\n```\n\n"
779
-
780
- if web_contents:
781
- prompt += "## Web Content\n\n"
782
- for url, (file_tuple, content) in web_contents.items():
783
- _, is_snippet, _, content_type = file_tuple
784
- content = handle_env_variables(content, env_vars)
785
- language = content_type if content_type in ['json', 'csv'] else ''
786
- prompt += f"### {url}{' (snippet)' if is_snippet else ''}\n\n```{language}\n{content}\n```\n\n"
787
-
788
- prompt += "## Task Instructions\n\n"
789
- cursor_position = len(prompt)
790
- prompt += "\n\n"
791
- prompt += "## Instructions for Achieving the Task\n\n"
792
- analysis_text = (
793
- "### Partnership Principles\n\n"
794
- "We work as collaborative partners. You provide technical expertise and critical thinking. "
795
- "I have exclusive access to my codebase, real environment, external services, and actual users. "
796
- "Never assume project file contents - always ask to see them.\n\n"
797
- "**Critical Thinking**: Challenge poor approaches, identify risks, suggest better alternatives. Don't be a yes-man.\n\n"
798
- "**Anti-Hallucination**: Never write placeholder code for files in ## Project Structure. Use [STOP - NEED FILE: filename] and wait.\n\n"
799
- "**Hard Stops**: End with [AWAITING USER RESPONSE] when you need input. Don't continue with assumptions.\n\n"
800
- "### Development Workflow\n\n"
801
- "We work in two modes:\n"
802
- "- **Iterative Mode**: Build incrementally, show only changes\n"
803
- "- **Consolidation Mode**: When I request, provide clean final version\n\n"
804
- "1. **Understand & Analyze**:\n"
805
- " - Rephrase task, identify issues, list needed files\n"
806
- " - Challenge problematic aspects\n"
807
- " - End: 'I need: [files]. Is this correct?' [AWAITING USER RESPONSE]\n\n"
808
- "2. **Plan**:\n"
809
- " - Present 2-3 approaches with pros/cons\n"
810
- " - Recommend best approach\n"
811
- " - End: 'Which approach?' [AWAITING USER RESPONSE]\n\n"
812
- "3. **Implement Iteratively**:\n"
813
- " - Small, testable increments\n"
814
- " - Track failed attempts: `Attempt 1: [FAILED] X→Y (learned: Z)`\n"
815
- " - After 3 failures, request diagnostics\n\n"
816
- "4. **Code Presentation**:\n"
817
- " - Always: `// FILE: path/to/file.ext`\n"
818
- " - Iterative: Show only changes with context\n"
819
- " - Consolidation: Smart choice - minimal changes = show patches, extensive = full file\n\n"
820
- "5. **Test & Validate**:\n"
821
- " - 'Test with: [command]. Share any errors.' [AWAITING USER RESPONSE]\n"
822
- " - Include debug outputs\n"
823
- " - May return to implementation based on results\n\n"
824
- "### Permissions & Restrictions\n\n"
825
- "**You MAY**: Request project files, ask me to test code/services, challenge my approach, refuse without info\n\n"
826
- "**You MUST NOT**: Assume project file contents, continue past [AWAITING USER RESPONSE], be agreeable when you see problems\n"
827
- )
828
- prompt += analysis_text
829
- return prompt, cursor_position
830
-
831
968
  def open_editor_for_input(template: str, cursor_position: int) -> str:
832
969
  editor = os.environ.get('EDITOR', 'vim')
833
970
  with tempfile.NamedTemporaryFile(mode='w+', suffix='.md', delete=False) as temp_file:
@@ -864,14 +1001,16 @@ def main():
864
1001
  env_vars = read_env_file()
865
1002
  project_root_abs = os.path.abspath(os.getcwd())
866
1003
 
867
-
868
1004
  files_to_include: List[FileTuple] = []
869
- processed_dirs = set()
870
1005
  web_contents: Dict[str, Tuple[FileTuple, str]] = {}
871
1006
  current_char_count = 0
872
-
1007
+
1008
+ # Separate URLs from file/directory paths
1009
+ paths_for_tree = []
1010
+
873
1011
  for input_path in args.inputs:
874
1012
  if input_path.startswith(('http://', 'https://')):
1013
+ # Handle web content as before
875
1014
  result = fetch_web_content(input_path)
876
1015
  if result:
877
1016
  file_tuple, full_content, snippet = result
@@ -905,89 +1044,37 @@ def main():
905
1044
  current_char_count += len(content)
906
1045
  print(f"Added {'snippet of ' if is_snippet else ''}web content from: {input_path}")
907
1046
  print_char_count(current_char_count)
908
- elif os.path.isfile(input_path):
909
- abs_input_path = os.path.abspath(input_path)
910
- if not is_ignored(abs_input_path, ignore_patterns) and not is_binary(abs_input_path):
911
- file_size = os.path.getsize(abs_input_path)
912
- file_size_readable = get_human_readable_size(file_size)
913
- file_char_estimate = file_size
914
- language = get_language_for_file(abs_input_path)
915
- file_to_add = None
916
-
917
- if is_large_file(abs_input_path):
918
- print(f"\nFile {get_relative_path(abs_input_path)} ({file_size_readable}, ~{file_char_estimate} chars) is large.")
919
- print("Preview (first ~50 lines or 4KB):")
920
- print(get_colored_file_snippet(abs_input_path))
921
- print("-" * 40)
922
- while True:
923
- print_char_count(current_char_count)
924
- choice = input(f"How to include large file {get_relative_path(abs_input_path)}? (f)ull / (s)nippet / (p)atches / (n)o skip: ").lower()
925
- if choice == 'f':
926
- file_to_add = (abs_input_path, False, None, language)
927
- current_char_count += file_char_estimate
928
- print(f"Added full file: {get_relative_path(abs_input_path)}")
929
- break
930
- elif choice == 's':
931
- snippet_content = get_file_snippet(abs_input_path)
932
- file_to_add = (abs_input_path, True, None, language)
933
- current_char_count += len(snippet_content)
934
- print(f"Added snippet of file: {get_relative_path(abs_input_path)}")
935
- break
936
- elif choice == 'p':
937
- chunks, char_count = select_file_patches(abs_input_path)
938
- if chunks:
939
- file_to_add = (abs_input_path, False, chunks, language)
940
- current_char_count += char_count
941
- print(f"Added selected patches from file: {get_relative_path(abs_input_path)}")
942
- else:
943
- print(f"No patches selected for {get_relative_path(abs_input_path)}. Skipping file.")
944
- break
945
- elif choice == 'n':
946
- print(f"Skipped large file: {get_relative_path(abs_input_path)}")
947
- break
948
- else:
949
- print("Invalid choice. Please enter 'f', 's', 'p', or 'n'.")
950
- else:
951
- file_to_add = (abs_input_path, False, None, language)
952
- current_char_count += file_char_estimate
953
- print(f"Added file: {get_relative_path(abs_input_path)} ({file_size_readable})")
954
-
955
- if file_to_add:
956
- files_to_include.append(file_to_add)
957
- # --- NEW: Call dependency analysis ---
958
- new_deps, deps_char_count = _propose_and_add_dependencies(abs_input_path, project_root_abs, files_to_include, current_char_count)
959
- files_to_include.extend(new_deps)
960
- current_char_count += deps_char_count
961
-
962
- print_char_count(current_char_count)
963
-
964
- else:
965
- if is_ignored(input_path, ignore_patterns):
966
- print(f"Ignoring file based on ignore patterns: {input_path}")
967
- elif is_binary(input_path):
968
- print(f"Ignoring binary file: {input_path}")
969
- else:
970
- print(f"Ignoring file: {input_path}")
971
- elif os.path.isdir(input_path):
972
- print(f"\nProcessing directory specified directly: {input_path}")
973
- # Pass project_root_abs to process_directory
974
- dir_files, dir_processed, current_char_count = process_directory(input_path, ignore_patterns, project_root_abs, current_char_count)
975
- files_to_include.extend(dir_files)
976
- processed_dirs.update(dir_processed)
977
1047
  else:
978
- 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
979
1067
 
980
1068
  if not files_to_include and not web_contents:
981
1069
  print("No files or web content were selected. Exiting.")
982
1070
  return
983
1071
 
984
1072
  print("\nFile and web content selection complete.")
985
- print_char_count(current_char_count) # Print final count before prompt generation
1073
+ print_char_count(current_char_count)
986
1074
 
987
1075
  added_files_count = len(files_to_include)
988
- added_dirs_count = len(processed_dirs)
989
1076
  added_web_count = len(web_contents)
990
- 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.")
991
1078
 
992
1079
  prompt_template, cursor_position = generate_prompt_template(files_to_include, ignore_patterns, web_contents, env_vars)
993
1080