janito 0.3.0__py3-none-any.whl → 0.4.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.
janito/console.py CHANGED
@@ -1,60 +1,330 @@
1
1
  from prompt_toolkit import PromptSession
2
2
  from prompt_toolkit.history import FileHistory
3
+ from prompt_toolkit.completion import WordCompleter, PathCompleter
4
+ from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
5
+ from prompt_toolkit.formatted_text import HTML
3
6
  from pathlib import Path
4
7
  from rich.console import Console
5
8
  from janito.claude import ClaudeAPIAgent
6
- from janito.prompts import build_request_analisys_prompt, SYSTEM_PROMPT
9
+ from janito.prompts import SYSTEM_PROMPT
10
+ from janito.analysis import build_request_analysis_prompt
7
11
  from janito.scan import collect_files_content
8
12
  from janito.__main__ import handle_option_selection
13
+ from rich.panel import Panel
14
+ from rich.align import Align
15
+ from janito.common import progress_send_message
16
+ from rich.table import Table
17
+ from rich.layout import Layout
18
+ from rich.live import Live
19
+ from typing import List, Optional
20
+ import shutil
9
21
 
10
- def start_console_session(workdir: Path, include: list[Path] = None) -> None:
11
- """Start an interactive console session using prompt_toolkit"""
22
+ def create_completer(workdir: Path) -> WordCompleter:
23
+ """Create command completer with common commands and paths"""
24
+ commands = [
25
+ 'ask', 'request', 'help', 'exit', 'quit',
26
+ '--raw', '--verbose', '--debug', '--test'
27
+ ]
28
+ return WordCompleter(commands, ignore_case=True)
29
+
30
+ def format_prompt(workdir: Path) -> HTML:
31
+ """Format the prompt with current directory"""
32
+ cwd = workdir.name
33
+ return HTML(f'<ansigreen>janito</ansigreen> <ansiblue>{cwd}</ansiblue>> ')
34
+
35
+ def display_help() -> None:
36
+ """Display available commands, options and their descriptions"""
37
+ console = Console()
38
+
39
+ layout = Layout()
40
+ layout.split_column(
41
+ Layout(name="header"),
42
+ Layout(name="commands"),
43
+ Layout(name="options"),
44
+ Layout(name="examples")
45
+ )
46
+
47
+ # Header
48
+ header_table = Table(box=None, show_header=False)
49
+ header_table.add_row("[bold cyan]Janito Console Help[/bold cyan]")
50
+ header_table.add_row("[dim]Your AI-powered software development buddy[/dim]")
51
+
52
+ # Commands table
53
+ commands_table = Table(title="Available Commands", box=None)
54
+ commands_table.add_column("Command", style="cyan", width=20)
55
+ commands_table.add_column("Description", style="white")
56
+
57
+
58
+ commands_table.add_row(
59
+ "/ask <text> (/a)",
60
+ "Ask a question about the codebase without making changes"
61
+ )
62
+ commands_table.add_row(
63
+ "<text> or /request <text> (/r)",
64
+ "Request code modifications or improvements"
65
+ )
66
+ commands_table.add_row(
67
+ "/help (/h)",
68
+ "Display this help message"
69
+ )
70
+ commands_table.add_row(
71
+ "/quit or /exit (/q)",
72
+ "Exit the console session"
73
+ )
74
+
75
+ # Options table
76
+ options_table = Table(title="Common Options", box=None)
77
+ options_table.add_column("Option", style="cyan", width=20)
78
+ options_table.add_column("Description", style="white")
79
+
80
+ options_table.add_row(
81
+ "--raw",
82
+ "Display raw response without formatting"
83
+ )
84
+ options_table.add_row(
85
+ "--verbose",
86
+ "Show additional information during execution"
87
+ )
88
+ options_table.add_row(
89
+ "--debug",
90
+ "Display detailed debug information"
91
+ )
92
+ options_table.add_row(
93
+ "--test <cmd>",
94
+ "Run specified test command before applying changes"
95
+ )
96
+
97
+ # Examples panel
98
+ examples = Panel(
99
+ "\n".join([
100
+ "[dim]Basic Commands:[/dim]",
101
+ " ask how does the error handling work?",
102
+ " request add input validation to user functions",
103
+ "",
104
+ "[dim]Using Options:[/dim]",
105
+ " request update tests --verbose",
106
+ " ask explain auth flow --raw",
107
+ " request optimize code --test 'pytest'",
108
+ "",
109
+ "[dim]Complex Examples:[/dim]",
110
+ " request refactor login function --verbose --test 'python -m unittest'",
111
+ " ask code structure --raw --debug"
112
+ ]),
113
+ title="Examples",
114
+ border_style="blue"
115
+ )
116
+
117
+ # Update layout
118
+ layout["header"].update(header_table)
119
+ layout["commands"].update(commands_table)
120
+ layout["options"].update(options_table)
121
+ layout["examples"].update(examples)
122
+
123
+ console.print(layout)
124
+
125
+
126
+
127
+ def process_command(command: str, args: str, workdir: Path, include: List[Path], claude: ClaudeAPIAgent) -> None:
128
+ """Process console commands using CLI functions for consistent behavior"""
129
+ console = Console()
130
+
131
+ # Parse command options
132
+ raw = False
133
+ verbose = False
134
+ debug = False
135
+ test_cmd = None
136
+
137
+ # Extract options from args
138
+ words = args.split()
139
+ filtered_args = []
140
+ i = 0
141
+ while i < len(words):
142
+ if words[i] == '--raw':
143
+ raw = True
144
+ elif words[i] == '--verbose':
145
+ verbose = True
146
+ elif words[i] == '--debug':
147
+ debug = True
148
+ elif words[i] == '--test' and i + 1 < len(words):
149
+ test_cmd = words[i + 1]
150
+ i += 1
151
+ else:
152
+ filtered_args.append(words[i])
153
+ i += 1
154
+
155
+ args = ' '.join(filtered_args)
156
+
157
+ # Update config with command options
158
+ from janito.config import config
159
+ config.set_debug(debug)
160
+ config.set_verbose(verbose)
161
+ config.set_test_cmd(test_cmd)
162
+
163
+ # Remove leading slash if present
164
+ command = command.lstrip('/')
165
+
166
+ # Handle command aliases
167
+ command_aliases = {
168
+ 'h': 'help',
169
+ 'a': 'ask',
170
+ 'r': 'request',
171
+ 'q': 'quit',
172
+ 'exit': 'quit'
173
+ }
174
+ command = command_aliases.get(command, command)
175
+
176
+ if command == "help":
177
+ display_help()
178
+ return
179
+
180
+ if command == "quit":
181
+ raise EOFError()
182
+
183
+ if command == "ask":
184
+ if not args:
185
+ console.print(Panel(
186
+ "[red]Ask command requires a question[/red]",
187
+ title="Error",
188
+ border_style="red"
189
+ ))
190
+ return
191
+
192
+ # Use CLI question processing function
193
+ from janito.__main__ import process_question
194
+ process_question(args, workdir, include, raw, claude)
195
+ return
196
+
197
+ if command == "request":
198
+ if not args:
199
+ console.print(Panel(
200
+ "[red]Request command requires a description[/red]",
201
+ title="Error",
202
+ border_style="red"
203
+ ))
204
+ return
205
+
206
+ paths_to_scan = [workdir] if workdir else []
207
+ if include:
208
+ paths_to_scan.extend(include)
209
+ files_content = collect_files_content(paths_to_scan, workdir)
210
+
211
+ # Use CLI request processing functions
212
+ initial_prompt = build_request_analysis_prompt(files_content, args)
213
+ initial_response = progress_send_message(claude, initial_prompt)
214
+
215
+ from janito.__main__ import save_to_file
216
+ save_to_file(initial_response, 'analysis', workdir)
217
+
218
+ from janito.analysis import format_analysis
219
+ format_analysis(initial_response, raw, claude)
220
+ handle_option_selection(claude, initial_response, args, raw, workdir, include)
221
+ return
222
+
223
+ console.print(Panel(
224
+ f"[red]Unknown command: /{command}[/red]\nType '/help' for available commands",
225
+ title="Error",
226
+ border_style="red"
227
+ ))
228
+
229
+ def start_console_session(workdir: Path, include: Optional[List[Path]] = None) -> None:
230
+ """Start an enhanced interactive console session"""
12
231
  console = Console()
