kopipasta 0.27.0__py3-none-any.whl → 0.29.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of kopipasta might be problematic. Click here for more details.

kopipasta/prompt.py ADDED
@@ -0,0 +1,163 @@
1
+ import os
2
+ from kopipasta.file import FileTuple, read_file_contents, is_ignored
3
+
4
+
5
+ from typing import Dict, List, Tuple
6
+
7
+
8
+ def get_file_snippet(file_path, max_lines=50, max_bytes=4096):
9
+ snippet = ""
10
+ byte_count = 0
11
+ with open(file_path, 'r') as file:
12
+ for i, line in enumerate(file):
13
+ if i >= max_lines or byte_count >= max_bytes:
14
+ break
15
+ snippet += line
16
+ byte_count += len(line.encode('utf-8'))
17
+ return snippet
18
+
19
+
20
+ def get_language_for_file(file_path):
21
+ extension = os.path.splitext(file_path)[1].lower()
22
+ language_map = {
23
+ '.py': 'python',
24
+ '.js': 'javascript',
25
+ '.jsx': 'jsx',
26
+ '.ts': 'typescript',
27
+ '.tsx': 'tsx',
28
+ '.html': 'html',
29
+ '.htm': 'html',
30
+ '.css': 'css',
31
+ '.json': 'json',
32
+ '.md': 'markdown',
33
+ '.sql': 'sql',
34
+ '.sh': 'bash',
35
+ '.yml': 'yaml',
36
+ '.yaml': 'yaml',
37
+ '.go': 'go',
38
+ '.toml': 'toml',
39
+ '.c': 'c',
40
+ '.cpp': 'cpp',
41
+ '.cc': 'cpp',
42
+ '.h': 'cpp',
43
+ '.hpp': 'cpp',
44
+ }
45
+ return language_map.get(extension, '')
46
+
47
+
48
+ def get_project_structure(ignore_patterns):
49
+ tree = []
50
+ for root, dirs, files in os.walk('.'):
51
+ dirs[:] = [d for d in dirs if not is_ignored(os.path.join(root, d), ignore_patterns)]
52
+ files = [f for f in files if not is_ignored(os.path.join(root, f), ignore_patterns)]
53
+ level = root.replace('.', '').count(os.sep)
54
+ indent = ' ' * 4 * level + '|-- '
55
+ tree.append(f"{indent}{os.path.basename(root)}/")
56
+ subindent = ' ' * 4 * (level + 1) + '|-- '
57
+ for f in files:
58
+ tree.append(f"{subindent}{f}")
59
+ return '\n'.join(tree)
60
+
61
+
62
+ def handle_env_variables(content, env_vars):
63
+ detected_vars = []
64
+ for key, value in env_vars.items():
65
+ if value in content:
66
+ detected_vars.append((key, value))
67
+ if not detected_vars:
68
+ return content
69
+
70
+ print("Detected environment variables:")
71
+ for key, value in detected_vars:
72
+ print(f"- {key}={value}")
73
+
74
+ for key, value in detected_vars:
75
+ while True:
76
+ choice = input(f"How would you like to handle {key}? (m)ask / (s)kip / (k)eep: ").lower()
77
+ if choice in ['m', 's', 'k']:
78
+ break
79
+ print("Invalid choice. Please enter 'm', 's', or 'k'.")
80
+
81
+ if choice == 'm':
82
+ content = content.replace(value, '*' * len(value))
83
+ elif choice == 's':
84
+ content = content.replace(value, "[REDACTED]")
85
+ # If 'k', we don't modify the content
86
+
87
+ return content
88
+
89
+
90
+ def generate_prompt_template(files_to_include: List[FileTuple], ignore_patterns: List[str], web_contents: Dict[str, Tuple[FileTuple, str]], env_vars: Dict[str, str]) -> Tuple[str, int]:
91
+ prompt = "# Project Overview\n\n"
92
+ prompt += "## Project Structure\n\n"
93
+ prompt += "```\n"
94
+ prompt += get_project_structure(ignore_patterns)
95
+ prompt += "\n```\n\n"
96
+ prompt += "## File Contents\n\n"
97
+ for file, use_snippet, chunks, content_type in files_to_include:
98
+ relative_path = os.path.relpath(file)
99
+ language = content_type if content_type else get_language_for_file(file)
100
+
101
+ if chunks is not None:
102
+ prompt += f"### {relative_path} (selected patches)\n\n```{language}\n"
103
+ for chunk in chunks:
104
+ prompt += f"{chunk}\n"
105
+ prompt += "```\n\n"
106
+ elif use_snippet:
107
+ file_content = get_file_snippet(file)
108
+ prompt += f"### {relative_path} (snippet)\n\n```{language}\n{file_content}\n```\n\n"
109
+ else:
110
+ file_content = read_file_contents(file)
111
+ file_content = handle_env_variables(file_content, env_vars)
112
+ prompt += f"### {relative_path}\n\n```{language}\n{file_content}\n```\n\n"
113
+
114
+ if web_contents:
115
+ prompt += "## Web Content\n\n"
116
+ for url, (file_tuple, content) in web_contents.items():
117
+ _, is_snippet, _, content_type = file_tuple
118
+ content = handle_env_variables(content, env_vars)
119
+ language = content_type if content_type in ['json', 'csv'] else ''
120
+ prompt += f"### {url}{' (snippet)' if is_snippet else ''}\n\n```{language}\n{content}\n```\n\n"
121
+
122
+ prompt += "## Task Instructions\n\n"
123
+ cursor_position = len(prompt)
124
+ prompt += "\n\n"
125
+ prompt += "## Instructions for Achieving the Task\n\n"
126
+ analysis_text = (
127
+ "### Partnership Principles\n\n"
128
+ "We work as collaborative partners. You provide technical expertise and critical thinking. "
129
+ "I have exclusive access to my codebase, real environment, external services, and actual users. "
130
+ "Never assume project file contents - always ask to see them.\n\n"
131
+ "**Critical Thinking**: Challenge poor approaches, identify risks, suggest better alternatives. Don't be a yes-man.\n\n"
132
+ "**Anti-Hallucination**: Never write placeholder code for files in ## Project Structure. Use [STOP - NEED FILE: filename] and wait.\n\n"
133
+ "**Hard Stops**: End with [AWAITING USER RESPONSE] when you need input. Don't continue with assumptions.\n\n"
134
+ "### Development Workflow\n\n"
135
+ "We work in two modes:\n"
136
+ "- **Iterative Mode**: Build incrementally, show only changes\n"
137
+ "- **Consolidation Mode**: When I request, provide clean final version\n\n"
138
+ "1. **Understand & Analyze**:\n"
139
+ " - Rephrase task, identify issues, list needed files\n"
140
+ " - Challenge problematic aspects\n"
141
+ " - End: 'I need: [files]. Is this correct?' [AWAITING USER RESPONSE]\n\n"
142
+ "2. **Plan**:\n"
143
+ " - Present 2-3 approaches with pros/cons\n"
144
+ " - Recommend best approach\n"
145
+ " - End: 'Which approach?' [AWAITING USER RESPONSE]\n\n"
146
+ "3. **Implement Iteratively**:\n"
147
+ " - Small, testable increments\n"
148
+ " - Track failed attempts: `Attempt 1: [FAILED] X→Y (learned: Z)`\n"
149
+ " - After 3 failures, request diagnostics\n\n"
150
+ "4. **Code Presentation**:\n"
151
+ " - Always: `// FILE: path/to/file.ext`\n"
152
+ " - Iterative: Show only changes with context\n"
153
+ " - Consolidation: Smart choice - minimal changes = show patches, extensive = full file\n\n"
154
+ "5. **Test & Validate**:\n"
155
+ " - 'Test with: [command]. Share any errors.' [AWAITING USER RESPONSE]\n"
156
+ " - Include debug outputs\n"
157
+ " - May return to implementation based on results\n\n"
158
+ "### Permissions & Restrictions\n\n"
159
+ "**You MAY**: Request project files, ask me to test code/services, challenge my approach, refuse without info\n\n"
160
+ "**You MUST NOT**: Assume project file contents, continue past [AWAITING USER RESPONSE], be agreeable when you see problems\n"
161
+ )
162
+ prompt += analysis_text
163
+ return prompt, cursor_position
@@ -0,0 +1,510 @@
1
+ import os
2
+ from typing import Dict, List, Optional, Tuple
3
+ from rich.console import Console
4
+ from rich.tree import Tree
5
+ from rich.panel import Panel
6
+ from rich.table import Table
7
+ from rich.text import Text
8
+ from rich.live import Live
9
+ from rich.layout import Layout
10
+ import click
11
+
12
+ from kopipasta.file import FileTuple, is_binary, is_ignored, get_human_readable_size
13
+ from kopipasta.prompt import get_file_snippet, get_language_for_file
14
+
15
+
16
+ class FileNode:
17
+ """Represents a file or directory in the tree"""
18
+ def __init__(self, path: str, is_dir: bool, parent: Optional['FileNode'] = None):
19
+ self.path = os.path.abspath(path) # Always store absolute paths
20
+ self.is_dir = is_dir
21
+ self.parent = parent
22
+ self.children: List['FileNode'] = []
23
+ self.expanded = False
24
+ self.selected = False
25
+ self.selected_as_snippet = False
26
+ self.size = 0 if is_dir else os.path.getsize(self.path)
27
+ self.is_root = path == "." # Mark if this is the root node
28
+
29
+ @property
30
+ def name(self):
31
+ if self.is_root:
32
+ return "." # Show root as "." instead of directory name
33
+ return os.path.basename(self.path) or self.path
34
+
35
+ @property
36
+ def relative_path(self):
37
+ if self.is_root:
38
+ return "."
39
+ return os.path.relpath(self.path)
40
+
41
+
42
+ class TreeSelector:
43
+ """Interactive file tree selector using Rich"""
44
+
45
+ def __init__(self, ignore_patterns: List[str], project_root_abs: str):
46
+ self.console = Console()
47
+ self.ignore_patterns = ignore_patterns
48
+ self.project_root_abs = project_root_abs
49
+ self.selected_files: Dict[str, Tuple[bool, Optional[List[str]]]] = {} # path -> (is_snippet, chunks)
50
+ self.current_index = 0
51
+ self.nodes: List[FileNode] = []
52
+ self.visible_nodes: List[FileNode] = []
53
+ self.char_count = 0
54
+ self.quit_selection = False
55
+
56
+ def build_tree(self, paths: List[str]) -> FileNode:
57
+ """Build tree structure from given paths"""
58
+ # Use current directory as root
59
+ root = FileNode(".", True)
60
+ root.expanded = True # Always expand root
61
+
62
+ # Process each input path
63
+ for path in paths:
64
+ abs_path = os.path.abspath(path)
65
+
66
+ if os.path.isfile(abs_path):
67
+ # Single file - add to root
68
+ if not is_ignored(abs_path, self.ignore_patterns) and not is_binary(abs_path):
69
+ node = FileNode(abs_path, False, root)
70
+ root.children.append(node)
71
+ elif os.path.isdir(abs_path):
72
+ # If the directory is the current directory, scan its contents directly
73
+ if abs_path == os.path.abspath("."):
74
+ self._scan_directory(abs_path, root)
75
+ else:
76
+ # Otherwise add the directory as a child
77
+ dir_node = FileNode(abs_path, True, root)
78
+ root.children.append(dir_node)
79
+ # Auto-expand if it's the only child
80
+ if len(paths) == 1:
81
+ dir_node.expanded = True
82
+ self._scan_directory(abs_path, dir_node)
83
+
84
+ return root
85
+
86
+ def _scan_directory(self, dir_path: str, parent_node: FileNode):
87
+ """Recursively scan directory and build tree"""
88
+ abs_dir_path = os.path.abspath(dir_path)
89
+
90
+ # Check if we've already scanned this directory
91
+ if parent_node.children:
92
+ return
93
+
94
+ try:
95
+ items = sorted(os.listdir(abs_dir_path))
96
+ except PermissionError:
97
+ return
98
+
99
+ # Separate and sort directories and files
100
+ dirs = []
101
+ files = []
102
+
103
+ for item in items:
104
+ item_path = os.path.join(abs_dir_path, item)
105
+ if is_ignored(item_path, self.ignore_patterns):
106
+ continue
107
+
108
+ if os.path.isdir(item_path):
109
+ dirs.append(item)
110
+ elif os.path.isfile(item_path) and not is_binary(item_path):
111
+ files.append(item)
112
+
113
+ # Add directories first
114
+ for dir_name in sorted(dirs):
115
+ dir_path_full = os.path.join(abs_dir_path, dir_name)
116
+ # Check if this node already exists as a child
117
+ existing = next((child for child in parent_node.children
118
+ if os.path.abspath(child.path) == os.path.abspath(dir_path_full)), None)
119
+ if not existing:
120
+ dir_node = FileNode(dir_path_full, True, parent_node)
121
+ parent_node.children.append(dir_node)
122
+
123
+ # Then add files
124
+ for file_name in sorted(files):
125
+ file_path = os.path.join(abs_dir_path, file_name)
126
+ # Check if this node already exists as a child
127
+ existing = next((child for child in parent_node.children
128
+ if os.path.abspath(child.path) == os.path.abspath(file_path)), None)
129
+ if not existing:
130
+ file_node = FileNode(file_path, False, parent_node)
131
+ parent_node.children.append(file_node)
132
+
133
+ def _flatten_tree(self, node: FileNode, level: int = 0) -> List[Tuple[FileNode, int]]:
134
+ """Flatten tree into a list of (node, level) tuples for display"""
135
+ result = []
136
+
137
+ # Special handling for root - show its children at top level
138
+ if node.is_root:
139
+ # Don't include the root node itself in the display
140
+ for child in node.children:
141
+ result.extend(self._flatten_tree(child, 0)) # Start children at level 0
142
+ else:
143
+ # Include this node
144
+ result.append((node, level))
145
+
146
+ if node.is_dir and node.expanded:
147
+ # Load children on demand if not loaded
148
+ if not node.children:
149
+ self._scan_directory(node.path, node)
150
+
151
+ for child in node.children:
152
+ result.extend(self._flatten_tree(child, level + 1))
153
+
154
+ return result
155
+
156
+ def _build_display_tree(self) -> Tree:
157
+ """Build Rich tree for display"""
158
+ tree = Tree("📁 Project Files", guide_style="dim")
159
+
160
+ # Flatten tree and rebuild visible nodes list
161
+ flat_tree = self._flatten_tree(self.root)
162
+ self.visible_nodes = [node for node, _ in flat_tree]
163
+
164
+ # Build tree structure - we'll map absolute paths to tree nodes
165
+ node_map = {}
166
+
167
+ for i, (node, level) in enumerate(flat_tree):
168
+ # Determine style and icon
169
+ is_current = i == self.current_index
170
+ style = "bold cyan" if is_current else ""
171
+
172
+ if node.is_dir:
173
+ icon = "📂" if node.expanded else "📁"
174
+ size_str = f" ({len(node.children)} items)" if node.children else ""
175
+ else:
176
+ icon = "📄"
177
+ size_str = f" ({get_human_readable_size(node.size)})"
178
+
179
+ # Selection indicator
180
+ abs_path = os.path.abspath(node.path)
181
+ if abs_path in self.selected_files:
182
+ is_snippet = self.selected_files[abs_path][0]
183
+ if is_snippet:
184
+ selection = "◐" # Half-selected (snippet)
185
+ else:
186
+ selection = "●" # Fully selected
187
+ style = "green " + style
188
+ else:
189
+ selection = "○"
190
+
191
+ # Build label
192
+ label = Text()
193
+ label.append(f"{selection} ", style="dim")
194
+ label.append(f"{icon} {node.name}{size_str}", style=style)
195
+
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("."):
199
+ tree_node = tree.add(label)
200
+ node_map[abs_path] = tree_node
201
+ 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]
206
+ tree_node = parent_tree.add(label)
207
+ node_map[abs_path] = tree_node
208
+ else:
209
+ # Fallback - add to root
210
+ tree_node = tree.add(label)
211
+ node_map[abs_path] = tree_node
212
+
213
+ return tree
214
+
215
+ def _show_help(self) -> Panel:
216
+ """Create help panel"""
217
+ help_text = """[bold]Navigation:[/bold]
218
+ ↑/k: Move up ↓/j: Move down →/l/Enter: Expand dir ←/h: Collapse dir
219
+
220
+ [bold]Selection:[/bold]
221
+ Space: Toggle file/dir a: Add all in dir s: Snippet mode
222
+
223
+ [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"""
228
+
229
+ return Panel(help_text, title="Keyboard Shortcuts", border_style="dim", expand=False)
230
+
231
+ def _get_status_bar(self) -> str:
232
+ """Create status bar with selection info"""
233
+ # Count selections
234
+ full_count = sum(1 for _, (is_snippet, _) in self.selected_files.items() if not is_snippet)
235
+ snippet_count = sum(1 for _, (is_snippet, _) in self.selected_files.items() if is_snippet)
236
+
237
+ # Current item info
238
+ if self.visible_nodes and 0 <= self.current_index < len(self.visible_nodes):
239
+ current = self.visible_nodes[self.current_index]
240
+ current_info = f"[dim]Current:[/dim] {current.relative_path}"
241
+ else:
242
+ current_info = "No selection"
243
+
244
+ selection_info = f"[dim]Selected:[/dim] {full_count} full, {snippet_count} snippets | ~{self.char_count:,} chars (~{self.char_count//4:,} tokens)"
245
+
246
+ return f"\n{current_info} | {selection_info}\n"
247
+
248
+ def _handle_grep(self, node: FileNode):
249
+ """Handle grep search in directory"""
250
+ if not node.is_dir:
251
+ self.console.print("[red]Grep only works on directories[/red]")
252
+ return
253
+
254
+ pattern = click.prompt("Enter search pattern")
255
+ if not pattern:
256
+ return
257
+
258
+ self.console.print(f"Searching for '{pattern}' in {node.relative_path}...")
259
+
260
+ # Import here to avoid circular dependency
261
+ from kopipasta.main import grep_files_in_directory, select_from_grep_results
262
+
263
+ grep_results = grep_files_in_directory(pattern, node.path, self.ignore_patterns)
264
+ if not grep_results:
265
+ self.console.print(f"[yellow]No matches found for '{pattern}'[/yellow]")
266
+ return
267
+
268
+ # Show results and let user select
269
+ selected_files, new_char_count = select_from_grep_results(grep_results, self.char_count)
270
+
271
+ # Add selected files
272
+ added_count = 0
273
+ for file_tuple in selected_files:
274
+ file_path, is_snippet, chunks, _ = file_tuple
275
+ abs_path = os.path.abspath(file_path)
276
+
277
+ # Check if already selected
278
+ if abs_path not in self.selected_files:
279
+ self.selected_files[abs_path] = (is_snippet, chunks)
280
+ added_count += 1
281
+ # Ensure the file is visible in the tree
282
+ self._ensure_path_visible(abs_path)
283
+
284
+ self.char_count = new_char_count
285
+
286
+ # Show summary of what was added
287
+ if added_count > 0:
288
+ self.console.print(f"\n[green]Added {added_count} files from grep results[/green]")
289
+ else:
290
+ self.console.print(f"\n[yellow]All selected files were already in selection[/yellow]")
291
+
292
+ def _toggle_selection(self, node: FileNode, snippet_mode: bool = False):
293
+ """Toggle selection of a file or directory"""
294
+ if node.is_dir:
295
+ # For directories, toggle all children
296
+ self._toggle_directory(node)
297
+ else:
298
+ abs_path = os.path.abspath(node.path)
299
+ # For files, toggle individual selection
300
+ if abs_path in self.selected_files:
301
+ # Unselect
302
+ is_snippet, _ = self.selected_files[abs_path]
303
+ del self.selected_files[abs_path]
304
+ if is_snippet:
305
+ self.char_count -= len(get_file_snippet(node.path))
306
+ else:
307
+ self.char_count -= node.size
308
+ else:
309
+ # Select
310
+ if snippet_mode or (node.size > 102400 and not self._confirm_large_file(node)):
311
+ # Use snippet
312
+ self.selected_files[abs_path] = (True, None)
313
+ self.char_count += len(get_file_snippet(node.path))
314
+ else:
315
+ # Use full file
316
+ self.selected_files[abs_path] = (False, None)
317
+ self.char_count += node.size
318
+
319
+ def _toggle_directory(self, node: FileNode):
320
+ """Toggle all files in a directory"""
321
+ if not node.is_dir:
322
+ return
323
+
324
+ # Ensure children are loaded
325
+ if not node.children:
326
+ self._scan_directory(node.path, node)
327
+
328
+ # Collect all files recursively
329
+ all_files = []
330
+
331
+ def collect_files(n: FileNode):
332
+ if n.is_dir:
333
+ for child in n.children:
334
+ collect_files(child)
335
+ else:
336
+ all_files.append(n)
337
+
338
+ collect_files(node)
339
+
340
+ # Check if any are unselected
341
+ any_unselected = any(os.path.abspath(f.path) not in self.selected_files for f in all_files)
342
+
343
+ if any_unselected:
344
+ # Select all unselected
345
+ for file_node in all_files:
346
+ if file_node.path not in self.selected_files:
347
+ self._toggle_selection(file_node)
348
+ else:
349
+ # Unselect all
350
+ for file_node in all_files:
351
+ if file_node.path in self.selected_files:
352
+ self._toggle_selection(file_node)
353
+
354
+ def _ensure_path_visible(self, file_path: str):
355
+ """Ensure a file path is visible in the tree by expanding parent directories"""
356
+ abs_file_path = os.path.abspath(file_path)
357
+
358
+ # Build the path from root to the file
359
+ path_components = []
360
+ current = abs_file_path
361
+
362
+ while current != os.path.abspath(self.project_root_abs) and current != '/':
363
+ path_components.append(current)
364
+ parent = os.path.dirname(current)
365
+ if parent == current: # Reached root
366
+ break
367
+ current = parent
368
+
369
+ # Reverse to go from root to file
370
+ path_components.reverse()
371
+
372
+ # Find and expand each directory in the path
373
+ for component_path in path_components[:-1]: # All except the file itself
374
+ # Search through all nodes to find this path
375
+ found = False
376
+ for node in self._get_all_nodes(self.root):
377
+ if os.path.abspath(node.path) == component_path and node.is_dir:
378
+ if not node.expanded:
379
+ node.expanded = True
380
+ # Ensure children are loaded
381
+ if not node.children:
382
+ self._scan_directory(node.path, node)
383
+ found = True
384
+ break
385
+
386
+ if not found:
387
+ # This shouldn't happen if the tree is properly built
388
+ self.console.print(f"[yellow]Warning: Could not find directory {component_path} in tree[/yellow]")
389
+
390
+ def _get_all_nodes(self, node: FileNode) -> List[FileNode]:
391
+ """Get all nodes in the tree recursively"""
392
+ nodes = [node]
393
+ for child in node.children:
394
+ nodes.extend(self._get_all_nodes(child))
395
+ return nodes
396
+
397
+ def _confirm_large_file(self, node: FileNode) -> bool:
398
+ """Ask user about large file handling"""
399
+ size_str = get_human_readable_size(node.size)
400
+ return click.confirm(f"{node.name} is large ({size_str}). Include full content?", default=False)
401
+
402
+ def _show_dependencies(self, node: FileNode):
403
+ """Show and optionally add dependencies for a file"""
404
+ if node.is_dir:
405
+ return
406
+
407
+ self.console.print(f"\nAnalyzing dependencies for {node.relative_path}...")
408
+
409
+ # Import here to avoid circular dependency
410
+ from kopipasta.main import _propose_and_add_dependencies
411
+
412
+ # Create a temporary files list for the dependency analyzer
413
+ files_list = [(path, is_snippet, chunks, get_language_for_file(path))
414
+ for path, (is_snippet, chunks) in self.selected_files.items()]
415
+
416
+ new_deps, deps_char_count = _propose_and_add_dependencies(
417
+ node.path, self.project_root_abs, files_list, self.char_count
418
+ )
419
+
420
+ # Add new dependencies to our selection
421
+ for dep_path, is_snippet, chunks, _ in new_deps:
422
+ self.selected_files[dep_path] = (is_snippet, chunks)
423
+
424
+ self.char_count += deps_char_count
425
+
426
+ def run(self, initial_paths: List[str]) -> Tuple[List[FileTuple], int]:
427
+ """Run the interactive tree selector"""
428
+ self.root = self.build_tree(initial_paths)
429
+
430
+ # Don't use Live mode, instead manually control the display
431
+ while not self.quit_selection:
432
+ # Clear and redraw
433
+ self.console.clear()
434
+
435
+ # Draw tree
436
+ tree = self._build_display_tree()
437
+ self.console.print(tree)
438
+
439
+ # Draw help
440
+ self.console.print(self._show_help())
441
+
442
+ # Draw status bar
443
+ self.console.print(self._get_status_bar())
444
+
445
+ try:
446
+ # Get keyboard input
447
+ key = click.getchar()
448
+
449
+ if not self.visible_nodes:
450
+ continue
451
+
452
+ current_node = self.visible_nodes[self.current_index]
453
+
454
+ # Handle navigation
455
+ if key in ['\x1b[A', 'k']: # Up arrow or k
456
+ self.current_index = max(0, self.current_index - 1)
457
+ elif key in ['\x1b[B', 'j']: # Down arrow or j
458
+ self.current_index = min(len(self.visible_nodes) - 1, self.current_index + 1)
459
+ elif key in ['\x1b[C', 'l', '\r']: # Right arrow, l, or Enter
460
+ if current_node.is_dir:
461
+ current_node.expanded = True
462
+ elif key in ['\x1b[D', 'h']: # Left arrow or h
463
+ if current_node.is_dir and current_node.expanded:
464
+ current_node.expanded = False
465
+ elif current_node.parent:
466
+ # Jump to parent
467
+ parent_idx = next((i for i, n in enumerate(self.visible_nodes)
468
+ if n == current_node.parent), None)
469
+ if parent_idx is not None:
470
+ self.current_index = parent_idx
471
+
472
+ # Handle selection
473
+ elif key == ' ': # Space - toggle selection
474
+ self._toggle_selection(current_node)
475
+ elif key == 's': # Snippet mode
476
+ if not current_node.is_dir:
477
+ self._toggle_selection(current_node, snippet_mode=True)
478
+ elif key == 'a': # Add all in directory
479
+ if current_node.is_dir:
480
+ self._toggle_directory(current_node)
481
+
482
+ # Handle actions
483
+ elif key == 'g': # Grep
484
+ self.console.print() # Add some space
485
+ self._handle_grep(current_node)
486
+ click.pause("Press any key to continue...")
487
+ elif key == 'd': # Dependencies
488
+ self.console.print() # Add some space
489
+ self._show_dependencies(current_node)
490
+ click.pause("Press any key to continue...")
491
+ elif key == 'q': # Quit
492
+ self.quit_selection = True
493
+ elif key == '\x03': # Ctrl+C
494
+ raise KeyboardInterrupt()
495
+
496
+ except Exception as e:
497
+ self.console.print(f"[red]Error: {e}[/red]")
498
+ click.pause("Press any key to continue...")
499
+
500
+ # Clear screen one more time
501
+ self.console.clear()
502
+
503
+ # Convert selections to FileTuple format
504
+ files_to_include = []
505
+ for abs_path, (is_snippet, chunks) in self.selected_files.items():
506
+ # Convert back to relative path for the output
507
+ rel_path = os.path.relpath(abs_path)
508
+ files_to_include.append((rel_path, is_snippet, chunks, get_language_for_file(abs_path)))
509
+
510
+ return files_to_include, self.char_count