kopipasta 0.29.0__py3-none-any.whl → 0.31.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/cache.py ADDED
@@ -0,0 +1,37 @@
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+ from typing import List, Tuple
5
+
6
+ # Define FileTuple for type hinting
7
+ FileTuple = Tuple[str, bool, List[str] | None, str]
8
+
9
+ def get_cache_file_path() -> Path:
10
+ """Gets the cross-platform path to the cache file for the last selection."""
11
+ cache_dir = Path.home() / ".cache" / "kopipasta"
12
+ cache_dir.mkdir(parents=True, exist_ok=True)
13
+ return cache_dir / "last_selection.json"
14
+
15
+ def save_selection_to_cache(files_to_include: List[FileTuple]):
16
+ """Saves the list of selected file relative paths to the cache."""
17
+ cache_file = get_cache_file_path()
18
+ relative_paths = sorted([os.path.relpath(f[0]) for f in files_to_include])
19
+ try:
20
+ with open(cache_file, 'w', encoding='utf-8') as f:
21
+ json.dump(relative_paths, f, indent=2)
22
+ except IOError as e:
23
+ print(f"\nWarning: Could not save selection to cache: {e}")
24
+
25
+ def load_selection_from_cache() -> List[str]:
26
+ """Loads the list of selected files from the cache file."""
27
+ cache_file = get_cache_file_path()
28
+ if not cache_file.exists():
29
+ return []
30
+ try:
31
+ with open(cache_file, 'r', encoding='utf-8') as f:
32
+ paths = json.load(f)
33
+ # Filter out paths that no longer exist
34
+ return [p for p in paths if os.path.exists(p)]
35
+ except (IOError, json.JSONDecodeError) as e:
36
+ print(f"\nWarning: Could not load previous selection from cache: {e}")
37
+ return []
kopipasta/main.py CHANGED
@@ -18,6 +18,7 @@ from kopipasta.file import FileTuple, get_human_readable_size, is_binary, is_ign
18
18
  import kopipasta.import_parser as import_parser
19
19
  from kopipasta.tree_selector import TreeSelector
20
20
  from kopipasta.prompt import generate_prompt_template, get_file_snippet, get_language_for_file
21
+ from kopipasta.cache import save_selection_to_cache
21
22
 