13
232
  claude = ClaudeAPIAgent(system_prompt=SYSTEM_PROMPT)
14
233
 
15
- # Setup prompt session with history
234
+ # Setup history with persistence
16
235
  history_file = workdir / '.janito' / 'console_history'
17
236
  history_file.parent.mkdir(parents=True, exist_ok=True)
18
- session = PromptSession(history=FileHistory(str(history_file)))
237
+
238
+ # Create session with history and completions
239
+ session = PromptSession(
240
+ history=FileHistory(str(history_file)),
241
+ completer=create_completer(workdir),
242
+ auto_suggest=AutoSuggestFromHistory(),
243
+ complete_while_typing=True
244
+ )
19
245
 
246
+ # Get version and terminal info
20
247
  from importlib.metadata import version
21
248
  try:
22
249
  ver = version("janito")
23
250
  except:
24
251
  ver = "dev"
252
+
253
+ term_width = shutil.get_terminal_size().columns
254
+
25
255
 
26
- console.print("\n[bold blue]╔═══════════════════════════════════════════╗[/bold blue]")
27
- console.print("[bold blue]║ Janito AI Assistant ║[/bold blue]")
28
- console.print("[bold blue]║ v" + ver.ljust(8) + " ║[/bold blue]")
29
- console.print("[bold blue]╠═══════════════════════════════════════════╣[/bold blue]")
30
- console.print("[bold blue]║ Your AI-powered development companion ║[/bold blue]")
31
- console.print("[bold blue]╚═══════════════════════════════════════════╝[/bold blue]")
32
- console.print("\n[cyan]Type your requests or 'exit' to quit[/cyan]\n")
256
+
257
+ # Create welcome message with consistent colors and enhanced information
258
+ COLORS = {
259
+ 'primary': '#729FCF', # Soft blue for primary elements
260
+ 'secondary': '#8AE234', # Bright green for actions/success
261
+ 'accent': '#AD7FA8', # Purple for accents
262
+ 'muted': '#7F9F7F', # Muted green for less important text
263
+ }
264
+
265
+ welcome_text = (
266
+ f"[bold {COLORS['primary']}]Welcome to Janito v{ver}[/bold {COLORS['primary']}]\n"
267
+ f"[{COLORS['muted']}]Your AI-Powered Software Development Buddy[/{COLORS['muted']}]\n\n"
268
+ f"[{COLORS['accent']}]Keyboard Shortcuts:[/{COLORS['accent']}]\n"
269
+ "• ↑↓ : Navigate command history\n"
270
+ "• Tab : Complete commands and paths\n"
271
+ "• Ctrl+D : Exit console\n"
272
+ "• Ctrl+C : Cancel current operation\n\n"
273
+ f"[{COLORS['accent']}]Available Commands:[/{COLORS['accent']}]\n"
274
+ "• /ask (or /a) : Ask questions about code\n"
275
+ "• /request (or /r) : Request code changes\n"
276
+ "• /help (or /h) : Show detailed help\n"
277
+ "• /quit (or /q) : Exit console\n\n"
278
+ f"[{COLORS['accent']}]Quick Tips:[/{COLORS['accent']}]\n"
279
+ "• Start typing and press Tab for suggestions\n"
280
+ "• Use --test to run tests before changes\n"
281
+ "• Add --verbose for detailed output\n"
282
+ "• Type a request directly without /request\n\n"
283
+ f"[{COLORS['secondary']}]Current Version:[/{COLORS['secondary']}] v{ver}\n"
284
+ f"[{COLORS['muted']}]Working Directory:[/{COLORS['muted']}] {workdir.absolute()}"
285
+ )
286
+
287
+ welcome_panel = Panel(
288
+ welcome_text,
289
+ width=min(80, term_width - 4),
290
+ border_style="blue",
291
+ title="Janito Console",
292
+ subtitle="Press Tab for completions"
293
+ )
294
+
295
+ console.print("\n")
296
+ console.print(welcome_panel)
297
+ console.print("\n[cyan]How can I help you with your code today?[/cyan]\n")
33
298
 
