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/file.py +47 -0
- kopipasta/main.py +399 -640
- kopipasta/prompt.py +163 -0
- kopipasta/tree_selector.py +510 -0
- kopipasta-0.29.0.dist-info/METADATA +111 -0
- kopipasta-0.29.0.dist-info/RECORD +12 -0
- kopipasta-0.27.0.dist-info/METADATA +0 -171
- kopipasta-0.27.0.dist-info/RECORD +0 -9
- {kopipasta-0.27.0.dist-info → kopipasta-0.29.0.dist-info}/LICENSE +0 -0
- {kopipasta-0.27.0.dist-info → kopipasta-0.29.0.dist-info}/WHEEL +0 -0
- {kopipasta-0.27.0.dist-info → kopipasta-0.29.0.dist-info}/entry_points.txt +0 -0
- {kopipasta-0.27.0.dist-info → kopipasta-0.29.0.dist-info}/top_level.txt +0 -0
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
|