22
23
  def _propose_and_add_dependencies(
23
24
  file_just_added: str,
@@ -993,10 +994,14 @@ def open_editor_for_input(template: str, cursor_position: int) -> str:
993
994
 
994
995
  def main():
995
996
  parser = argparse.ArgumentParser(description="Generate a prompt with project structure, file contents, and web content.")
996
- parser.add_argument('inputs', nargs='+', help='Files, directories, or URLs to include in the prompt')
997
+ parser.add_argument('inputs', nargs='*', help='Files, directories, or URLs to include. Defaults to current directory.')
997
998
  parser.add_argument('-t', '--task', help='Task description for the AI prompt')
998
999
  args = parser.parse_args()
999
1000
 
1001
+ # Default to the current directory if no inputs are provided
1002
+ if not args.inputs:
1003
+ args.inputs.append('.')
1004
+
1000
1005
  ignore_patterns = read_gitignore()
1001
1006
  env_vars = read_env_file()
1002
1007
  project_root_abs = os.path.abspath(os.getcwd())
@@ -1054,7 +1059,7 @@ def main():
1054
1059
  # Use tree selector for file/directory selection
1055
1060
  if paths_for_tree:
1056
1061
  print("\nStarting interactive file selection...")
1057
- print("Use arrow keys to navigate, Space to select, 'q' to finish. Press 'h' for help.\n")
1062
+ print("Use arrow keys to navigate, Space to select, 'q' to finish. See all keys below.\n")
1058
1063
 
1059
1064
  tree_selector = TreeSelector(ignore_patterns, project_root_abs)
1060
1065
  try:
@@ -1069,6 +1074,10 @@ def main():
1069
1074
  print("No files or web content were selected. Exiting.")
1070
1075
  return
1071
1076
 
1077
+ # Save the final selection for the next run
1078
+ if files_to_include:
1079
+ save_selection_to_cache(files_to_include)
1080
+
1072
1081
  print("\nFile and web content selection complete.")
1073
1082
  print_char_count(current_char_count)
1074
1083
 
@@ -1,16 +1,15 @@
1
1
  import os
2
+ import shutil
2
3
  from typing import Dict, List, Optional, Tuple
3
4
  from rich.console import Console
4
5
  from rich.tree import Tree
5
6
  from rich.panel import Panel
6
- from rich.table import Table
7
7
  from rich.text import Text
8
- from rich.live import Live
9
- from rich.layout import Layout
10
8
  import click
11
9
 
12
10
  from kopipasta.file import FileTuple, is_binary, is_ignored, get_human_readable_size
13
11
  from kopipasta.prompt import get_file_snippet, get_language_for_file
12
+ from kopipasta.cache import load_selection_from_cache
14
13
 
15
14
 
16
15
  class FileNode:
@@ -52,6 +51,7 @@ class TreeSelector:
52
51
  self.visible_nodes: List[FileNode] = []
53
52
  self.char_count = 0
54
53
  self.quit_selection = False
54
+ self.viewport_offset = 0 # First visible item index
55
55
 
56
56
  def build_tree(self, paths: List[str]) -> FileNode:
57
57
  """Build tree structure from given paths"""
@@ -154,17 +154,49 @@ class TreeSelector:
154
154
  return result
155
155
 
156
156
  def _build_display_tree(self) -> Tree:
157
- """Build Rich tree for display"""
158
- tree = Tree("📁 Project Files", guide_style="dim")
157
+ """Build Rich tree for display with viewport"""
158
+ # Get terminal size
159
+ term_width, term_height = shutil.get_terminal_size()
159
160
 
160
- # Flatten tree and rebuild visible nodes list
161
+ # Reserve space for header, help panel, and status
162
+ available_height = term_height - 15 # Adjust based on your UI
163
+ available_height = max(5, available_height) # Minimum height
164
+
165
+ # Flatten tree to get all visible nodes
161
166
  flat_tree = self._flatten_tree(self.root)
162
167
  self.visible_nodes = [node for node, _ in flat_tree]
163
168
 
164
- # Build tree structure - we'll map absolute paths to tree nodes
169
+ # Calculate viewport
170
+ if self.visible_nodes:
171
+ # Ensure current selection is visible
172
+ if self.current_index < self.viewport_offset:
173
+ self.viewport_offset = self.current_index
174
+ elif self.current_index >= self.viewport_offset + available_height:
175
+ self.viewport_offset = self.current_index - available_height + 1
176
+
177
+ # Clamp viewport to valid range
178
+ max_offset = max(0, len(self.visible_nodes) - available_height)
179
+ self.viewport_offset = max(0, min(self.viewport_offset, max_offset))
180
+ else:
181
+ self.viewport_offset = 0
182
+
183
+ # Create tree with scroll indicators
184
+ tree_title = "📁 Project Files"
185
+ if self.viewport_offset > 0:
186
+ tree_title += f" ↑ ({self.viewport_offset} more)"
187
+
188
+ tree = Tree(tree_title, guide_style="dim")
189
+
190
+ # Build tree structure - only for visible portion
165
191
  node_map = {}
192
+ viewport_end = min(len(flat_tree), self.viewport_offset + available_height)
193
+
194
+ # Track what level each visible item is at for proper tree structure
195
+ level_stacks = {} # level -> stack of tree nodes
166
196
 
167
- for i, (node, level) in enumerate(flat_tree):
197
+ for i in range(self.viewport_offset, viewport_end):
198
+ node, level = flat_tree[i]
199
+
168
200
  # Determine style and icon
169
201
  is_current = i == self.current_index
170
202
  style = "bold cyan" if is_current else ""
@@ -193,40 +225,45 @@ class TreeSelector:
193
225
  label.append(f"{selection} ", style="dim")
194
226
  label.append(f"{icon} {node.name}{size_str}", style=style)
195
227
 
196
- # Add to tree at correct position
197
- # For root-level items, add directly to tree
198
- if node.parent and node.parent.path == os.path.abspath("."):
228
+ # Add to tree at correct level
229
+ if level == 0:
199
230
  tree_node = tree.add(label)
200
- node_map[abs_path] = tree_node
231
+ level_stacks[0] = tree_node
201
232
  else:
202
- # Find parent node in map
203
- parent_abs_path = os.path.abspath(node.parent.path) if node.parent else None
204
- if parent_abs_path and parent_abs_path in node_map:
205
- parent_tree = node_map[parent_abs_path]
233
+ # Find parent at previous level
234
+ parent_level = level - 1
235
+ if parent_level in level_stacks:
236
+ parent_tree = level_stacks[parent_level]
206
237
  tree_node = parent_tree.add(label)
207
- node_map[abs_path] = tree_node
238
+ level_stacks[level] = tree_node
208
239
  else:
209
- # Fallback - add to root
210
- tree_node = tree.add(label)
211
- node_map[abs_path] = tree_node
240
+ # Fallback - add to root with indentation indicator
241
+ indent_label = Text()
242
+ indent_label.append(" " * level + f"{selection} ", style="dim")
243
+ indent_label.append(f"{icon} {node.name}{size_str}", style=style)
244
+ tree_node = tree.add(indent_label)
245
+ level_stacks[level] = tree_node
246
+
247
+ # Add scroll indicator at bottom if needed
248
+ if viewport_end < len(self.visible_nodes):
249
+ remaining = len(self.visible_nodes) - viewport_end
250
+ tree.add(Text(f"↓ ({remaining} more items)", style="dim italic"))
212
251
 
213
252
  return tree
214
-
253
+
215
254
  def _show_help(self) -> Panel:
216
255
  """Create help panel"""
217
256
  help_text = """[bold]Navigation:[/bold]
218
- ↑/k: Move up ↓/j: Move down →/l/Enter: Expand dir ←/h: Collapse dir
257
+ ↑/k: Up ↓/j: Down →/l/Enter: Expand ←/h: Collapse
219
258
 
220
259
  [bold]Selection:[/bold]
221
260
  Space: Toggle file/dir a: Add all in dir s: Snippet mode
222
261
 
223
262
  [bold]Actions:[/bold]
224
- g: Grep in directory d: Show dependencies q: Quit selection
225
-
226
- [bold]Status:[/bold]
227
- Selected: [green]● Full[/green] [yellow]◐ Snippet[/yellow] ○ Not selected"""
263
+ r: Reuse last selection g: Grep in directory d: Show dependencies
264
+ q: Quit and finalize"""
228
265
 
229
- return Panel(help_text, title="Keyboard Shortcuts", border_style="dim", expand=False)
266
+ return Panel(help_text, title="Keyboard Controls", border_style="dim", expand=False)
230
267
 
231
268
  def _get_status_bar(self) -> str:
232
269
  """Create status bar with selection info"""
@@ -317,7 +354,7 @@ Selected: [green]● Full[/green] [yellow]◐ Snippet[/yellow] ○ Not selecte
317
354
  self.char_count += node.size
318
355
 
319
356
  def _toggle_directory(self, node: FileNode):
320
- """Toggle all files in a directory"""
357
+ """Toggle all files in a directory, now fully recursive."""
321
358
  if not node.is_dir:
322
359
  return
323
360
 
@@ -330,6 +367,9 @@ Selected: [green]● Full[/green] [yellow]◐ Snippet[/yellow] ○ Not selecte
330
367
 
331
368
  def collect_files(n: FileNode):
332
369
  if n.is_dir:
370
+ # CRITICAL FIX: Ensure sub-directory children are loaded before recursing
371
+ if not n.children:
372
+ self._scan_directory(n.path, n)
333
373
  for child in n.children:
334
374
  collect_files(child)
335
375
  else:
@@ -341,16 +381,92 @@ Selected: [green]● Full[/green] [yellow]◐ Snippet[/yellow] ○ Not selecte
341
381
  any_unselected = any(os.path.abspath(f.path) not in self.selected_files for f in all_files)
342
382
 
343
383
  if any_unselected:
344
- # Select all unselected
384
+ # Select all unselected files
345
385
  for file_node in all_files:
346
- if file_node.path not in self.selected_files:
347
- self._toggle_selection(file_node)
386
+ abs_path = os.path.abspath(file_node.path)
387
+ if abs_path not in self.selected_files:
388
+ self.selected_files[abs_path] = (False, None)
389
+ self.char_count += file_node.size
348
390
  else:
349
- # Unselect all
391
+ # Unselect all files
350
392
  for file_node in all_files:
351
- if file_node.path in self.selected_files:
352
- self._toggle_selection(file_node)
353
-
393
+ abs_path = os.path.abspath(file_node.path)
394
+ if abs_path in self.selected_files:
395
+ is_snippet, _ = self.selected_files[abs_path]
396
+ del self.selected_files[abs_path]
397
+ if is_snippet:
398
+ self.char_count -= len(get_file_snippet(file_node.path))
399
+ else:
400
+ self.char_count -= file_node.size
401
+
402
+ def _propose_and_apply_last_selection(self):
403
+ """Loads paths from cache, shows a confirmation dialog, and applies the selection if confirmed."""
404
+ cached_paths = load_selection_from_cache()
405
+
406
+ if not cached_paths:
407
+ self.console.print(Panel("[yellow]No cached selection found to reuse.[/yellow]", title="Info", border_style="dim"))
408
+ click.pause("Press any key to continue...")
409
+ return
410
+
411
+ # Categorize cached paths for the preview
412
+ files_to_add = []
413
+ files_already_selected = []
414
+ files_not_found = []
415
+
416
+ for rel_path in cached_paths:
417
+ abs_path = os.path.abspath(rel_path)
418
+ if not os.path.isfile(abs_path):
419
+ files_not_found.append(rel_path)
420
+ continue
421
+
422
+ if abs_path in self.selected_files:
423
+ files_already_selected.append(rel_path)
424
+ else:
425
+ files_to_add.append(rel_path)
426
+
427
+ # Build the rich text for the confirmation panel
428
+ preview_text = Text()
429
+ if files_to_add:
430
+ preview_text.append("The following files will be ADDED:\n", style="bold")
431
+ for path in sorted(files_to_add):
432
+ preview_text.append(" ")
433
+ preview_text.append("+", style="cyan")
434
+ preview_text.append(f" {path}\n")
435
+
436
+ if files_already_selected:
437
+ preview_text.append("\nAlready selected (no change):\n", style="bold dim")
438
+ for path in sorted(files_already_selected):
439
+ preview_text.append(f" ✓ {path}\n")
440
+
441
+ if files_not_found:
442
+ preview_text.append("\nNot found on disk (will be skipped):\n", style="bold dim")
443
+ for path in sorted(files_not_found):
444
+ preview_text.append(" ")
445
+ preview_text.append("-", style="red")
446
+ preview_text.append(f" {path}\n")
447
+
448
+ # Display the confirmation panel and prompt
449
+ self.console.clear()
450
+ self.console.print(Panel(preview_text, title="[bold cyan]Reuse Last Selection?", border_style="cyan", padding=(1, 2)))
451
+
452
+ if not files_to_add:
453
+ self.console.print("\n[yellow]No new files to add from the last selection.[/yellow]")
454
+ click.pause("Press any key to continue...")
455
+ return
456
+
457
+ # Use click.confirm for a simple and effective y/n prompt
458
+ if not click.confirm(f"\nAdd {len(files_to_add)} file(s) to your current selection?", default=True):
459
+ return
460
+
461
+ # If confirmed, apply the changes
462
+ for rel_path in files_to_add:
463
+ abs_path = os.path.abspath(rel_path)
464
+ if os.path.isfile(abs_path) and abs_path not in self.selected_files:
465
+ file_size = os.path.getsize(abs_path)
466
+ self.selected_files[abs_path] = (False, None)
467
+ self.char_count += file_size
468
+ self._ensure_path_visible(abs_path)
469
+
354
470
  def _ensure_path_visible(self, file_path: str):
355
471
  """Ensure a file path is visible in the tree by expanding parent directories"""
356
472
  abs_file_path = os.path.abspath(file_path)
@@ -456,6 +572,20 @@ Selected: [green]● Full[/green] [yellow]◐ Snippet[/yellow] ○ Not selecte
456
572
  self.current_index = max(0, self.current_index - 1)
457
573
  elif key in ['\x1b[B', 'j']: # Down arrow or j
458
574
  self.current_index = min(len(self.visible_nodes) - 1, self.current_index + 1)
575
+ elif key == '\x1b[5~': # Page Up
576
+ term_width, term_height = shutil.get_terminal_size()
577
+ page_size = max(1, term_height - 15)
578
+ self.current_index = max(0, self.current_index - page_size)
579
+ elif key == '\x1b[6~': # Page Down
580
+ term_width, term_height = shutil.get_terminal_size()
581
+ page_size = max(1, term_height - 15)
582
+ self.current_index = min(len(self.visible_nodes) - 1, self.current_index + page_size)
583
+ elif key == '\x1b[H': # Home - go to top
584
+ self.current_index = 0
585
+ elif key == '\x1b[F': # End - go to bottom
586
+ self.current_index = len(self.visible_nodes) - 1
587
+ elif key == 'G': # Shift+G - go to bottom (vim style)
588
+ self.current_index = len(self.visible_nodes) - 1
459
589
  elif key in ['\x1b[C', 'l', '\r']: # Right arrow, l, or Enter
460
590
  if current_node.is_dir:
461
591
  current_node.expanded = True
@@ -480,10 +610,11 @@ Selected: [green]● Full[/green] [yellow]◐ Snippet[/yellow] ○ Not selecte
480
610
  self._toggle_directory(current_node)
481
611
 
482
612
  # Handle actions
613
+ elif key == 'r': # Reuse last selection
614
+ self._propose_and_apply_last_selection()
483
615
  elif key == 'g': # Grep
484
616
  self.console.print() # Add some space
485
617
  self._handle_grep(current_node)
486
- click.pause("Press any key to continue...")
487
618
  elif key == 'd': # Dependencies
488
619
  self.console.print() # Add some space
489
620
  self._show_dependencies(current_node)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: kopipasta
3
- Version: 0.29.0
3
+ Version: 0.31.0
4
4
  Summary: A CLI tool to generate prompts with project structure and file contents
5
5
  Home-page: https://github.com/mkorpela/kopipasta
6
6
  Author: Mikko Korpela
@@ -0,0 +1,13 @@
1
+ kopipasta/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ kopipasta/cache.py,sha256=rH52yX3g7p22_1NXmrBDcBEntm3ivMjtV8n8zxTLY1k,1454
3
+ kopipasta/file.py,sha256=2HEoNczbKH3TtLO0zYUcZfUoHqb0-o83xFUOgY9rG-Y,1244
4
+ kopipasta/import_parser.py,sha256=yLzkMlQm2avKjfqcpMY0PxbA_2ihV9gSYJplreWIPEQ,12424
5
+ kopipasta/main.py,sha256=3Sle6pFEko4HSCXClmEb1L4QsN41VEOd0ZuzxeUDFUk,49210
6
+ kopipasta/prompt.py,sha256=fOCuJVTLUfR0fjKf5qIlnl_3pNsKNKsvt3C8f4tsmxk,6889
7
+ kopipasta/tree_selector.py,sha256=Iov8KLFopb7OLvX-xhn0FmFy0Ee5rFpPYzV6JLxCR84,27614
8
+ kopipasta-0.31.0.dist-info/LICENSE,sha256=xw4C9TAU7LFu4r_MwSbky90uzkzNtRwAo3c51IWR8lk,1091
9
+ kopipasta-0.31.0.dist-info/METADATA,sha256=RO8xJpSYGdqzsr-A7a22wTJEu2EwF_xtzJaziMD9XUw,4894
10
+ kopipasta-0.31.0.dist-info/WHEEL,sha256=GV9aMThwP_4oNCtvEC2ec3qUYutgWeAzklro_0m4WJQ,91
11
+ kopipasta-0.31.0.dist-info/entry_points.txt,sha256=but54qDNz1-F8fVvGstq_QID5tHjczP7bO7rWLFkc6Y,50
12
+ kopipasta-0.31.0.dist-info/top_level.txt,sha256=iXohixMuCdw8UjGDUp0ouICLYBDrx207sgZIJ9lxn0o,10
13
+ kopipasta-0.31.0.dist-info/RECORD,,
@@ -1,12 +0,0 @@
1
- kopipasta/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- kopipasta/file.py,sha256=2HEoNczbKH3TtLO0zYUcZfUoHqb0-o83xFUOgY9rG-Y,1244
3
- kopipasta/import_parser.py,sha256=yLzkMlQm2avKjfqcpMY0PxbA_2ihV9gSYJplreWIPEQ,12424
4
- kopipasta/main.py,sha256=MlfmP_eG0ZyvdhxdGyPsN2TxqnM4hq9pa2ToQEbbNP4,48894
5
- kopipasta/prompt.py,sha256=fOCuJVTLUfR0fjKf5qIlnl_3pNsKNKsvt3C8f4tsmxk,6889
6
- kopipasta/tree_selector.py,sha256=Dr7R4d19NzxWMwfQI3GioaD2i1ho84hMRxCzVp4sfCg,21342
7
- kopipasta-0.29.0.dist-info/LICENSE,sha256=xw4C9TAU7LFu4r_MwSbky90uzkzNtRwAo3c51IWR8lk,1091
8
- kopipasta-0.29.0.dist-info/METADATA,sha256=4vQpgi-XLKavkyQulJTbEscOUX5uAe1n6n-PJZwKTck,4894
9
- kopipasta-0.29.0.dist-info/WHEEL,sha256=GV9aMThwP_4oNCtvEC2ec3qUYutgWeAzklro_0m4WJQ,91
10
- kopipasta-0.29.0.dist-info/entry_points.txt,sha256=but54qDNz1-F8fVvGstq_QID5tHjczP7bO7rWLFkc6Y,50
11
- kopipasta-0.29.0.dist-info/top_level.txt,sha256=iXohixMuCdw8UjGDUp0ouICLYBDrx207sgZIJ9lxn0o,10
12
- kopipasta-0.29.0.dist-info/RECORD,,