34
299
  while True:
35
300
  try:
36
- request = session.prompt("janito> ")
37
- if request.lower() in ('exit', 'quit'):
38
- break
39
-
40
- if not request.strip():
301
+ # Get input with formatted prompt
302
+ user_input = session.prompt(
303
+ lambda: format_prompt(workdir),
304
+ complete_while_typing=True
305
+ ).strip()
306
+
307
+ if not user_input:
41
308
  continue
309
+
310
+ if user_input.lower() in ('exit', 'quit'):
311
+ console.print("\n[cyan]Goodbye! Have a great day![/cyan]\n")
312
+ break
42
313
 
43
- # Get current files content
44
- paths_to_scan = [workdir] if workdir else []
45
- if include:
46
- paths_to_scan.extend(include)
47
- files_content = collect_files_content(paths_to_scan, workdir)
48
-
49
- # Get initial analysis
50
- initial_prompt = build_request_analisys_prompt(files_content, request)
51
- initial_response = claude.send_message(initial_prompt)
52
-
53
- # Show response and handle options
54
- console.print(initial_response)
55
- handle_option_selection(claude, initial_response, request, False, workdir, include)
314
+ # Split input into command and args
315
+ parts = user_input.split(maxsplit=1)
316
+ if parts[0].startswith('/'): # Handle /command format
317
+ command = parts[0][1:] # Remove the / prefix
318
+ else:
319
+ command = "request" # Default to request if no command specified
320
+
321
+ args = parts[1] if len(parts) > 1 else ""
322
+
323
+ # Process command with separated args
324
+ process_command(command, args, workdir, include, claude)
56
325
 
