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.

@@ -0,0 +1,791 @@
1
+ import os
2
+ import shutil
3
+ from typing import Dict, List, Optional, Tuple
4
+ from rich.console import Console
5
+ from rich.tree import Tree
6
+ from rich.panel import Panel
7
+ from rich.text import Text
8
+ import click
9
+
10
+ from kopipasta.file import FileTuple, is_binary, is_ignored, get_human_readable_size
11
+ from kopipasta.prompt import get_file_snippet, get_language_for_file
12
+ from kopipasta.cache import load_selection_from_cache
13
+
14
+
15
+ class FileNode:
16
+ """Represents a file or directory in the tree"""
17
+
18
+ def __init__(
19
+ self,
20
+ path: str,
21
+ is_dir: bool,
22
+ parent: Optional["FileNode"] = None,
23
+ is_scan_root: bool = False,
24
+ ):
25
+ self.path = os.path.abspath(path)
26
+ self.is_dir = is_dir
27
+ self.parent = parent
28
+ self.children: List["FileNode"] = []
29
+ self.expanded = False
30
+ self.is_scan_root = is_scan_root
31
+ # Base size (for files) or initial placeholder (for dirs)
32
+ self.size = 0 if is_dir else os.path.getsize(self.path)
33
+ # New attributes for caching the results of a deep scan
34
+ self.total_size: int = self.size
35
+ self.is_scanned: bool = not self.is_dir
36
+
37
+ @property
38
+ def name(self):
39
+ return os.path.basename(self.path) or self.path
40
+
41
+ @property
42
+ def relative_path(self):
43
+ # os.path.relpath is relative to the current working directory by default
44
+ return os.path.relpath(self.path)
45
+
46
+
47
+ class TreeSelector:
48
+ """Interactive file tree selector using Rich"""
49
+
50
+ def __init__(self, ignore_patterns: List[str], project_root_abs: str):
51
+ self.console = Console()
52
+ self.ignore_patterns = ignore_patterns
53
+ self.project_root_abs = project_root_abs
54
+ self.selected_files: Dict[
55
+ str, Tuple[bool, Optional[List[str]]]
56
+ ] = {} # path -> (is_snippet, chunks)
57
+ self.current_index = 0
58
+ self.nodes: List[FileNode] = []
59
+ self.visible_nodes: List[FileNode] = []
60
+ self.char_count = 0
61
+ self.quit_selection = False
62
+ self.viewport_offset = 0 # First visible item index
63
+ self._metrics_cache: Dict[str, Tuple[int, int]] = {}
64
+
65
+ def _calculate_directory_metrics(self, node: FileNode) -> Tuple[int, int]:
66
+ """Recursively calculate total and selected size for a directory."""
67
+ if not node.is_dir:
68
+ return 0, 0
69
+
70
+ # If the directory itself is ignored, don't explore it.
71
+ if is_ignored(node.path, self.ignore_patterns, self.project_root_abs):
72
+ return 0, 0
73
+
74
+ # Use instance cache for this render cycle
75
+ if node.path in self._metrics_cache:
76
+ return self._metrics_cache[node.path]
77
+
78
+ total_size = 0
79
+ selected_size = 0
80
+
81
+ # Ensure directory is scanned
82
+ if not node.children:
83
+ self._deep_scan_directory_and_calc_size(node.path, node)
84
+
85
+ for child in node.children:
86
+ if child.is_dir:
87
+ child_total, child_selected = self._calculate_directory_metrics(child)
88
+ total_size += child_total
89
+ selected_size += child_selected
90
+ else: # It's a file
91
+ total_size += child.size
92
+ if child.path in self.selected_files:
93
+ is_snippet, _ = self.selected_files[child.path]
94
+ if is_snippet:
95
+ selected_size += len(get_file_snippet(child.path))
96
+ else:
97
+ selected_size += child.size
98
+
99
+ self._metrics_cache[node.path] = (total_size, selected_size)
100
+ return total_size, selected_size
101
+
102
+ def build_tree(self, paths: List[str]) -> FileNode:
103
+ """Build tree structure from given paths."""
104
+ # If one directory is given, make its contents the top level of the tree.
105
+ if len(paths) == 1 and os.path.isdir(paths[0]):
106
+ root_path = os.path.abspath(paths[0])
107
+ root = FileNode(root_path, True, is_scan_root=True)
108
+ root.expanded = True
109
+ self._deep_scan_directory_and_calc_size(root_path, root)
110
+ return root
111
+
112
+ # Otherwise, create a virtual root to hold multiple items (e.g., `kopipasta file.py dir/`).
113
+ # This virtual root itself won't be displayed.
114
+ virtual_root_path = os.path.join(
115
+ self.project_root_abs, "__kopipasta_virtual_root__"
116
+ )
117
+ root = FileNode(virtual_root_path, True, is_scan_root=True)
118
+ root.expanded = True
119
+
120
+ for path in paths:
121
+ abs_path = os.path.abspath(path)
122
+ node = None
123
+ if os.path.isfile(abs_path):
124
+ if not is_ignored(
125
+ abs_path, self.ignore_patterns, self.project_root_abs
126
+ ) and not is_binary(abs_path):
127
+ node = FileNode(abs_path, False, root)
128
+ elif os.path.isdir(abs_path):
129
+ node = FileNode(abs_path, True, root)
130
+
131
+ if node:
132
+ root.children.append(node)
133
+
134
+ return root
135
+
136
+ def _deep_scan_directory_and_calc_size(self, dir_path: str, parent_node: FileNode):
137
+ """Recursively scan directory and build tree"""
138
+ abs_dir_path = os.path.abspath(dir_path)
139
+
140
+ # Check if we've already scanned this directory
141
+ if parent_node.children:
142
+ return
143
+
144
+ try:
145
+ items = sorted(os.listdir(abs_dir_path))
146
+ except PermissionError:
147
+ return
148
+
149
+ # Separate and sort directories and files
150
+ dirs = []
151
+ files = []
152
+
153
+ for item in items:
154
+ item_path = os.path.join(abs_dir_path, item)
155
+ if is_ignored(item_path, self.ignore_patterns, self.project_root_abs):
156
+ continue
157
+
158
+ if os.path.isdir(item_path):
159
+ dirs.append(item)
160
+ elif os.path.isfile(item_path) and not is_binary(item_path):
161
+ files.append(item)
162
+
163
+ # Add directories first
164
+ for dir_name in sorted(dirs):
165
+ dir_path_full = os.path.join(abs_dir_path, dir_name)
166
+ # Check if this node already exists as a child
167
+ existing = next(
168
+ (
169
+ child
170
+ for child in parent_node.children
171
+ if os.path.abspath(child.path) == os.path.abspath(dir_path_full)
172
+ ),
173
+ None,
174
+ )
175
+ if not existing:
176
+ dir_node = FileNode(dir_path_full, True, parent_node)
177
+ parent_node.children.append(dir_node)
178
+
179
+ # Then add files
180
+ for file_name in sorted(files):
181
+ file_path = os.path.join(abs_dir_path, file_name)
182
+ # Check if this node already exists as a child
183
+ existing = next(
184
+ (
185
+ child
186
+ for child in parent_node.children
187
+ if os.path.abspath(child.path) == os.path.abspath(file_path)
188
+ ),
189
+ None,
190
+ )
191
+ if not existing:
192
+ file_node = FileNode(file_path, False, parent_node)
193
+ parent_node.children.append(file_node)
194
+
195
+ def _flatten_tree(
196
+ self, node: FileNode, level: int = 0
197
+ ) -> List[Tuple[FileNode, int]]:
198
+ """Flatten tree into a list of (node, level) tuples for display."""
199
+ result = []
200
+
201
+ # If it's the special root node, don't display it. Display its children at the top level.
202
+ if node.is_scan_root:
203
+ for child in node.children:
204
+ result.extend(self._flatten_tree(child, 0))
205
+ else:
206
+ result.append((node, level))
207
+ if node.is_dir and node.expanded:
208
+ if not node.children:
209
+ self._deep_scan_directory_and_calc_size(node.path, node)
210
+ for child in node.children:
211
+ result.extend(self._flatten_tree(child, level + 1))
212
+
213
+ return result
214
+
215
+ def _build_display_tree(self) -> Tree:
216
+ """Build Rich tree for display with viewport"""
217
+ self._metrics_cache = {} # Clear cache for each new render
218
+
219
+ # Get terminal size
220
+ _, term_height = shutil.get_terminal_size()
221
+
222
+ # Reserve space for header, help panel, and status
223
+ reserved_space = 12
224
+ available_height = term_height - reserved_space
225
+ available_height = max(5, available_height) # Minimum height
226
+
227
+ # Flatten tree to get all visible nodes
228
+ flat_tree = self._flatten_tree(self.root)
229
+ self.visible_nodes = [node for node, _ in flat_tree]
230
+
231
+ # Calculate viewport
232
+ if self.visible_nodes:
233
+ # Ensure current selection is visible
234
+ if self.current_index < self.viewport_offset:
235
+ self.viewport_offset = self.current_index
236
+ elif self.current_index >= self.viewport_offset + available_height:
237
+ self.viewport_offset = self.current_index - available_height + 1
238
+
239
+ # Clamp viewport to valid range
240
+ max_offset = max(0, len(self.visible_nodes) - available_height)
241
+ self.viewport_offset = max(0, min(self.viewport_offset, max_offset))
242
+ else:
243
+ self.viewport_offset = 0
244
+
245
+ # Create tree with scroll indicators
246
+ tree_title = "📁 Project Files"
247
+ if self.viewport_offset > 0:
248
+ tree_title += f" ↑ ({self.viewport_offset} more)"
249
+
250
+ tree = Tree(tree_title)
251
+
252
+ # Build tree structure - only for visible portion
253
+ viewport_end = min(len(flat_tree), self.viewport_offset + available_height)
254
+
255
+ # Track what level each visible item is at for proper tree structure
256
+ level_stacks = {} # level -> stack of tree nodes
257
+
258
+ for i in range(self.viewport_offset, viewport_end):
259
+ node, level = flat_tree[i]
260
+
261
+ # Determine style and icon
262
+ is_current = i == self.current_index
263
+ style = "bold cyan" if is_current else ""
264
+
265
+ label = Text()
266
+
267
+ if node.is_dir:
268
+ icon = "📂" if node.expanded else "📁"
269
+ total_size, selected_size = self._calculate_directory_metrics(node)
270
+ if total_size > 0:
271
+ size_str = f" ({get_human_readable_size(selected_size)} / {get_human_readable_size(total_size)})"
272
+ else:
273
+ size_str = "" # Don't show size for empty dirs
274
+
275
+ # Omit the selection circle for directories
276
+ label.append(f"{icon} {node.name}{size_str}", style=style)
277
+
278
+ else: # It's a file
279
+ icon = "📄"
280
+ size_str = f" ({get_human_readable_size(node.size)})"
281
+
282
+ # File selection indicator
283
+ abs_path = os.path.abspath(node.path)
284
+ if abs_path in self.selected_files:
285
+ is_snippet, _ = self.selected_files[abs_path]
286
+ selection = "◐" if is_snippet else "●"
287
+ style = "green " + style
288
+ else:
289
+ selection = "○"
290
+
291
+ label.append(f"{selection} ", style="dim")
292
+ label.append(f"{icon} {node.name}{size_str}", style=style)
293
+
294
+ # Add to tree at correct level
295
+ if level == 0:
296
+ tree_node = tree.add(label)
297
+ level_stacks[0] = tree_node
298
+ else:
299
+ # Find parent at previous level
300
+ parent_level = level - 1
301
+ if parent_level in level_stacks:
302
+ parent_tree = level_stacks[parent_level]
303
+ tree_node = parent_tree.add(label)
304
+ level_stacks[level] = tree_node
305
+ else:
306
+ # Fallback - add to root with indentation indicator
307
+ indent_text = " " * level
308
+ if not node.is_dir:
309
+ # Re-add file selection marker for indented fallback
310
+ selection_char = "○"
311
+ if node.path in self.selected_files:
312
+ selection_char = (
313
+ "◐" if self.selected_files[node.path][0] else "●"
314
+ )
315
+ indent_text += f"{selection_char} "
316
+
317
+ # Create a new label with proper indentation for this edge case
318
+ fallback_label_text = f"{indent_text}{label.plain}"
319
+ tree_node = tree.add(Text(fallback_label_text, style=style))
320
+ level_stacks[level] = tree_node
321
+
322
+ # Add scroll indicator at bottom if needed
323
+ if viewport_end < len(self.visible_nodes):
324
+ remaining = len(self.visible_nodes) - viewport_end
325
+ tree.add(Text(f"↓ ({remaining} more items)", style="dim italic"))
326
+
327
+ return tree
328
+
329
+ def _show_help(self) -> Panel:
330
+ """Create help panel"""
331
+ help_text = """[bold]Navigation:[/bold] ↑/k: Up ↓/j: Down →/l/Enter: Expand ←/h: Collapse
332
+ [bold]Selection:[/bold] Space: Toggle file/dir a: Add all in dir s: Snippet mode
333
+ [bold]Actions:[/bold] r: Reuse last selection g: Grep in directory d: Show dependencies
334
+ q: Quit and finalize"""
335
+
336
+ return Panel(
337
+ help_text, title="Keyboard Controls", border_style="dim", expand=False
338
+ )
339
+
340
+ def _get_status_bar(self) -> str:
341
+ """Create status bar with selection info"""
342
+ # Count selections
343
+ full_count = sum(
344
+ 1 for _, (is_snippet, _) in self.selected_files.items() if not is_snippet
345
+ )
346
+ snippet_count = sum(
347
+ 1 for _, (is_snippet, _) in self.selected_files.items() if is_snippet
348
+ )
349
+
350
+ # Current item info
351
+ if self.visible_nodes and 0 <= self.current_index < len(self.visible_nodes):
352
+ current = self.visible_nodes[self.current_index]
353
+ current_info = f"[dim]Current:[/dim] {current.relative_path}"
354
+ else:
355
+ current_info = "No selection"
356
+
357
+ selection_info = f"[dim]Selected:[/dim] {full_count} full, {snippet_count} snippets | ~{self.char_count:,} chars (~{self.char_count//4:,} tokens)"
358
+
359
+ return f"\n{current_info} | {selection_info}\n"
360
+
361
+ def _handle_grep(self, node: FileNode):
362
+ """Handle grep search in directory"""
363
+ if not node.is_dir:
364
+ self.console.print("[red]Grep only works on directories[/red]")
365
+ return
366
+
367
+ pattern = click.prompt("Enter search pattern")
368
+ if not pattern:
369
+ return
370
+
371
+ self.console.print(f"Searching for '{pattern}' in {node.relative_path}...")
372
+
373
+ # Import here to avoid circular dependency
374
+ from kopipasta.main import grep_files_in_directory, select_from_grep_results
375
+
376
+ grep_results = grep_files_in_directory(pattern, node.path, self.ignore_patterns)
377
+ if not grep_results:
378
+ self.console.print(f"[yellow]No matches found for '{pattern}'[/yellow]")
379
+ return
380
+
381
+ # Show results and let user select
382
+ selected_files, new_char_count = select_from_grep_results(
383
+ grep_results, self.char_count
384
+ )
385
+
386
+ # Add selected files
387
+ added_count = 0
388
+ for file_tuple in selected_files:
389
+ file_path, is_snippet, chunks, _ = file_tuple
390
+ abs_path = os.path.abspath(file_path)
391
+
392
+ # Check if already selected
393
+ if abs_path not in self.selected_files:
394
+ self.selected_files[abs_path] = (is_snippet, chunks)
395
+ added_count += 1
396
+ # Ensure the file is visible in the tree
397
+ self._ensure_path_visible(abs_path)
398
+
399
+ self.char_count = new_char_count
400
+
401
+ # Show summary of what was added
402
+ if added_count > 0:
403
+ self.console.print(
404
+ f"\n[green]Added {added_count} files from grep results[/green]"
405
+ )
406
+ else:
407
+ self.console.print(
408
+ f"\n[yellow]All selected files were already in selection[/yellow]"
409
+ )
410
+
411
+ def _toggle_selection(self, node: FileNode, snippet_mode: bool = False):
412
+ """Toggle selection of a file or directory"""
413
+ if node.is_dir:
414
+ # For directories, toggle all children
415
+ self._toggle_directory(node)
416
+ else:
417
+ abs_path = os.path.abspath(node.path)
418
+ # For files, toggle individual selection
419
+ if abs_path in self.selected_files:
420
+ # Unselect
421
+ is_snippet, _ = self.selected_files[abs_path]
422
+ del self.selected_files[abs_path]
423
+ self.char_count -= (
424
+ len(get_file_snippet(node.path)) if is_snippet else node.size
425
+ )
426
+ else:
427
+ # Select
428
+ if snippet_mode or (
429
+ node.size > 102400 and not self._confirm_large_file(node)
430
+ ):
431
+ # Use snippet
432
+ self.selected_files[abs_path] = (True, None)
433
+ self.char_count += len(get_file_snippet(node.path))
434
+ else:
435
+ # Use full file
436
+ self.selected_files[abs_path] = (False, None)
437
+ self.char_count += node.size
438
+
439
+ def _toggle_directory(self, node: FileNode):
440
+ """Toggle all files in a directory, now fully recursive."""
441
+ if not node.is_dir:
442
+ return
443
+
444
+ # Ensure children are loaded
445
+ if not node.children:
446
+ self._deep_scan_directory_and_calc_size(node.path, node)
447
+
448
+ # Collect all files recursively
449
+ all_files = []
450
+
451
+ def collect_files(n: FileNode):
452
+ if n.is_dir:
453
+ # CRITICAL FIX: Ensure sub-directory children are loaded before recursing
454
+ if not n.children:
455
+ self._deep_scan_directory_and_calc_size(n.path, n)
456
+ for child in n.children:
457
+ collect_files(child)
458
+ else:
459
+ all_files.append(n)
460
+
461
+ collect_files(node)
462
+
463
+ # Check if any are unselected
464
+ any_unselected = any(
465
+ os.path.abspath(f.path) not in self.selected_files for f in all_files
466
+ )
467
+
468
+ if any_unselected:
469
+ # Select all unselected files
470
+ for file_node in all_files:
471
+ abs_path = os.path.abspath(file_node.path)
472
+ if abs_path not in self.selected_files:
473
+ self.selected_files[abs_path] = (False, None)
474
+ self.char_count += file_node.size
475
+ else:
476
+ # Unselect all files
477
+ for file_node in all_files:
478
+ abs_path = os.path.abspath(file_node.path)
479
+ if abs_path in self.selected_files:
480
+ is_snippet, _ = self.selected_files[abs_path]
481
+ del self.selected_files[abs_path]
482
+ if is_snippet:
483
+ self.char_count -= len(get_file_snippet(file_node.path))
484
+ else:
485
+ self.char_count -= file_node.size
486
+
487
+ def _propose_and_apply_last_selection(self):
488
+ """Loads paths from cache, shows a confirmation dialog, and applies the selection if confirmed."""
489
+ cached_paths = load_selection_from_cache()
490
+
491
+ if not cached_paths:
492
+ self.console.print(
493
+ Panel(
494
+ "[yellow]No cached selection found to reuse.[/yellow]",
495
+ title="Info",
496
+ border_style="dim",
497
+ )
498
+ )
499
+ click.pause("Press any key to continue...")
500
+ return
501
+
502
+ # Categorize cached paths for the preview
503
+ files_to_add = []
504
+ files_already_selected = []
505
+ files_not_found = []
506
+
507
+ for rel_path in cached_paths:
508
+ abs_path = os.path.abspath(rel_path)
509
+ if not os.path.isfile(abs_path):
510
+ files_not_found.append(rel_path)
511
+ continue
512
+
513
+ if abs_path in self.selected_files:
514
+ files_already_selected.append(rel_path)
515
+ else:
516
+ files_to_add.append(rel_path)
517
+
518
+ # Build the rich text for the confirmation panel
519
+ preview_text = Text()
520
+ if files_to_add:
521
+ preview_text.append("The following files will be ADDED:\n", style="bold")
522
+ for path in sorted(files_to_add):
523
+ preview_text.append(" ")
524
+ preview_text.append("+", style="cyan")
525
+ preview_text.append(f" {path}\n")
526
+
527
+ if files_already_selected:
528
+ preview_text.append("\nAlready selected (no change):\n", style="bold dim")
529
+ for path in sorted(files_already_selected):
530
+ preview_text.append(f" ✓ {path}\n")
531
+
532
+ if files_not_found:
533
+ preview_text.append(
534
+ "\nNot found on disk (will be skipped):\n", style="bold dim"
535
+ )
536
+ for path in sorted(files_not_found):
537
+ preview_text.append(" ")
538
+ preview_text.append("-", style="red")
539
+ preview_text.append(f" {path}\n")
540
+
541
+ # Display the confirmation panel and prompt
542
+ self.console.clear()
543
+ self.console.print(
544
+ Panel(
545
+ preview_text,
546
+ title="[bold cyan]Reuse Last Selection?",
547
+ border_style="cyan",
548
+ padding=(1, 2),
549
+ )
550
+ )
551
+
552
+ if not files_to_add:
553
+ self.console.print(
554
+ "\n[yellow]No new files to add from the last selection.[/yellow]"
555
+ )
556
+ click.pause("Press any key to continue...")
557
+ return
558
+
559
+ # Use click.confirm for a simple and effective y/n prompt
560
+ if not click.confirm(
561
+ f"\nAdd {len(files_to_add)} file(s) to your current selection?",
562
+ default=True,
563
+ ):
564
+ return
565
+
566
+ # If confirmed, apply the changes
567
+ for rel_path in files_to_add:
568
+ abs_path = os.path.abspath(rel_path)
569
+ if os.path.isfile(abs_path) and abs_path not in self.selected_files:
570
+ file_size = os.path.getsize(abs_path)
571
+ self.selected_files[abs_path] = (False, None)
572
+ self.char_count += file_size
573
+ self._ensure_path_visible(abs_path)
574
+
575
+ def _ensure_path_visible(self, file_path: str):
576
+ """Ensure a file path is visible in the tree by expanding parent directories"""
577
+ abs_file_path = os.path.abspath(file_path)
578
+
579
+ # Build the path from root to the file
580
+ path_components = []
581
+ current = abs_file_path
582
+
583
+ while current != os.path.abspath(self.project_root_abs) and current != "/":
584
+ path_components.append(current)
585
+ parent = os.path.dirname(current)
586
+ if parent == current: # Reached root
587
+ break
588
+ current = parent
589
+
590
+ # Reverse to go from root to file
591
+ path_components.reverse()
592
+
593
+ # Find and expand each directory in the path
594
+ for component_path in path_components[:-1]: # All except the file itself
595
+ # Search through all nodes to find this path
596
+ found = False
597
+ for node in self._get_all_nodes(self.root):
598
+ if os.path.abspath(node.path) == component_path and node.is_dir:
599
+ if not node.expanded:
600
+ node.expanded = True
601
+ # Ensure children are loaded
602
+ if not node.children:
603
+ self._deep_scan_directory_and_calc_size(node.path, node)
604
+ found = True
605
+ break
606
+
607
+ if not found:
608
+ # This shouldn't happen if the tree is properly built
609
+ self.console.print(
610
+ f"[yellow]Warning: Could not find directory {component_path} in tree[/yellow]"
611
+ )
612
+
613
+ def _get_all_nodes(self, node: FileNode) -> List[FileNode]:
614
+ """Get all nodes in the tree recursively"""
615
+ nodes = [node]
616
+ for child in node.children:
617
+ nodes.extend(self._get_all_nodes(child))
618
+ return nodes
619
+
620
+ def _confirm_large_file(self, node: FileNode) -> bool:
621
+ """Ask user about large file handling"""
622
+ size_str = get_human_readable_size(node.size)
623
+ return click.confirm(
624
+ f"{node.name} is large ({size_str}). Include full content?", default=False
625
+ )
626
+
627
+ def _show_dependencies(self, node: FileNode):
628
+ """Show and optionally add dependencies for a file"""
629
+ if node.is_dir:
630
+ return
631
+
632
+ self.console.print(f"\nAnalyzing dependencies for {node.relative_path}...")
633
+
634
+ # Import here to avoid circular dependency
635
+ from kopipasta.main import _propose_and_add_dependencies
636
+
637
+ # Create a temporary files list for the dependency analyzer
638
+ files_list = [
639
+ (path, is_snippet, chunks, get_language_for_file(path))
640
+ for path, (is_snippet, chunks) in self.selected_files.items()
641
+ ]
642
+
643
+ new_deps, deps_char_count = _propose_and_add_dependencies(
644
+ node.path, self.project_root_abs, files_list, self.char_count
645
+ )
646
+
647
+ # Add new dependencies to our selection
648
+ for dep_path, is_snippet, chunks, _ in new_deps:
649
+ self.selected_files[dep_path] = (is_snippet, chunks)
650
+
651
+ self.char_count += deps_char_count
652
+
653
+ def _preselect_files(self, files_to_preselect: List[str]):
654
+ """Pre-selects a list of files passed from the command line."""
655
+ if not files_to_preselect:
656
+ return
657
+
658
+ added_count = 0
659
+ for file_path in files_to_preselect:
660
+ abs_path = os.path.abspath(file_path)
661
+ if abs_path in self.selected_files:
662
+ continue
663
+
664
+ # This check is simpler than a full tree walk and sufficient here
665
+ if os.path.isfile(abs_path) and not is_binary(abs_path):
666
+ file_size = os.path.getsize(abs_path)
667
+ self.selected_files[abs_path] = (
668
+ False,
669
+ None,
670
+ ) # (is_snippet=False, chunks=None)
671
+ self.char_count += file_size
672
+ added_count += 1
673
+ self._ensure_path_visible(abs_path)
674
+
675
+ def run(
676
+ self, initial_paths: List[str], files_to_preselect: Optional[List[str]] = None
677
+ ) -> Tuple[List[FileTuple], int]:
678
+ """Run the interactive tree selector"""
679
+ self.root = self.build_tree(initial_paths)
680
+
681
+ if files_to_preselect:
682
+ self._preselect_files(files_to_preselect)
683
+
684
+ # Don't use Live mode, instead manually control the display
685
+ while not self.quit_selection:
686
+ # Clear and redraw
687
+ self.console.clear()
688
+
689
+ # Draw tree
690
+ tree = self._build_display_tree()
691
+ self.console.print(tree)
692
+
693
+ # Draw help
694
+ self.console.print(self._show_help())
695
+
696
+ # Draw status bar
697
+ self.console.print(self._get_status_bar())
698
+
699
+ try:
700
+ # Get keyboard input
701
+ key = click.getchar()
702
+
703
+ if not self.visible_nodes:
704
+ continue
705
+
706
+ current_node = self.visible_nodes[self.current_index]
707
+
708
+ # Handle navigation
709
+ if key in ["\x1b[A", "\xe0H", "k"]: # Up arrow or k
710
+ self.current_index = max(0, self.current_index - 1)
711
+ elif key in ["\x1b[B", "\xe0P", "j"]: # Down arrow or j
712
+ self.current_index = min(
713
+ len(self.visible_nodes) - 1, self.current_index + 1
714
+ )
715
+ elif key == "\x1b[5~": # Page Up
716
+ term_width, term_height = shutil.get_terminal_size()
717
+ page_size = max(1, term_height - 15)
718
+ self.current_index = max(0, self.current_index - page_size)
719
+ elif key == "\x1b[6~": # Page Down
720
+ term_width, term_height = shutil.get_terminal_size()
721
+ page_size = max(1, term_height - 15)
722
+ self.current_index = min(
723
+ len(self.visible_nodes) - 1, self.current_index + page_size
724
+ )
725
+ elif key == "\x1b[H": # Home - go to top
726
+ self.current_index = 0
727
+ elif key == "\x1b[F": # End - go to bottom
728
+ self.current_index = len(self.visible_nodes) - 1
729
+ elif key == "G": # Shift+G - go to bottom (vim style)
730
+ self.current_index = len(self.visible_nodes) - 1
731
+ elif key in ["\x1b[C", "l", "\r", "\xe0M"]: # Right arrow, l, or Enter
732
+ if current_node.is_dir:
733
+ current_node.expanded = True
734
+ elif key in ["\x1b[D", "h", "\xe0K"]: # Left arrow or h
735
+ if current_node.is_dir and current_node.expanded:
736
+ current_node.expanded = False
737
+ elif current_node.parent:
738
+ # Jump to parent
739
+ parent_idx = next(
740
+ (
741
+ i
742
+ for i, n in enumerate(self.visible_nodes)
743
+ if n == current_node.parent
744
+ ),
745
+ None,
746
+ )
747
+ if parent_idx is not None:
748
+ self.current_index = parent_idx
749
+
750
+ # Handle selection
751
+ elif key == " ": # Space - toggle selection
752
+ self._toggle_selection(current_node)
753
+ elif key == "s": # Snippet mode
754
+ if not current_node.is_dir:
755
+ self._toggle_selection(current_node, snippet_mode=True)
756
+ elif key == "a": # Add all in directory
757
+ if current_node.is_dir:
758
+ self._toggle_directory(current_node)
759
+
760
+ # Handle actions
761
+ elif key == "r": # Reuse last selection
762
+ self._propose_and_apply_last_selection()
763
+ elif key == "g": # Grep
764
+ self.console.print() # Add some space
765
+ self._handle_grep(current_node)
766
+ elif key == "d": # Dependencies
767
+ self.console.print() # Add some space
768
+ self._show_dependencies(current_node)
769
+ click.pause("Press any key to continue...")
770
+ elif key == "q": # Quit
771
+ self.quit_selection = True
772
+ elif key == "\x03": # Ctrl+C
773
+ raise KeyboardInterrupt()
774
+
775
+ except Exception as e:
776
+ self.console.print(f"[red]Error: {e}[/red]")
777
+ click.pause("Press any key to continue...")
778
+
779
+ # Clear screen one more time
780
+ self.console.clear()
781
+
782
+ # Convert selections to FileTuple format
783
+ files_to_include = []
784
+ for abs_path, (is_snippet, chunks) in self.selected_files.items():
785
+ # Convert back to relative path for the output
786
+ rel_path = os.path.relpath(abs_path)
787
+ files_to_include.append(
788
+ (rel_path, is_snippet, chunks, get_language_for_file(abs_path))
789
+ )
790
+
791
+ return files_to_include, self.char_count