kopipasta 0.35.0__py3-none-any.whl → 0.36.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.

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