57
326
  except KeyboardInterrupt:
58
327
  continue
59
328
  except EOFError:
329
+ console.print("\n[cyan]Goodbye! Have a great day![/cyan]\n")
60
330
  break
janito/contentchange.py CHANGED
@@ -1,13 +1,9 @@
1
- import re
2
1
  from pathlib import Path
3
- from typing import Dict, Tuple, TypedDict, List
2
+ from typing import Dict, Tuple
4
3
  from rich.console import Console
5
- from rich.prompt import Confirm
6
- import tempfile
7
- from janito.changeviewer import show_file_changes, FileChange, show_diff_changes
8
- import ast
9
4
  from datetime import datetime
10
- import shutil
5
+ from janito.fileparser import FileChange, parse_block_changes
6
+ from janito.changeapplier import preview_and_apply_changes
11
7
 
12
8
  def get_file_type(filepath: Path) -> str:
13
9
  """Determine the type of saved file based on its name"""
@@ -22,24 +18,6 @@ def get_file_type(filepath: Path) -> str:
22
18
  return 'response'
23
19
  return 'unknown'
24
20
 
25
- def parse_block_changes(content: str) -> Dict[Path, FileChange]:
26
- """Parse file changes from code blocks in the content.
27
- Returns dict mapping filepath -> FileChange"""
28
- changes = {}
29
- pattern = r'##\s*([\da-f-]+)\s+([^\n]+)\s+begin\s*"([^"]*)"[^\n]*##\n(.*?)##\s*\1\s+\2\s+end\s*##'
30
- matches = re.finditer(pattern, content, re.DOTALL)
31
-
32
- for match in matches:
33
- filepath = Path(match.group(2))
34
- description = match.group(3)
35
- file_content = match.group(4).strip()
36
- changes[filepath] = FileChange(
37
- description=description,
38
- new_content=file_content
39
- )
40
-
41
- return changes
42
-
43
21
  def save_changes_to_history(content: str, request: str, workdir: Path) -> Path:
44
22
  """Save change content to history folder with timestamp and request info"""
45
23
  timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") # Already in the correct format
@@ -71,8 +49,6 @@ def validate_python_syntax(content: str, filepath: Path) -> Tuple[bool, str]:
71
49
  return True, ""
72
50
  except SyntaxError as e:
73
51
  return False, f"Line {e.lineno}: {e.msg}"
74
- except Exception as e:
75
- return False, str(e)
76
52
 
77
53
  def format_parsed_changes(changes: Dict[Path, Tuple[str, str]]) -> str:
78
54
  """Format parsed changes to show only file change descriptions"""
@@ -81,55 +57,7 @@ def format_parsed_changes(changes: Dict[Path, Tuple[str, str]]) -> str:
81
57
  result.append(f"=== {filepath} ===\n{description}\n")
82
58
  return "\n".join(result)
83
59
 
