kopipasta 0.38.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 ADDED
@@ -0,0 +1,1449 @@
1
+ #!/usr/bin/env python3
2
+ import os
3
+ import argparse
4
+ import re
5
+ import subprocess
6
+ import tempfile
7
+ import shutil
8
+ from typing import Dict, List, Optional, Set, Tuple
9
+ import pyperclip
10
+ from pygments import highlight
11
+ from pygments.lexers import get_lexer_for_filename, TextLexer
12
+ from pygments.formatters import TerminalFormatter
13
+ import pygments.util
14
+
15
+ import requests
16
+
17
+ from kopipasta.file import (
18
+ FileTuple,
19
+ get_human_readable_size,
20
+ is_binary,
21
+ is_ignored,
22
+ is_large_file,
23
+ read_file_contents,
24
+ )
25
+ import kopipasta.import_parser as import_parser
26
+ from kopipasta.tree_selector import TreeSelector
27
+ from kopipasta.prompt import (
28
+ generate_prompt_template,
29
+ get_file_snippet,
30
+ get_language_for_file,
31
+ )
32
+ from kopipasta.cache import save_selection_to_cache
33
+
34
+
35
+ def _propose_and_add_dependencies(
36
+ file_just_added: str,
37
+ project_root_abs: str,
38
+ files_to_include: List[FileTuple],
39
+ current_char_count: int,
40
+ ) -> Tuple[List[FileTuple], int]:
41
+ """
42
+ Analyzes a file for local dependencies and interactively asks the user to add them.
43
+ """
44
+ language = get_language_for_file(file_just_added)
45
+ if language not in ["python", "typescript", "javascript", "tsx", "jsx"]:
46
+ return [], 0 # Only analyze languages we can parse
47
+
48
+ print(f"Analyzing {os.path.relpath(file_just_added)} for local dependencies...")
49
+
50
+ try:
51
+ file_content = read_file_contents(file_just_added)
52
+ if not file_content:
53
+ return [], 0
54
+
55
+ resolved_deps_abs: Set[str] = set()
56
+ if language == "python":
57
+ resolved_deps_abs = import_parser.parse_python_imports(
58
+ file_content, file_just_added, project_root_abs
59
+ )
60
+ elif language in ["typescript", "javascript", "tsx", "jsx"]:
61
+ resolved_deps_abs = import_parser.parse_typescript_imports(
62
+ file_content, file_just_added, project_root_abs
63
+ )
64
+
65
+ # Filter out dependencies that are already in the context
66
+ included_paths = {os.path.abspath(f[0]) for f in files_to_include}
67
+ suggested_deps = sorted(
68
+ [
69
+ dep
70
+ for dep in resolved_deps_abs
71
+ if os.path.abspath(dep) not in included_paths
72
+ and os.path.abspath(dep) != os.path.abspath(file_just_added)
73
+ ]
74
+ )
75
+
76
+ if not suggested_deps:
77
+ print("No new local dependencies found.")
78
+ return [], 0
79
+
80
+ print(
81
+ f"\nFound {len(suggested_deps)} new local {'dependency' if len(suggested_deps) == 1 else 'dependencies'}:"
82
+ )
83
+ for i, dep_path in enumerate(suggested_deps):
84
+ print(f" ({i+1}) {os.path.relpath(dep_path)}")
85
+
86
+ while True:
87
+ choice = input(
88
+ "\nAdd dependencies? (a)ll, (n)one, or enter numbers (e.g. 1, 3-4): "
89
+ ).lower()
90
+
91
+ deps_to_add_paths = None
92
+ if choice == "a":
93
+ deps_to_add_paths = suggested_deps
94
+ break
95
+ if choice == "n":
96
+ deps_to_add_paths = []
97
+ print(f"Skipped {len(suggested_deps)} dependencies.")
98
+ break
99
+
100
+ # Try to parse the input as numbers directly.
101
+ try:
102
+ selected_indices = set()
103
+ parts = choice.replace(" ", "").split(",")
104
+ if all(p.strip() for p in parts): # Ensure no empty parts like in "1,"
105
+ for part in parts:
106
+ if "-" in part:
107
+ start_str, end_str = part.split("-", 1)
108
+ start = int(start_str)
109
+ end = int(end_str)
110
+ if start > end:
111
+ start, end = end, start
112
+ selected_indices.update(range(start - 1, end))
113
+ else:
114
+ selected_indices.add(int(part) - 1)
115
+
116
+ # Validate that all selected numbers are within the valid range
117
+ if all(0 <= i < len(suggested_deps) for i in selected_indices):
118
+ deps_to_add_paths = [
119
+ suggested_deps[i] for i in sorted(list(selected_indices))
120
+ ]
121
+ break # Success! Exit the loop.
122
+ else:
123
+ print(
124
+ f"Error: Invalid number selection. Please choose numbers between 1 and {len(suggested_deps)}."
125
+ )
126
+ else:
127
+ raise ValueError("Empty part detected in input.")
128
+
129
+ except ValueError:
130
+ # This will catch any input that isn't 'a', 'n', or a valid number/range.
131
+ print(
132
+ "Invalid choice. Please enter 'a', 'n', or a list/range of numbers (e.g., '1,3' or '2-4')."
133
+ )
134
+
135
+ if not deps_to_add_paths:
136
+ return [], 0 # No dependencies were selected
137
+
138
+ newly_added_files: List[FileTuple] = []
139
+ char_count_delta = 0
140
+ for dep_path in deps_to_add_paths:
141
+ # Assume non-large for now for simplicity, can be enhanced later
142
+ file_size = os.path.getsize(dep_path)
143
+ newly_added_files.append(
144
+ (dep_path, False, None, get_language_for_file(dep_path))
145
+ )
146
+ char_count_delta += file_size
147
+ print(
148
+ f"Added dependency: {os.path.relpath(dep_path)} ({get_human_readable_size(file_size)})"
149
+ )
150
+
151
+ return newly_added_files, char_count_delta
152
+
153
+ except Exception as e:
154
+ print(
155
+ f"Warning: Could not analyze dependencies for {os.path.relpath(file_just_added)}: {e}"
156
+ )
157
+ return [], 0
158
+
159
+
160
+ def get_colored_code(file_path, code):
161
+ try:
162
+ lexer = get_lexer_for_filename(file_path)
163
+ except pygments.util.ClassNotFound:
164
+ lexer = TextLexer()
165
+ return highlight(code, lexer, TerminalFormatter())
166
+
167
+
168
+ def read_gitignore():
169
+ default_ignore_patterns = [
170
+ ".git",
171
+ "node_modules",
172
+ "venv",
173
+ ".venv",
174
+ "dist",
175
+ ".idea",
176
+ "__pycache__",
177
+ "*.pyc",
178
+ ".ruff_cache",
179
+ ".mypy_cache",
180
+ ".pytest_cache",
181
+ ".vscode",
182
+ ".vite",
183
+ ".terraform",
184
+ "output",
185
+ "poetry.lock",
186
+ "package-lock.json",
187
+ ".env",
188
+ "*.log",
189
+ "*.bak",
190
+ "*.swp",
191
+ "*.swo",
192
+ "*.tmp",
193
+ "tmp",
194
+ "temp",
195
+ "logs",
196
+ "build",
197
+ "target",
198
+ ".DS_Store",
199
+ "Thumbs.db",
200
+ "*.class",
201
+ "*.jar",
202
+ "*.war",
203
+ "*.ear",
204
+ "*.sqlite",
205
+ "*.db",
206
+ "*.jpg",
207
+ "*.jpeg",
208
+ "*.png",
209
+ "*.gif",
210
+ "*.bmp",
211
+ "*.tiff",
212
+ "*.ico",
213
+ "*.svg",
214
+ "*.webp",
215
+ "*.mp3",
216
+ "*.mp4",
217
+ "*.avi",
218
+ "*.mov",
219
+ "*.wmv",
220
+ "*.flv",
221
+ "*.pdf",
222
+ "*.doc",
223
+ "*.docx",
224
+ "*.xls",
225
+ "*.xlsx",
226
+ "*.ppt",
227
+ "*.pptx",
228
+ "*.zip",
229
+ "*.rar",
230
+ "*.tar",
231
+ "*.gz",
232
+ "*.7z",
233
+ "*.exe",
234
+ "*.dll",
235
+ "*.so",
236
+ "*.dylib",
237
+ ]
238
+ gitignore_patterns = default_ignore_patterns.copy()
239
+
240
+ if os.path.exists(".gitignore"):
241
+ print(".gitignore detected.")
242
+ with open(".gitignore", "r") as file:
243
+ for line in file:
244
+ line = line.strip()
245
+ if line and not line.startswith("#"):
246
+ gitignore_patterns.append(line)
247
+ return gitignore_patterns
248
+
249
+
250
+ def split_python_file(file_content):
251
+ """
252
+ Splits Python code into logical chunks using the AST module.
253
+ Ensures each chunk is at least 10 lines.
254
+ Returns a list of tuples: (chunk_code, start_line, end_line)
255
+ """
256
+ import ast
257
+
258
+ tree = ast.parse(file_content)
259
+ chunks = []
260
+ prev_end = 0
261
+ lines = file_content.splitlines(keepends=True)
262
+
263
+ def get_code(start, end):
264
+ return "".join(lines[start:end])
265
+
266
+ nodes = [node for node in ast.iter_child_nodes(tree) if hasattr(node, "lineno")]
267
+
268
+ i = 0
269
+ while i < len(nodes):
270
+ node = nodes[i]
271
+ start_line = node.lineno - 1 # Convert to 0-indexed
272
+ end_line = getattr(node, "end_lineno", None)
273
+ if end_line is None:
274
+ end_line = start_line + 1
275
+
276
+ # Merge chunks to meet minimum lines
277
+ chunk_start = start_line
278
+ chunk_end = end_line
279
+ while (chunk_end - chunk_start) < 10 and i + 1 < len(nodes):
280
+ i += 1
281
+ next_node = nodes[i]
282
+ next_start = next_node.lineno - 1
283
+ next_end = getattr(next_node, "end_lineno", None) or next_start + 1
284
+ chunk_end = next_end
285
+
286
+ # Add code before the node (e.g., imports or global code)
287
+ if prev_end < chunk_start:
288
+ code = get_code(prev_end, chunk_start)
289
+ if code.strip():
290
+ chunks.append((code, prev_end, chunk_start))
291
+
292
+ # Add the merged chunk
293
+ code = get_code(chunk_start, chunk_end)
294
+ chunks.append((code, chunk_start, chunk_end))
295
+ prev_end = chunk_end
296
+ i += 1
297
+
298
+ # Add any remaining code at the end
299
+ if prev_end < len(lines):
300
+ code = get_code(prev_end, len(lines))
301
+ if code.strip():
302
+ chunks.append((code, prev_end, len(lines)))
303
+
304
+ return merge_small_chunks(chunks)
305
+
306
+
307
+ def merge_small_chunks(chunks, min_lines=10):
308
+ """
309
+ Merges chunks to ensure each has at least min_lines lines.
310
+ """
311
+ merged_chunks = []
312
+ buffer_code = ""
313
+ buffer_start = None
314
+ buffer_end = None
315
+
316
+ for code, start_line, end_line in chunks:
317
+ num_lines = end_line - start_line
318
+ if buffer_code == "":
319
+ buffer_code = code
320
+ buffer_start = start_line
321
+ buffer_end = end_line
322
+ else:
323
+ buffer_code += code
324
+ buffer_end = end_line
325
+
326
+ if (buffer_end - buffer_start) >= min_lines:
327
+ merged_chunks.append((buffer_code, buffer_start, buffer_end))
328
+ buffer_code = ""
329
+ buffer_start = None
330
+ buffer_end = None
331
+
332
+ if buffer_code:
333
+ merged_chunks.append((buffer_code, buffer_start, buffer_end))
334
+
335
+ return merged_chunks
336
+
337
+
338
+ def split_javascript_file(file_content):
339
+ """
340
+ Splits JavaScript code into logical chunks using regular expressions.
341
+ Returns a list of tuples: (chunk_code, start_line, end_line)
342
+ """
343
+ lines = file_content.splitlines(keepends=True)
344
+ chunks = []
345
+ pattern = re.compile(
346
+ r"^\s*(export\s+)?(async\s+)?(function\s+\w+|class\s+\w+|\w+\s*=\s*\(.*?\)\s*=>)",
347
+ re.MULTILINE,
348
+ )
349
+ matches = list(pattern.finditer(file_content))
350
+
351
+ if not matches:
352
+ return [(file_content, 0, len(lines))]
353
+
354
+ prev_end_line = 0
355
+ for match in matches:
356
+ start_index = match.start()
357
+ start_line = file_content.count("\n", 0, start_index)
358
+ if prev_end_line < start_line:
359
+ code = "".join(lines[prev_end_line:start_line])
360
+ chunks.append((code, prev_end_line, start_line))
361
+
362
+ function_code_lines = []
363
+ brace_count = 0
364
+ in_block = False
365
+ for i in range(start_line, len(lines)):
366
+ line = lines[i]
367
+ function_code_lines.append(line)
368
+ brace_count += line.count("{") - line.count("}")
369
+ if "{" in line:
370
+ in_block = True
371
+ if in_block and brace_count == 0:
372
+ end_line = i + 1
373
+ code = "".join(function_code_lines)
374
+ chunks.append((code, start_line, end_line))
375
+ prev_end_line = end_line
376
+ break
377
+ else:
378
+ end_line = len(lines)
379
+ code = "".join(function_code_lines)
380
+ chunks.append((code, start_line, end_line))
381
+ prev_end_line = end_line
382
+
383
+ if prev_end_line < len(lines):
384
+ code = "".join(lines[prev_end_line:])
385
+ chunks.append((code, prev_end_line, len(lines)))
386
+
387
+ return merge_small_chunks(chunks)
388
+
389
+
390
+ def split_html_file(file_content):
391
+ """
392
+ Splits HTML code into logical chunks based on top-level elements using regular expressions.
393
+ Returns a list of tuples: (chunk_code, start_line, end_line)
394
+ """
395
+ pattern = re.compile(r"<(?P<tag>\w+)(\s|>).*?</(?P=tag)>", re.DOTALL)
396
+ lines = file_content.splitlines(keepends=True)
397
+ chunks = []
398
+ matches = list(pattern.finditer(file_content))
399
+
400
+ if not matches:
401
+ return [(file_content, 0, len(lines))]
402
+
403
+ prev_end = 0
404
+ for match in matches:
405
+ start_index = match.start()
406
+ end_index = match.end()
407
+ start_line = file_content.count("\n", 0, start_index)
408
+ end_line = file_content.count("\n", 0, end_index)
409
+
410
+ if prev_end < start_line:
411
+ code = "".join(lines[prev_end:start_line])
412
+ chunks.append((code, prev_end, start_line))
413
+
414
+ code = "".join(lines[start_line:end_line])
415
+ chunks.append((code, start_line, end_line))
416
+ prev_end = end_line
417
+
418
+ if prev_end < len(lines):
419
+ code = "".join(lines[prev_end:])
420
+ chunks.append((code, prev_end, len(lines)))
421
+
422
+ return merge_small_chunks(chunks)
423
+
424
+
425
+ def split_c_file(file_content):
426
+ """
427
+ Splits C/C++ code into logical chunks using regular expressions.
428
+ Returns a list of tuples: (chunk_code, start_line, end_line)
429
+ """
430
+ pattern = re.compile(r"^\s*(?:[\w\*\s]+)\s+(\w+)\s*\([^)]*\)\s*\{", re.MULTILINE)
431
+ lines = file_content.splitlines(keepends=True)
432
+ chunks = []
433
+ matches = list(pattern.finditer(file_content))
434
+
435
+ if not matches:
436
+ return [(file_content, 0, len(lines))]
437
+
438
+ prev_end_line = 0
439
+ for match in matches:
440
+ start_index = match.start()
441
+ start_line = file_content.count("\n", 0, start_index)
442
+ if prev_end_line < start_line:
443
+ code = "".join(lines[prev_end_line:start_line])
444
+ chunks.append((code, prev_end_line, start_line))
445
+
446
+ function_code_lines = []
447
+ brace_count = 0
448
+ in_function = False
449
+ for i in range(start_line, len(lines)):
450
+ line = lines[i]
451
+ function_code_lines.append(line)
452
+ brace_count += line.count("{") - line.count("}")
453
+ if "{" in line:
454
+ in_function = True
455
+ if in_function and brace_count == 0:
456
+ end_line = i + 1
457
+ code = "".join(function_code_lines)
458
+ chunks.append((code, start_line, end_line))
459
+ prev_end_line = end_line
460
+ break
461
+ else:
462
+ end_line = len(lines)
463
+ code = "".join(function_code_lines)
464
+ chunks.append((code, start_line, end_line))
465
+ prev_end_line = end_line
466
+
467
+ if prev_end_line < len(lines):
468
+ code = "".join(lines[prev_end_line:])
469
+ chunks.append((code, prev_end_line, len(lines)))
470
+
471
+ return merge_small_chunks(chunks)
472
+
473
+
474
+ def split_generic_file(file_content):
475
+ """
476
+ Splits generic text files into chunks based on double newlines.
477
+ Returns a list of tuples: (chunk_code, start_line, end_line)
478
+ """
479
+ lines = file_content.splitlines(keepends=True)
480
+ chunks = []
481
+ start = 0
482
+ for i, line in enumerate(lines):
483
+ if line.strip() == "":
484
+ if start < i:
485
+ chunk_code = "".join(lines[start:i])
486
+ chunks.append((chunk_code, start, i))
487
+ start = i + 1
488
+ if start < len(lines):
489
+ chunk_code = "".join(lines[start:])
490
+ chunks.append((chunk_code, start, len(lines)))
491
+ return merge_small_chunks(chunks)
492
+
493
+
494
+ def select_file_patches(file_path):
495
+ file_content = read_file_contents(file_path)
496
+ language = get_language_for_file(file_path)
497
+ chunks = []
498
+ total_char_count = 0
499
+
500
+ if language == "python":
501
+ code_chunks = split_python_file(file_content)
502
+ elif language == "javascript":
503
+ code_chunks = split_javascript_file(file_content)
504
+ elif language == "html":
505
+ code_chunks = split_html_file(file_content)
506
+ elif language in ["c", "cpp"]:
507
+ code_chunks = split_c_file(file_content)
508
+ else:
509
+ code_chunks = split_generic_file(file_content)
510
+ placeholder = get_placeholder_comment(language)
511
+
512
+ print(f"\nSelecting patches for {file_path}")
513
+ for index, (chunk_code, start_line, end_line) in enumerate(code_chunks):
514
+ print(f"\nChunk {index + 1} (Lines {start_line + 1}-{end_line}):")
515
+ colored_chunk = get_colored_code(file_path, chunk_code)
516
+ print(colored_chunk)
517
+ while True:
518
+ choice = input("(y)es include / (n)o skip / (q)uit rest of file? ").lower()
519
+ if choice == "y":
520
+ chunks.append(chunk_code)
521
+ total_char_count += len(chunk_code)
522
+ break
523
+ elif choice == "n":
524
+ if not chunks or chunks[-1] != placeholder:
525
+ chunks.append(placeholder)
526
+ total_char_count += len(placeholder)
527
+ break
528
+ elif choice == "q":
529
+ print("Skipping the rest of the file.")
530
+ if chunks and chunks[-1] != placeholder:
531
+ chunks.append(placeholder)
532
+ return chunks, total_char_count
533
+ else:
534
+ print("Invalid choice. Please enter 'y', 'n', or 'q'.")
535
+
536
+ return chunks, total_char_count
537
+
538
+
539
+ def get_placeholder_comment(language):
540
+ comments = {
541
+ "python": "# Skipped content\n",
542
+ "javascript": "// Skipped content\n",
543
+ "typescript": "// Skipped content\n",
544
+ "java": "// Skipped content\n",
545
+ "c": "// Skipped content\n",
546
+ "cpp": "// Skipped content\n",
547
+ "html": "<!-- Skipped content -->\n",
548
+ "css": "/* Skipped content */\n",
549
+ "default": "# Skipped content\n",
550
+ }
551
+ return comments.get(language, comments["default"])
552
+
553
+
554
+ def get_colored_file_snippet(file_path, max_lines=50, max_bytes=4096):
555
+ snippet = get_file_snippet(file_path, max_lines, max_bytes)
556
+ return get_colored_code(file_path, snippet)
557
+
558
+
559
+ def print_char_count(count):
560
+ token_estimate = count // 4
561
+ print(
562
+ f"\rCurrent prompt size: {count} characters (~ {token_estimate} tokens)",
563
+ flush=True,
564
+ )
565
+
566
+
567
+ def grep_files_in_directory(
568
+ pattern: str, directory: str, ignore_patterns: List[str]
569
+ ) -> List[Tuple[str, List[str], int]]:
570
+ """
571
+ Search for files containing a pattern using ag (silver searcher).
572
+ Returns list of (filepath, preview_lines, match_count).
573
+ """
574
+ # Check if ag is available
575
+ if not shutil.which("ag"):
576
+ print("Silver Searcher (ag) not found. Install it for grep functionality:")
577
+ print(" - Mac: brew install the_silver_searcher")
578
+ print(" - Ubuntu/Debian: apt-get install silversearcher-ag")
579
+ print(" - Other: https://github.com/ggreer/the_silver_searcher")
580
+ return []
581
+
582
+ try:
583
+ # First get files with matches
584
+ cmd = [
585
+ "ag",
586
+ "--files-with-matches",
587
+ "--nocolor",
588
+ "--ignore-case",
589
+ pattern,
590
+ directory,
591
+ ]
592
+
593
+ result = subprocess.run(cmd, capture_output=True, text=True)
594
+ if result.returncode != 0 or not result.stdout.strip():
595
+ return []
596
+
597
+ files = result.stdout.strip().split("\n")
598
+ grep_results = []
599
+
600
+ for file in files:
601
+ if is_ignored(file, ignore_patterns, directory) or is_binary(file):
602
+ continue
603
+
604
+ # Get match count and preview lines
605
+ count_cmd = ["ag", "--count", "--nocolor", pattern, file]
606
+ count_result = subprocess.run(count_cmd, capture_output=True, text=True)
607
+ match_count = 0
608
+ if count_result.stdout:
609
+ # ag --count outputs "filename:count"
610
+ # We need to handle filenames that might contain colons
611
+ stdout_line = count_result.stdout.strip()
612
+ # Find the last colon to separate filename from count
613
+ last_colon_idx = stdout_line.rfind(":")
614
+ if last_colon_idx > 0:
615
+ try:
616
+ match_count = int(stdout_line[last_colon_idx + 1 :])
617
+ except ValueError:
618
+ match_count = 1
619
+ else:
620
+ match_count = 1
621
+
622
+ # Get preview of matches (up to 3 lines)
623
+ preview_cmd = [
624
+ "ag",
625
+ "--max-count=3",
626
+ "--nocolor",
627
+ "--noheading",
628
+ "--numbers",
629
+ pattern,
630
+ file,
631
+ ]
632
+ preview_result = subprocess.run(preview_cmd, capture_output=True, text=True)
633
+ preview_lines = []
634
+ if preview_result.stdout:
635
+ for line in preview_result.stdout.strip().split("\n")[:3]:
636
+ # Format: "line_num:content"
637
+ if ":" in line:
638
+ line_num, content = line.split(":", 1)
639
+ preview_lines.append(f" {line_num}: {content.strip()}")
640
+ else:
641
+ preview_lines.append(f" {line.strip()}")
642
+
643
+ grep_results.append((file, preview_lines, match_count))
644
+
645
+ return sorted(grep_results)
646
+
647
+ except Exception as e:
648
+ print(f"Error running ag: {e}")
649
+ return []
650
+
651
+
652
+ def select_from_grep_results(
653
+ grep_results: List[Tuple[str, List[str], int]], current_char_count: int
654
+ ) -> Tuple[List[FileTuple], int]:
655
+ """
656
+ Let user select from grep results.
657
+ Returns (selected_files, new_char_count).
658
+ """
659
+ if not grep_results:
660
+ return [], current_char_count
661
+
662
+ print(f"\nFound {len(grep_results)} files:")
663
+ for i, (file_path, preview_lines, match_count) in enumerate(grep_results):
664
+ file_size = os.path.getsize(file_path)
665
+ file_size_readable = get_human_readable_size(file_size)
666
+ print(
667
+ f"\n{i+1}. {os.path.relpath(file_path)} ({file_size_readable}) - {match_count} {'match' if match_count == 1 else 'matches'}"
668
+ )
669
+ for preview_line in preview_lines[:3]:
670
+ print(preview_line)
671
+ if match_count > 3:
672
+ print(f" ... and {match_count - 3} more matches")
673
+
674
+ while True:
675
+ print_char_count(current_char_count)
676
+ choice = input(
677
+ "\nSelect grep results: (a)ll / (n)one / (s)elect individually / numbers (e.g. 1,3-4) / (q)uit? "
678
+ ).lower()
679
+
680
+ selected_files: List[FileTuple] = []
681
+ char_count_delta = 0
682
+
683
+ if choice == "a":
684
+ for file_path, _, _ in grep_results:
685
+ file_size = os.path.getsize(file_path)
686
+ selected_files.append(
687
+ (file_path, False, None, get_language_for_file(file_path))
688
+ )
689
+ char_count_delta += file_size
690
+ print(f"Added all {len(grep_results)} files from grep results.")
691
+ return selected_files, current_char_count + char_count_delta
692
+
693
+ elif choice == "n":
694
+ print("Skipped all grep results.")
695
+ return [], current_char_count
696
+
697
+ elif choice == "q":
698
+ print("Cancelled grep selection.")
699
+ return [], current_char_count
700
+
701
+ elif choice == "s":
702
+ for i, (file_path, preview_lines, match_count) in enumerate(grep_results):
703
+ file_size = os.path.getsize(file_path)
704
+ file_size_readable = get_human_readable_size(file_size)
705
+ file_char_estimate = file_size
706
+ file_token_estimate = file_char_estimate // 4
707
+
708
+ print(
709
+ f"\n{os.path.relpath(file_path)} ({file_size_readable}, ~{file_char_estimate} chars, ~{file_token_estimate} tokens)"
710
+ )
711
+ print(
712
+ f"{match_count} {'match' if match_count == 1 else 'matches'} for search pattern"
713
+ )
714
+
715
+ while True:
716
+ print_char_count(current_char_count + char_count_delta)
717
+ file_choice = input("(y)es / (n)o / (q)uit? ").lower()
718
+
719
+ if file_choice == "y":
720
+ if is_large_file(file_path):
721
+ while True:
722
+ snippet_choice = input(
723
+ f"File is large. Use (f)ull content or (s)nippet? "
724
+ ).lower()
725
+ if snippet_choice in ["f", "s"]:
726
+ break
727
+ print("Invalid choice. Please enter 'f' or 's'.")
728
+ if snippet_choice == "s":
729
+ selected_files.append(
730
+ (
731
+ file_path,
732
+ True,
733
+ None,
734
+ get_language_for_file(file_path),
735
+ )
736
+ )
737
+ char_count_delta += len(get_file_snippet(file_path))
738
+ else:
739
+ selected_files.append(
740
+ (
741
+ file_path,
742
+ False,
743
+ None,
744
+ get_language_for_file(file_path),
745
+ )
746
+ )
747
+ char_count_delta += file_size
748
+ else:
749
+ selected_files.append(
750
+ (
751
+ file_path,
752
+ False,
753
+ None,
754
+ get_language_for_file(file_path),
755
+ )
756
+ )
757
+ char_count_delta += file_size
758
+ print(f"Added: {os.path.relpath(file_path)}")
759
+ break
760
+ elif file_choice == "n":
761
+ break
762
+ elif file_choice == "q":
763
+ print(f"Added {len(selected_files)} files from grep results.")
764
+ return selected_files, current_char_count + char_count_delta
765
+ else:
766
+ print("Invalid choice. Please enter 'y', 'n', or 'q'.")
767
+
768
+ print(f"Added {len(selected_files)} files from grep results.")
769
+ return selected_files, current_char_count + char_count_delta
770
+
771
+ else:
772
+ # Try to parse number selection
773
+ try:
774
+ selected_indices = set()
775
+ parts = choice.replace(" ", "").split(",")
776
+ if all(p.strip() for p in parts):
777
+ for part in parts:
778
+ if "-" in part:
779
+ start_str, end_str = part.split("-", 1)
780
+ start = int(start_str)
781
+ end = int(end_str)
782
+ if start > end:
783
+ start, end = end, start
784
+ selected_indices.update(range(start - 1, end))
785
+ else:
786
+ selected_indices.add(int(part) - 1)
787
+
788
+ if all(0 <= i < len(grep_results) for i in selected_indices):
789
+ for i in sorted(selected_indices):
790
+ file_path, _, _ = grep_results[i]
791
+ file_size = os.path.getsize(file_path)
792
+ selected_files.append(
793
+ (
794
+ file_path,
795
+ False,
796
+ None,
797
+ get_language_for_file(file_path),
798
+ )
799
+ )
800
+ char_count_delta += file_size
801
+ print(f"Added {len(selected_files)} files from grep results.")
802
+ return selected_files, current_char_count + char_count_delta
803
+ else:
804
+ print(
805
+ f"Error: Invalid number selection. Please choose numbers between 1 and {len(grep_results)}."
806
+ )
807
+ else:
808
+ raise ValueError("Empty part detected in input.")
809
+ except ValueError:
810
+ print(
811
+ "Invalid choice. Please enter 'a', 'n', 's', 'q', or a list/range of numbers."
812
+ )
813
+
814
+
815
+ def select_files_in_directory(
816
+ directory: str,
817
+ ignore_patterns: List[str],
818
+ project_root_abs: str,
819
+ current_char_count: int = 0,
820
+ selected_files_set: Optional[Set[str]] = None,
821
+ ) -> Tuple[List[FileTuple], int]:
822
+ if selected_files_set is None:
823
+ selected_files_set = set()
824
+
825
+ files = [
826
+ f
827
+ for f in os.listdir(directory)
828
+ if os.path.isfile(os.path.join(directory, f))
829
+ and not is_ignored(os.path.join(directory, f), ignore_patterns)
830
+ and not is_binary(os.path.join(directory, f))
831
+ ]
832
+
833
+ if not files:
834
+ return [], current_char_count
835
+
836
+ print(f"\nDirectory: {directory}")
837
+ print("Files:")
838
+ for file in files:
839
+ file_path = os.path.join(directory, file)
840
+ file_size = os.path.getsize(file_path)
841
+ file_size_readable = get_human_readable_size(file_size)
842
+ file_char_estimate = file_size # Assuming 1 byte ā‰ˆ 1 character for text files
843
+ file_token_estimate = file_char_estimate // 4
844
+
845
+ # Show if already selected
846
+ if os.path.abspath(file_path) in selected_files_set:
847
+ print(
848
+ f"āœ“ {file} ({file_size_readable}, ~{file_char_estimate} chars, ~{file_token_estimate} tokens) [already selected]"
849
+ )
850
+ else:
851
+ print(
852
+ f"- {file} ({file_size_readable}, ~{file_char_estimate} chars, ~{file_token_estimate} tokens)"
853
+ )
854
+
855
+ while True:
856
+ print_char_count(current_char_count)
857
+ choice = input(
858
+ "(y)es add all / (n)o ignore all / (s)elect individually / (g)rep / (q)uit? "
859
+ ).lower()
860
+ selected_files: List[FileTuple] = []
861
+ char_count_delta = 0
862
+
863
+ if choice == "g":
864
+ # Grep functionality
865
+ pattern = input("\nEnter search pattern: ")
866
+ if pattern:
867
+ print(f"\nSearching in {directory} for '{pattern}'...")
868
+ grep_results = grep_files_in_directory(
869
+ pattern, directory, ignore_patterns
870
+ )
871
+
872
+ if not grep_results:
873
+ print(f"No files found matching '{pattern}'")
874
+ continue
875
+
876
+ grep_selected, new_char_count = select_from_grep_results(
877
+ grep_results, current_char_count
878
+ )
879
+
880
+ if grep_selected:
881
+ selected_files.extend(grep_selected)
882
+ current_char_count = new_char_count
883
+
884
+ # Update selected files set
885
+ for file_tuple in grep_selected:
886
+ selected_files_set.add(os.path.abspath(file_tuple[0]))
887
+
888
+ # Analyze dependencies for grep-selected files
889
+ files_to_analyze = [f[0] for f in grep_selected]
890
+ for file_path in files_to_analyze:
891
+ new_deps, deps_char_count = _propose_and_add_dependencies(
892
+ file_path,
893
+ project_root_abs,
894
+ selected_files,
895
+ current_char_count,
896
+ )
897
+ selected_files.extend(new_deps)
898
+ current_char_count += deps_char_count
899
+
900
+ # Update selected files set with dependencies
901
+ for dep_tuple in new_deps:
902
+ selected_files_set.add(os.path.abspath(dep_tuple[0]))
903
+
904
+ print(f"\nReturning to directory: {directory}")
905
+ # Re-show the directory with updated selections
906
+ print("Files:")
907
+ for file in files:
908
+ file_path = os.path.join(directory, file)
909
+ file_size = os.path.getsize(file_path)
910
+ file_size_readable = get_human_readable_size(file_size)
911
+ if os.path.abspath(file_path) in selected_files_set:
912
+ print(f"āœ“ {file} ({file_size_readable}) [already selected]")
913
+ else:
914
+ print(f"- {file} ({file_size_readable})")
915
+
916
+ # Ask what to do with remaining files
917
+ remaining_files = [
918
+ f
919
+ for f in files
920
+ if os.path.abspath(os.path.join(directory, f))
921
+ not in selected_files_set
922
+ ]
923
+ if remaining_files:
924
+ while True:
925
+ print_char_count(current_char_count)
926
+ remaining_choice = input(
927
+ "(y)es add remaining / (n)o skip remaining / (s)elect more / (g)rep again / (q)uit? "
928
+ ).lower()
929
+ if remaining_choice == "y":
930
+ # Add all remaining files
931
+ for file in remaining_files:
932
+ file_path = os.path.join(directory, file)
933
+ file_size = os.path.getsize(file_path)
934
+ selected_files.append(
935
+ (
936
+ file_path,
937
+ False,
938
+ None,
939
+ get_language_for_file(file_path),
940
+ )
941
+ )
942
+ current_char_count += file_size
943
+ selected_files_set.add(os.path.abspath(file_path))
944
+
945
+ # Analyze dependencies for remaining files
946
+ for file in remaining_files:
947
+ file_path = os.path.join(directory, file)
948
+ (
949
+ new_deps,
950
+ deps_char_count,
951
+ ) = _propose_and_add_dependencies(
952
+ file_path,
953
+ project_root_abs,
954
+ selected_files,
955
+ current_char_count,
956
+ )
957
+ selected_files.extend(new_deps)
958
+ current_char_count += deps_char_count
959
+
960
+ print(f"Added all remaining files from {directory}")
961
+ return selected_files, current_char_count
962
+ elif remaining_choice == "n":
963
+ print(f"Skipped remaining files from {directory}")
964
+ return selected_files, current_char_count
965
+ elif remaining_choice == "s":
966
+ # Continue to individual selection
967
+ choice = "s"
968
+ break
969
+ elif remaining_choice == "g":
970
+ # Continue to grep again
971
+ choice = "g"
972
+ break
973
+ elif remaining_choice == "q":
974
+ return selected_files, current_char_count
975
+ else:
976
+ print("Invalid choice. Please try again.")
977
+
978
+ if choice == "s":
979
+ # Fall through to individual selection
980
+ pass
981
+ elif choice == "g":
982
+ # Loop back to grep
983
+ continue
984
+ else:
985
+ # No remaining files
986
+ return selected_files, current_char_count
987
+ else:
988
+ # No files selected from grep, continue
989
+ continue
990
+ else:
991
+ continue
992
+
993
+ if choice == "y":
994
+ files_to_add_after_loop = []
995
+ for file in files:
996
+ file_path = os.path.join(directory, file)
997
+ if os.path.abspath(file_path) in selected_files_set:
998
+ continue # Skip already selected files
999
+
1000
+ if is_large_file(file_path):
1001
+ while True:
1002
+ snippet_choice = input(
1003
+ f"{file} is large. Use (f)ull content or (s)nippet? "
1004
+ ).lower()
1005
+ if snippet_choice in ["f", "s"]:
1006
+ break
1007
+ print("Invalid choice. Please enter 'f' or 's'.")
1008
+ if snippet_choice == "s":
1009
+ selected_files.append(
1010
+ (file_path, True, None, get_language_for_file(file_path))
1011
+ )
1012
+ char_count_delta += len(get_file_snippet(file_path))
1013
+ else:
1014
+ selected_files.append(
1015
+ (file_path, False, None, get_language_for_file(file_path))
1016
+ )
1017
+ char_count_delta += os.path.getsize(file_path)
1018
+ else:
1019
+ selected_files.append(
1020
+ (file_path, False, None, get_language_for_file(file_path))
1021
+ )
1022
+ char_count_delta += os.path.getsize(file_path)
1023
+ files_to_add_after_loop.append(file_path)
1024
+
1025
+ # Analyze dependencies after the loop
1026
+ current_char_count += char_count_delta
1027
+ for file_path in files_to_add_after_loop:
1028
+ new_deps, deps_char_count = _propose_and_add_dependencies(
1029
+ file_path, project_root_abs, selected_files, current_char_count
1030
+ )
1031
+ selected_files.extend(new_deps)
1032
+ current_char_count += deps_char_count
1033
+
1034
+ print(f"Added all files from {directory}")
1035
+ return selected_files, current_char_count
1036
+
1037
+ elif choice == "n":
1038
+ print(f"Ignored all files from {directory}")
1039
+ return [], current_char_count
1040
+
1041
+ elif choice == "s":
1042
+ for file in files:
1043
+ file_path = os.path.join(directory, file)
1044
+ if os.path.abspath(file_path) in selected_files_set:
1045
+ continue # Skip already selected files
1046
+
1047
+ file_size = os.path.getsize(file_path)
1048
+ file_size_readable = get_human_readable_size(file_size)
1049
+ file_char_estimate = file_size
1050
+ file_token_estimate = file_char_estimate // 4
1051
+ while True:
1052
+ if current_char_count > 0:
1053
+ print_char_count(current_char_count)
1054
+ file_choice = input(
1055
+ f"{file} ({file_size_readable}, ~{file_char_estimate} chars, ~{file_token_estimate} tokens) (y/n/p/q)? "
1056
+ ).lower()
1057
+ if file_choice == "y":
1058
+ file_to_add = None
1059
+ if is_large_file(file_path):
1060
+ while True:
1061
+ snippet_choice = input(
1062
+ f"{file} is large. Use (f)ull content or (s)nippet? "
1063
+ ).lower()
1064
+ if snippet_choice in ["f", "s"]:
1065
+ break
1066
+ print("Invalid choice. Please enter 'f' or 's'.")
1067
+ if snippet_choice == "s":
1068
+ file_to_add = (
1069
+ file_path,
1070
+ True,
1071
+ None,
1072
+ get_language_for_file(file_path),
1073
+ )
1074
+ current_char_count += len(get_file_snippet(file_path))
1075
+ else:
1076
+ file_to_add = (
1077
+ file_path,
1078
+ False,
1079
+ None,
1080
+ get_language_for_file(file_path),
1081
+ )
1082
+ current_char_count += file_char_estimate
1083
+ else:
1084
+ file_to_add = (
1085
+ file_path,
1086
+ False,
1087
+ None,
1088
+ get_language_for_file(file_path),
1089
+ )
1090
+ current_char_count += file_char_estimate
1091
+
1092
+ if file_to_add:
1093
+ selected_files.append(file_to_add)
1094
+ selected_files_set.add(os.path.abspath(file_path))
1095
+ # Analyze dependencies immediately after adding
1096
+ new_deps, deps_char_count = _propose_and_add_dependencies(
1097
+ file_path,
1098
+ project_root_abs,
1099
+ selected_files,
1100
+ current_char_count,
1101
+ )
1102
+ selected_files.extend(new_deps)
1103
+ current_char_count += deps_char_count
1104
+ break
1105
+ elif file_choice == "n":
1106
+ break
1107
+ elif file_choice == "p":
1108
+ chunks, char_count = select_file_patches(file_path)
1109
+ if chunks:
1110
+ selected_files.append(
1111
+ (
1112
+ file_path,
1113
+ False,
1114
+ chunks,
1115
+ get_language_for_file(file_path),
1116
+ )
1117
+ )
1118
+ current_char_count += char_count
1119
+ selected_files_set.add(os.path.abspath(file_path))
1120
+ break
1121
+ elif file_choice == "q":
1122
+ print(f"Quitting selection for {directory}")
1123
+ return selected_files, current_char_count
1124
+ else:
1125
+ print("Invalid choice. Please enter 'y', 'n', 'p', or 'q'.")
1126
+ print(f"Added {len(selected_files)} files from {directory}")
1127
+ return selected_files, current_char_count
1128
+
1129
+ elif choice == "q":
1130
+ print(f"Quitting selection for {directory}")
1131
+ return [], current_char_count
1132
+ else:
1133
+ print("Invalid choice. Please try again.")
1134
+
1135
+
1136
+ def process_directory(
1137
+ directory: str,
1138
+ ignore_patterns: List[str],
1139
+ project_root_abs: str,
1140
+ current_char_count: int = 0,
1141
+ selected_files_set: Optional[Set[str]] = None,
1142
+ ) -> Tuple[List[FileTuple], Set[str], int]:
1143
+ if selected_files_set is None:
1144
+ selected_files_set = set()
1145
+
1146
+ files_to_include: List[FileTuple] = []
1147
+ processed_dirs: Set[str] = set()
1148
+
1149
+ for root, dirs, files in os.walk(directory):
1150
+ dirs[:] = [
1151
+ d for d in dirs if not is_ignored(os.path.join(root, d), ignore_patterns)
1152
+ ]
1153
+ files = [
1154
+ f
1155
+ for f in files
1156
+ if not is_ignored(os.path.join(root, f), ignore_patterns)
1157
+ and not is_binary(os.path.join(root, f))
1158
+ ]
1159
+
1160
+ if root in processed_dirs:
1161
+ continue
1162
+
1163
+ print(f"\nExploring directory: {root}")
1164
+ choice = input("(y)es explore / (n)o skip / (q)uit? ").lower()
1165
+ if choice == "y":
1166
+ # Pass selected_files_set to track already selected files
1167
+ selected_files, current_char_count = select_files_in_directory(
1168
+ root,
1169
+ ignore_patterns,
1170
+ project_root_abs,
1171
+ current_char_count,
1172
+ selected_files_set,
1173
+ )
1174
+ files_to_include.extend(selected_files)
1175
+
1176
+ # Update selected_files_set
1177
+ for file_tuple in selected_files:
1178
+ selected_files_set.add(os.path.abspath(file_tuple[0]))
1179
+
1180
+ processed_dirs.add(root)
1181
+ elif choice == "n":
1182
+ dirs[:] = [] # Skip all subdirectories
1183
+ continue
1184
+ elif choice == "q":
1185
+ break
1186
+ else:
1187
+ print("Invalid choice. Skipping this directory.")
1188
+ continue
1189
+
1190
+ return files_to_include, processed_dirs, current_char_count
1191
+
1192
+
1193
+ def fetch_web_content(
1194
+ url: str,
1195
+ ) -> Tuple[Optional[FileTuple], Optional[str], Optional[str]]:
1196
+ try:
1197
+ response = requests.get(url)
1198
+ response.raise_for_status()
1199
+ content_type = response.headers.get("content-type", "").lower()
1200
+ full_content = response.text
1201
+ snippet = (
1202
+ full_content[:10000] + "..." if len(full_content) > 10000 else full_content
1203
+ )
1204
+
1205
+ if "json" in content_type:
1206
+ content_type = "json"
1207
+ elif "csv" in content_type:
1208
+ content_type = "csv"
1209
+ else:
1210
+ content_type = "text"
1211
+
1212
+ return (url, False, None, content_type), full_content, snippet
1213
+ except requests.RequestException as e:
1214
+ print(f"Error fetching content from {url}: {e}")
1215
+ return None, None, None
1216
+
1217
+
1218
+ def read_env_file():
1219
+ env_vars = {}
1220
+ if os.path.exists(".env"):
1221
+ with open(".env", "r") as env_file:
1222
+ for line in env_file:
1223
+ line = line.strip()
1224
+ if line and not line.startswith("#"):
1225
+ key, value = line.split("=", 1)
1226
+ env_vars[key.strip()] = value.strip()
1227
+ return env_vars
1228
+
1229
+
1230
+ def open_editor_for_input(template: str, cursor_position: int) -> str:
1231
+ editor = os.environ.get("EDITOR", "vim")
1232
+ with tempfile.NamedTemporaryFile(
1233
+ mode="w+", suffix=".md", delete=False, encoding="utf-8"
1234
+ ) as temp_file:
1235
+ temp_file.write(template)
1236
+ temp_file.flush()
1237
+ temp_file_path = temp_file.name
1238
+
1239
+ try:
1240
+ cursor_line = template[:cursor_position].count("\n") + 1
1241
+ cursor_column = cursor_position - template.rfind("\n", 0, cursor_position)
1242
+
1243
+ if "vim" in editor or "nvim" in editor:
1244
+ subprocess.call(
1245
+ [
1246
+ editor,
1247
+ f"+call cursor({cursor_line}, {cursor_column})",
1248
+ "+startinsert",
1249
+ temp_file_path,
1250
+ ]
1251
+ )
1252
+ elif "emacs" in editor:
1253
+ subprocess.call([editor, f"+{cursor_line}:{cursor_column}", temp_file_path])
1254
+ elif "nano" in editor:
1255
+ subprocess.call([editor, f"+{cursor_line},{cursor_column}", temp_file_path])
1256
+ else:
1257
+ subprocess.call([editor, temp_file_path])
1258
+
1259
+ with open(temp_file_path, "r", encoding="utf-8") as file:
1260
+ content = file.read()
1261
+ return content
1262
+ finally:
1263
+ os.unlink(temp_file_path)
1264
+
1265
+
1266
+ def main():
1267
+ parser = argparse.ArgumentParser(
1268
+ description="Generate a prompt with project structure, file contents, and web content."
1269
+ )
1270
+ parser.add_argument(
1271
+ "inputs",
1272
+ nargs="*",
1273
+ help="Files, directories, or URLs to include. Defaults to current directory.",
1274
+ )
1275
+ parser.add_argument("-t", "--task", help="Task description for the AI prompt")
1276
+ args = parser.parse_args()
1277
+
1278
+ # Default to the current directory if no inputs are provided
1279
+ if not args.inputs:
1280
+ args.inputs.append(".")
1281
+
1282
+ ignore_patterns = read_gitignore()
1283
+ env_vars = read_env_file()
1284
+ project_root_abs = os.path.abspath(os.getcwd())
1285
+
1286
+ files_to_include: List[FileTuple] = []
1287
+ web_contents: Dict[str, Tuple[FileTuple, str]] = {}
1288
+ current_char_count = 0
1289
+
1290
+ # Separate URLs from file/directory paths
1291
+ paths_for_tree = []
1292
+ files_to_preselect = []
1293
+
1294
+ for input_path in args.inputs:
1295
+ if input_path.startswith(("http://", "https://")):
1296
+ # Handle web content as before
1297
+ result = fetch_web_content(input_path)
1298
+ if result:
1299
+ file_tuple, full_content, snippet = result
1300
+ is_large = len(full_content) > 10000
1301
+ if is_large:
1302
+ print(f"\nContent from {input_path} is large. Here's a snippet:\n")
1303
+ print(get_colored_code(input_path, snippet))
1304
+ print("\n" + "-" * 40 + "\n")
1305
+
1306
+ while True:
1307
+ choice = input("Use (f)ull content or (s)nippet? ").lower()
1308
+ if choice in ["f", "s"]:
1309
+ break
1310
+ print("Invalid choice. Please enter 'f' or 's'.")
1311
+
1312
+ if choice == "f":
1313
+ content = full_content
1314
+ is_snippet = False
1315
+ print("Using full content.")
1316
+ else:
1317
+ content = snippet
1318
+ is_snippet = True
1319
+ print("Using snippet.")
1320
+ else:
1321
+ content = full_content
1322
+ is_snippet = False
1323
+ print(
1324
+ f"Content from {input_path} is not large. Using full content."
1325
+ )
1326
+
1327
+ file_tuple = (file_tuple[0], is_snippet, file_tuple[2], file_tuple[3])
1328
+ web_contents[input_path] = (file_tuple, content)
1329
+ current_char_count += len(content)
1330
+ print(
1331
+ f"Added {'snippet of ' if is_snippet else ''}web content from: {input_path}"
1332
+ )
1333
+ print_char_count(current_char_count)
1334
+ else:
1335
+ abs_path = os.path.abspath(input_path)
1336
+ if os.path.exists(abs_path):
1337
+ paths_for_tree.append(input_path)
1338
+ if os.path.isfile(abs_path):
1339
+ files_to_preselect.append(abs_path)
1340
+ else:
1341
+ print(f"Warning: {input_path} does not exist. Skipping.")
1342
+
1343
+ # Use tree selector for file/directory selection
1344
+ if paths_for_tree:
1345
+ print("\nStarting interactive file selection...")
1346
+ print(
1347
+ "Use arrow keys to navigate, Space to select, 'q' to finish. See all keys below.\n"
1348
+ )
1349
+
1350
+ tree_selector = TreeSelector(ignore_patterns, project_root_abs)
1351
+ try:
1352
+ selected_files, file_char_count = tree_selector.run(
1353
+ paths_for_tree, files_to_preselect
1354
+ )
1355
+ files_to_include.extend(selected_files)
1356
+ current_char_count += file_char_count
1357
+ except KeyboardInterrupt:
1358
+ print("\nSelection cancelled.")
1359
+ return
1360
+
1361
+ if not files_to_include and not web_contents:
1362
+ print("No files or web content were selected. Exiting.")
1363
+ return
1364
+
1365
+ # Save the final selection for the next run
1366
+ if files_to_include:
1367
+ save_selection_to_cache(files_to_include)
1368
+
1369
+ print("\nFile and web content selection complete.")
1370
+ print_char_count(current_char_count)
1371
+
1372
+ added_files_count = len(files_to_include)
1373
+ added_web_count = len(web_contents)
1374
+ print(
1375
+ f"Summary: Added {added_files_count} files/patches and {added_web_count} web sources."
1376
+ )
1377
+
1378
+ prompt_template, cursor_position = generate_prompt_template(
1379
+ files_to_include, ignore_patterns, web_contents, env_vars
1380
+ )
1381
+
1382
+ if args.task:
1383
+ task_description = args.task
1384
+ task_marker = "## Task Instructions\n\n"
1385
+ insertion_point = prompt_template.find(task_marker)
1386
+ if insertion_point != -1:
1387
+ final_prompt = (
1388
+ prompt_template[: insertion_point + len(task_marker)]
1389
+ + task_description
1390
+ + "\n\n"
1391
+ + prompt_template[insertion_point + len(task_marker) :]
1392
+ )
1393
+ else:
1394
+ final_prompt = (
1395
+ prompt_template[:cursor_position]
1396
+ + task_description
1397
+ + prompt_template[cursor_position:]
1398
+ )
1399
+ print("\nUsing task description from -t argument.")
1400
+ else:
1401
+ print("\nOpening editor for task instructions...")
1402
+ final_prompt = open_editor_for_input(prompt_template, cursor_position)
1403
+
1404
+ print("\n\nGenerated prompt:")
1405
+ print("-" * 80)
1406
+ print(final_prompt)
1407
+ print("-" * 80)
1408
+
1409
+ try:
1410
+ pyperclip.copy(final_prompt)
1411
+ print("\n--- Included Files & Content ---\n")
1412
+ for file_path, is_snippet, chunks, _ in sorted(
1413
+ files_to_include, key=lambda x: x[0]
1414
+ ):
1415
+ details = []
1416
+ if is_snippet:
1417
+ details.append("snippet")
1418
+ if chunks is not None:
1419
+ details.append(f"{len(chunks)} patches")
1420
+
1421
+ detail_str = f" ({', '.join(details)})" if details else ""
1422
+ print(f"- {os.path.relpath(file_path)}{detail_str}")
1423
+
1424
+ for url, (file_tuple, _) in sorted(web_contents.items()):
1425
+ is_snippet = file_tuple[1]
1426
+ detail_str = " (snippet)" if is_snippet else ""
1427
+ print(f"- {url}{detail_str}")
1428
+
1429
+ separator = (
1430
+ "\n"
1431
+ + "=" * 40
1432
+ + "\nā˜•šŸ Kopipasta Complete! šŸā˜•\n"
1433
+ + "=" * 40
1434
+ + "\n"
1435
+ )
1436
+ print(separator)
1437
+
1438
+ final_char_count = len(final_prompt)
1439
+ final_token_estimate = final_char_count // 4
1440
+ print(
1441
+ f"Prompt has been copied to clipboard. Final size: {final_char_count} characters (~ {final_token_estimate} tokens)"
1442
+ )
1443
+ except pyperclip.PyperclipException as e:
1444
+ print(f"\nWarning: Failed to copy to clipboard: {e}")
1445
+ print("You can manually copy the prompt above.")
1446
+
1447
+
1448
+ if __name__ == "__main__":
1449
+ main()