84
- def validate_changes(changes: Dict[Path, FileChange]) -> Tuple[bool, List[Tuple[Path, str]]]:
85
- """Validate all changes, returns (is_valid, list of errors)"""
86
- errors = []
87
- for filepath, change in changes.items():
88
- if filepath.suffix == '.py':
89
- is_valid, error = validate_python_syntax(change['new_content'], filepath)
90
- if not is_valid:
91
- errors.append((filepath, error))
92
- return len(errors) == 0, errors
93
-
94
- def preview_and_apply_changes(changes: Dict[Path, FileChange], workdir: Path) -> bool:
95
- """Preview changes in temporary directory and apply if confirmed."""
96
- console = Console()
97
-
98
- if not changes:
99
- console.print("\n[yellow]No changes were found to apply[/yellow]")
100
- return False
101
-
102
- with tempfile.TemporaryDirectory() as temp_dir:
103
- preview_dir = Path(temp_dir)
104
- if workdir.exists():
105
- shutil.copytree(workdir, preview_dir, dirs_exist_ok=True)
106
-
107
- for filepath, change in changes.items():
108
- # Get original content
109
- orig_path = workdir / filepath
110
- original = orig_path.read_text() if orig_path.exists() else ""
111
-
112
- # Prepare preview
113
- preview_path = preview_dir / filepath
114
- preview_path.parent.mkdir(parents=True, exist_ok=True)
115
- preview_path.write_text(change['new_content'])
116
-
117
- # Show changes
118
- show_diff_changes(console, filepath, original, change['new_content'], change['description'])
119
-
120
- # Apply changes if confirmed
121
- if Confirm.ask("\nApply these changes?"):
122
- for filepath, _ in changes.items():
123
- preview_path = preview_dir / filepath
124
- target_path = workdir / filepath
125
- target_path.parent.mkdir(parents=True, exist_ok=True)
126
- shutil.copy2(preview_path, target_path)
127
- console.print(f"[green]✓[/green] Applied changes to {filepath}")
128
- return True
129
-
130
- return False
131
-
132
- def apply_content_changes(content: str, request: str, workdir: Path) -> Tuple[bool, Path]:
60
+ def apply_content_changes(content: str, request: str, workdir: Path, test_cmd: str = None) -> Tuple[bool, Path]:
133
61
  """Regular flow: Parse content, save to history, and apply changes."""
134
62
  console = Console()
135
63
  changes = parse_block_changes(content)
@@ -138,20 +66,11 @@ def apply_content_changes(content: str, request: str, workdir: Path) -> Tuple[bo
138
66
  console.print("\n[yellow]No file changes were found in the response[/yellow]")
139
67
  return False, None
140
68
 
141
- # Validate changes before proceeding
142
- is_valid, errors = validate_changes(changes)
143
- if not is_valid:
144
- console = Console()
145
- console.print("\n[red bold]⚠️ Cannot apply changes: Python syntax errors detected![/red bold]")
146
- for filepath, error in errors:
147
- console.print(f"\n[red]⚠️ {filepath}: {error}[/red]")
148
- return False, None
149
-
150
69
  history_file = save_changes_to_history(content, request, workdir)
151
- success = preview_and_apply_changes(changes, workdir)
70
+ success = preview_and_apply_changes(changes, workdir, test_cmd)
152
71
  return success, history_file
153
72
 
154
- def handle_changes_file(filepath: Path, workdir: Path) -> Tuple[bool, Path]:
73
+ def handle_changes_file(filepath: Path, workdir: Path, test_cmd: str = None) -> Tuple[bool, Path]:
155
74
  """Replay flow: Load changes from file and apply them."""
156
75
  content = filepath.read_text()
157
76
  changes = parse_block_changes(content)
@@ -161,5 +80,5 @@ def handle_changes_file(filepath: Path, workdir: Path) -> Tuple[bool, Path]:
161
80
  console.print("\n[yellow]No file changes were found in the file[/yellow]")
162
81
  return False, None
163
82
 
164
- success = preview_and_apply_changes(changes, workdir)
165
- return success, filepath
83
+ success = preview_and_apply_changes(changes, workdir, test_cmd)
84
+ return success, filepath
@@ -0,0 +1,113 @@
1
+ from typing import List, Tuple, Optional, NamedTuple
2
+ from difflib import SequenceMatcher
3
+ from janito.config import config
4
+ from rich.console import Console
5
+
6
+ class ContextError(NamedTuple):
7
+ """Contains error details for context matching failures"""
8
+ pre_context: List[str]
9
+ post_context: List[str]
10
+ content: str
11
+
12
+ def parse_change_block(content: str) -> Tuple[List[str], List[str], List[str]]:
13
+ """Parse a change block into pre-context, post-context and change lines.
14
+ Returns (pre_context_lines, post_context_lines, change_lines)"""
15
+ pre_context_lines = []
16
+ post_context_lines = []
17
+ change_lines = []
18
+ in_pre_context = True
19
+
20
+ for line in content.splitlines():
21
+ if line.startswith('='):
22
+ if in_pre_context:
23
+ pre_context_lines.append(line[1:])
24
+ else:
25
+ post_context_lines.append(line[1:])
26
+ elif line.startswith('>'):
27
+ in_pre_context = False
28
+ change_lines.append(line[1:])
29
+
30
+ return pre_context_lines, post_context_lines, change_lines
31
+
32
+ def find_context_match(file_content: str, pre_context: List[str], post_context: List[str], min_context: int = 2) -> Optional[Tuple[int, int]]:
33
+ """Find exact matching location using line-by-line matching.
34
+ Returns (start_index, end_index) or None if no match found."""
35
+ if not (pre_context or post_context) or (len(pre_context) + len(post_context)) < min_context:
36
+ return None
37
+
38
+ file_lines = file_content.splitlines()
39
+
40
+ # Function to check if lines match at a given position
41
+ def lines_match_at(pos: int, target_lines: List[str]) -> bool:
42
+ if pos + len(target_lines) > len(file_lines):
43
+ return False
44
+ return all(a == b for a, b in zip(file_lines[pos:pos + len(target_lines)], target_lines))
45
+
46
+ # For debug output
47
+ debug_matches = []
48
+
49
+ # Try to find pre_context match
50
+ pre_match_pos = None
51
+ if pre_context:
52
+ for i in range(len(file_lines) - len(pre_context) + 1):
53
+ if lines_match_at(i, pre_context):
54
+ pre_match_pos = i
55
+ break
56
+ if config.debug:
57
+ # Record first 20 non-matches for debug output
58
+ if len(debug_matches) < 20:
59
+ debug_matches.append((i, file_lines[i:i + len(pre_context)]))
60
+
61
+ # Try to find post_context match after pre_context if found
62
+ if pre_match_pos is not None and post_context:
63
+ expected_post_pos = pre_match_pos + len(pre_context)
64
+ if not lines_match_at(expected_post_pos, post_context):
65
+ pre_match_pos = None
66
+
67
+ if pre_match_pos is None and config.debug:
68
+ console = Console()
69
+ console.print("\n[bold red]Context Match Debug:[/bold red]")
70
+
71
+ if pre_context:
72
+ console.print("\n[yellow]Expected pre-context:[/yellow]")
73
+ for i, line in enumerate(pre_context):
74
+ console.print(f" {i+1:2d} | '{line}'")
75
+
76
+ if post_context:
77
+ console.print("\n[yellow]Expected post-context:[/yellow]")
78
+ for i, line in enumerate(post_context):
79
+ console.print(f" {i+1:2d} | '{line}'")
80
+
81
+ console.print("\n[yellow]First 20 attempted matches in file:[/yellow]")
82
+ for pos, lines in debug_matches:
83
+ console.print(f"\n[cyan]At line {pos+1}:[/cyan]")
84
+ for i, line in enumerate(lines):
85
+ match_status = "≠" if i < len(pre_context) and line != pre_context[i] else "="
86
+ console.print(f" {i+1:2d} | '{line}' {match_status}")
87
+
88
+ return None
89
+
90
+ if pre_match_pos is None:
91
+ return None
92
+
93
+ end_pos = pre_match_pos + len(pre_context)
94
+
95
+ return pre_match_pos, end_pos
96
+
97
+ def apply_changes(content: str,
98
+ pre_context_lines: List[str],
99
+ post_context_lines: List[str],
100
+ change_lines: List[str]) -> Optional[Tuple[str, Optional[ContextError]]]:
101
+ """Apply changes with context matching, returns (new_content, error_details)"""
102
+ if not content.strip() and not pre_context_lines and not post_context_lines:
103
+ return '\n'.join(change_lines), None
104
+
105
+ pre_context = '\n'.join(pre_context_lines)
106
+ post_context = '\n'.join(post_context_lines)
107
+
108
+ if pre_context and pre_context not in content:
109
+ return None, ContextError(pre_context_lines, post_context_lines, content)
110
+
111
+ if post_context and post_context not in content:
112
+ return None, ContextError(pre_context_lines, post_context_lines, content)
113
+