janito 0.4.0__py3-none-any.whl → 0.6.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.
Files changed (104) hide show
  1. janito/__init__.py +1 -1
  2. janito/__main__.py +102 -326
  3. janito/agents/__init__.py +16 -0
  4. janito/agents/agent.py +21 -0
  5. janito/{claude.py → agents/claudeai.py} +13 -17
  6. janito/agents/openai.py +53 -0
  7. janito/agents/test.py +34 -0
  8. janito/change/__init__.py +32 -0
  9. janito/change/__main__.py +0 -0
  10. janito/change/analysis/__init__.py +23 -0
  11. janito/change/analysis/__main__.py +7 -0
  12. janito/change/analysis/analyze.py +61 -0
  13. janito/change/analysis/formatting.py +78 -0
  14. janito/change/analysis/options.py +81 -0
  15. janito/change/analysis/prompts.py +98 -0
  16. janito/change/analysis/view/__init__.py +9 -0
  17. janito/change/analysis/view/terminal.py +171 -0
  18. janito/change/applier/__init__.py +5 -0
  19. janito/change/applier/file.py +58 -0
  20. janito/change/applier/main.py +156 -0
  21. janito/change/applier/text.py +245 -0
  22. janito/change/applier/workspace_dir.py +58 -0
  23. janito/change/core.py +131 -0
  24. janito/change/history.py +44 -0
  25. janito/change/operations.py +7 -0
  26. janito/change/parser.py +289 -0
  27. janito/change/play.py +54 -0
  28. janito/change/preview.py +82 -0
  29. janito/change/prompts.py +126 -0
  30. janito/change/test.py +0 -0
  31. janito/change/validator.py +251 -0
  32. janito/change/viewer/__init__.py +11 -0
  33. janito/change/viewer/content.py +66 -0
  34. janito/change/viewer/diff.py +43 -0
  35. janito/change/viewer/pager.py +56 -0
  36. janito/change/viewer/panels.py +555 -0
  37. janito/change/viewer/styling.py +103 -0
  38. janito/change/viewer/themes.py +55 -0
  39. janito/clear_statement_parser/clear_statement_format.txt +328 -0
  40. janito/clear_statement_parser/examples.txt +326 -0
  41. janito/clear_statement_parser/models.py +104 -0
  42. janito/clear_statement_parser/parser.py +496 -0
  43. janito/cli/__init__.py +2 -0
  44. janito/cli/base.py +30 -0
  45. janito/cli/commands.py +45 -0
  46. janito/cli/functions.py +111 -0
  47. janito/cli/handlers/ask.py +22 -0
  48. janito/cli/handlers/demo.py +22 -0
  49. janito/cli/handlers/request.py +24 -0
  50. janito/cli/handlers/scan.py +9 -0
  51. janito/cli/history.py +61 -0
  52. janito/cli/registry.py +26 -0
  53. janito/common.py +41 -10
  54. janito/config.py +71 -6
  55. janito/demo/__init__.py +4 -0
  56. janito/demo/data.py +13 -0
  57. janito/demo/mock_data.py +20 -0
  58. janito/demo/operations.py +45 -0
  59. janito/demo/runner.py +59 -0
  60. janito/demo/scenarios.py +32 -0
  61. janito/prompts.py +1 -65
  62. janito/qa.py +8 -5
  63. janito/review.py +13 -0
  64. janito/search_replace/README.md +146 -0
  65. janito/search_replace/__init__.py +6 -0
  66. janito/search_replace/__main__.py +21 -0
  67. janito/search_replace/core.py +119 -0
  68. janito/search_replace/parser.py +52 -0
  69. janito/search_replace/play.py +61 -0
  70. janito/search_replace/replacer.py +36 -0
  71. janito/search_replace/searcher.py +299 -0
  72. janito/shell/__init__.py +39 -0
  73. janito/shell/bus.py +31 -0
  74. janito/shell/commands.py +195 -0
  75. janito/shell/handlers.py +122 -0
  76. janito/shell/history.py +20 -0
  77. janito/shell/processor.py +52 -0
  78. janito/tui/__init__.py +21 -0
  79. janito/tui/base.py +22 -0
  80. janito/tui/flows/__init__.py +5 -0
  81. janito/tui/flows/changes.py +65 -0
  82. janito/tui/flows/content.py +128 -0
  83. janito/tui/flows/selection.py +117 -0
  84. janito/tui/screens/__init__.py +3 -0
  85. janito/tui/screens/app.py +1 -0
  86. janito/workspace/__init__.py +7 -0
  87. janito/workspace/analysis.py +121 -0
  88. janito/workspace/manager.py +48 -0
  89. janito/workspace/scan.py +232 -0
  90. janito-0.6.0.dist-info/METADATA +185 -0
  91. janito-0.6.0.dist-info/RECORD +95 -0
  92. {janito-0.4.0.dist-info → janito-0.6.0.dist-info}/WHEEL +1 -1
  93. janito/analysis.py +0 -281
  94. janito/changeapplier.py +0 -436
  95. janito/changeviewer.py +0 -350
  96. janito/console.py +0 -330
  97. janito/contentchange.py +0 -84
  98. janito/contextparser.py +0 -113
  99. janito/fileparser.py +0 -125
  100. janito/scan.py +0 -137
  101. janito-0.4.0.dist-info/METADATA +0 -164
  102. janito-0.4.0.dist-info/RECORD +0 -21
  103. {janito-0.4.0.dist-info → janito-0.6.0.dist-info}/entry_points.txt +0 -0
  104. {janito-0.4.0.dist-info → janito-0.6.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,32 @@
1
+ """
2
+ This package provides the following change flow steps:
3
+
4
+ - Create a preview directory
5
+ - Build change request prompt
6
+ - Send change request to AI agent
7
+ - Parse the response into changes
8
+ - Save response to history file
9
+ - Preview the changes (applying them to the preview directory)
10
+ - Validate the changes
11
+ - Run tests if specified
12
+ - Show the change view (using janito.changeviewer)
13
+ - Prompt the user to apply the changes to the working directory
14
+ - Apply the changes
15
+ """
16
+
17
+ from typing import Tuple
18
+ from pathlib import Path
19
+ from ..agents import agent # Updated import to use singleton directly
20
+ from .parser import build_change_request_prompt, parse_response
21
+ from .preview import setup_workspace_dir_preview
22
+ from .applier.main import ChangeApplier
23
+
24
+ __all__ = [
25
+ 'build_change_request_prompt',
26
+ 'get_change_response',
27
+ 'parse_response',
28
+ 'setup_workspace_dir_preview',
29
+ 'parse_change_response',
30
+ 'save_change_response',
31
+ 'ChangeApplier'
32
+ ]
File without changes
@@ -0,0 +1,23 @@
1
+ """Analysis module for Janito.
2
+
3
+ This module provides functionality for analyzing and displaying code changes.
4
+ """
5
+
6
+ from .options import AnalysisOption, parse_analysis_options
7
+ from .view import format_analysis, prompt_user, get_option_selection
8
+ from .prompts import (
9
+ build_request_analysis_prompt,
10
+ validate_option_letter
11
+ )
12
+ from .analyze import analyze_request
13
+
14
+ __all__ = [
15
+ 'AnalysisOption',
16
+ 'parse_analysis_options',
17
+ 'format_analysis',
18
+ 'build_request_analysis_prompt',
19
+ 'get_option_selection',
20
+ 'prompt_user',
21
+ 'validate_option_letter',
22
+ 'analyze_request'
23
+ ]
@@ -0,0 +1,7 @@
1
+ """Main entry point for the analysis module."""
2
+
3
+ from .analyze import analyze_request
4
+ from janito.config import config
5
+ from janito.workspace import collect_files_content
6
+ from pathlib import Path
7
+
@@ -0,0 +1,61 @@
1
+ """Core analysis functionality."""
2
+
3
+ from typing import Optional, Dict
4
+
5
+ from janito.agents import agent
6
+ from janito.common import progress_send_message
7
+ from janito.config import config
8
+ from .view import format_analysis
9
+ from .options import AnalysisOption, parse_analysis_options
10
+ from .prompts import (
11
+ build_request_analysis_prompt,
12
+ get_option_selection,
13
+ validate_option_letter
14
+ )
15
+
16
+ def analyze_request(
17
+ request: str,
18
+ files_content_xml: str,
19
+ pre_select: str = ""
20
+ ) -> Optional[AnalysisOption]:
21
+ """
22
+ Analyze changes and get user selection.
23
+
24
+ Args:
25
+ files_content: Content of files to analyze
26
+ request: User's change request
27
+ pre_select: Optional pre-selected option letter
28
+
29
+ Returns:
30
+ Selected AnalysisOption or None if modified
31
+ """
32
+ # Build and send prompt
33
+ prompt = build_request_analysis_prompt(request, files_content_xml)
34
+ response = progress_send_message(prompt)
35
+
36
+ # Parse options
37
+ options = parse_analysis_options(response)
38
+ if not options:
39
+ return None
40
+
41
+ if pre_select:
42
+ return options.get(pre_select.upper())
43
+
44
+ if config.tui:
45
+ from janito.tui import TuiApp
46
+ app = TuiApp(options=options)
47
+ app.run()
48
+ return app.selected_option
49
+
50
+ # Display formatted analysis in terminal mode
51
+ format_analysis(response, config.raw)
52
+
53
+ # Get user selection
54
+ while True:
55
+ selection = get_option_selection()
56
+
57
+ if selection == 'M':
58
+ return None
59
+
60
+ if validate_option_letter(selection, options):
61
+ return options[selection.upper()]
@@ -0,0 +1,78 @@
1
+ """Centralized formatting utilities for analysis display."""
2
+
3
+ from typing import Dict, List, Text
4
+ from rich.text import Text
5
+ from rich.columns import Columns
6
+ from rich.padding import Padding
7
+ from pathlib import Path
8
+
9
+ # Layout constants
10
+ COLUMN_SPACING = 6 # Increased spacing between columns
11
+ MIN_PANEL_WIDTH = 45 # Wider minimum width for better readability
12
+ SECTION_PADDING = (2, 0) # More vertical padding
13
+
14
+ # Color scheme constants
15
+ STATUS_COLORS = {
16
+ 'new': 'bright_green',
17
+ 'modified': 'yellow',
18
+ 'removed': 'red',
19
+ 'default': 'white'
20
+ }
21
+
22
+ STRUCTURAL_COLORS = {
23
+ 'directory': 'dim',
24
+ 'separator': 'blue dim',
25
+ 'repeat': 'bright_magenta bold'
26
+ }
27
+
28
+ def create_header(text: str, style: str = "bold cyan") -> Text:
29
+ """Create formatted header with separator."""
30
+ content = Text()
31
+ content.append(text, style=style)
32
+ content.append("\n")
33
+ content.append("═" * len(text), style="cyan")
34
+ return content
35
+
36
+ def create_section_header(text: str, width: int = 20) -> Text:
37
+ """Create centered section header with separator."""
38
+ content = Text()
39
+ padding = (width - len(text)) // 2
40
+ content.append(" " * padding + text, style="bold cyan")
41
+ content.append("\n")
42
+ content.append("─" * width, style="cyan")
43
+ return content
44
+
45
+ def format_file_path(path: str, status: str, max_dir_length: int = 0, is_repeated: bool = False) -> Text:
46
+ """Format file path with status indicators and consistent alignment.
47
+
48
+ Args:
49
+ path: File path to format
50
+ status: File status (Modified, New, Removed)
51
+ max_dir_length: Maximum directory name length for padding
52
+ is_repeated: Whether this directory was seen before
53
+ """
54
+ content = Text()
55
+ style = STATUS_COLORS.get(status.lower(), STATUS_COLORS['default'])
56
+
57
+ parts = Path(path).parts
58
+ parent_dir = str(Path(path).parent)
59
+
60
+ if parent_dir != '.':
61
+ # Add 4 spaces for consistent base padding
62
+ base_padding = 4
63
+ dir_padding = max_dir_length + base_padding
64
+
65
+ if is_repeated:
66
+ # Add arrow with consistent spacing
67
+ content.append(" " * base_padding)
68
+ content.append("↑ ", style="magenta")
69
+ content.append(" " * (dir_padding - base_padding - 2))
70
+ else:
71
+ # Left-align directory name with consistent padding
72
+ content.append(" " * base_padding)
73
+ content.append(parent_dir, style="dim")
74
+ content.append(" " * (dir_padding - len(parent_dir) - base_padding))
75
+
76
+ # Add filename with consistent spacing
77
+ content.append(parts[-1], style=style)
78
+ return content
@@ -0,0 +1,81 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Dict, List
3
+ from pathlib import Path
4
+
5
+ @dataclass
6
+ class AnalysisOption:
7
+ """Represents an analysis option with letter identifier and details"""
8
+ letter: str
9
+ summary: str
10
+ description_items: List[str] = field(default_factory=list)
11
+ affected_files: List[str] = field(default_factory=list)
12
+
13
+ def format_option_text(self) -> str:
14
+ """Format option details as text for change core"""
15
+ text = f"Option {self.letter} - {self.summary}\n"
16
+ text += "=" * len(f"Option {self.letter} - {self.summary}") + "\n\n"
17
+
18
+ if self.description_items:
19
+ text += "Description:\n"
20
+ for item in self.description_items:
21
+ text += f"- {item}\n"
22
+ text += "\n"
23
+
24
+ if self.affected_files:
25
+ text += "Affected files:\n"
26
+ for file in self.affected_files:
27
+ text += f"- {file}\n"
28
+
29
+ return text
30
+
31
+ def is_new_directory(self, file_path: str) -> bool:
32
+ """Check if file path represents the first occurrence of a directory"""
33
+ parent = str(Path(file_path).parent)
34
+ return parent != '.' and not any(
35
+ parent in self.get_clean_path(file)
36
+ for file in self.affected_files
37
+ if self.get_clean_path(file) != file_path
38
+ )
39
+
40
+ def get_clean_path(self, file_path: str) -> str:
41
+ """Remove status markers from file path"""
42
+ return file_path.split(' (')[0].strip()
43
+
44
+ def parse_analysis_options(content: str) -> Dict[str, AnalysisOption]:
45
+ """Parse analysis options from formatted text file"""
46
+ options = {}
47
+ current_option = None
48
+ current_section = None
49
+
50
+ for line in content.splitlines():
51
+ line = line.strip()
52
+
53
+ # Skip empty lines and section separators
54
+ if not line or line.startswith('---') or line == 'END_OF_OPTIONS':
55
+ continue
56
+
57
+ # New option starts with a letter and period
58
+ if line[0].isalpha() and line[1:3] == '. ':
59
+ letter, summary = line.split('. ', 1)
60
+ current_option = AnalysisOption(letter=letter.upper(), summary=summary)
61
+ options[letter.upper()] = current_option
62
+ current_section = None
63
+ continue
64
+
65
+ # Section headers
66
+ if line.lower() == 'description:':
67
+ current_section = 'description'
68
+ continue
69
+ elif line.lower() == 'affected files:':
70
+ current_section = 'files'
71
+ continue
72
+
73
+ # Add items to current section
74
+ if current_option and line.startswith('- '):
75
+ content = line[2:].strip()
76
+ if current_section == 'description':
77
+ current_option.description_items.append(content)
78
+ elif current_section == 'files':
79
+ current_option.affected_files.append(content)
80
+
81
+ return options
@@ -0,0 +1,98 @@
1
+ """User prompts and input handling for analysis."""
2
+
3
+ from typing import List, Dict
4
+ from rich.console import Console
5
+ from rich.panel import Panel
6
+ from rich.rule import Rule
7
+ from rich.prompt import Prompt
8
+ from rich import box
9
+
10
+
11
+ from .options import AnalysisOption
12
+
13
+ # Keep only prompt-related functionality
14
+ CHANGE_ANALYSIS_PROMPT = """
15
+ Current files:
16
+ <files>
17
+ {files_content}
18
+ </files>
19
+
20
+ Considering the above current files content, provide 3 sections, each identified by a keyword and representing an option.
21
+ Each option should include a concise description and a list of affected files.
22
+ 1st option should be basic style change, 2nd organized style, 3rd exntensible style.
23
+ Do not use style as keyword, instead focus on the changes summary.
24
+
25
+ Use the following format:
26
+
27
+ A. Keyword summary of the change
28
+ -----------------
29
+ Description:
30
+ - Concise description of the change
31
+
32
+ Affected files:
33
+ - path/file1.py (new)
34
+ - path/file2.py (modified)
35
+ - path/file3.py (removed)
36
+
37
+ END_OF_OPTIONS (mandatory marker)
38
+
39
+ RULES:
40
+ - do NOT provide the content of the files
41
+ - do NOT offer to implement the changes
42
+ - description items should be 80 chars or less
43
+
44
+ Request:
45
+ {request}
46
+ """
47
+
48
+ def prompt_user(message: str, choices: List[str] = None) -> str:
49
+ """Display a prominent user prompt with optional choices"""
50
+ console = Console()
51
+ term_width = console.width or 80
52
+ console.print()
53
+ console.print(Rule(" User Input Required ", style="bold cyan", align="center"))
54
+
55
+ if choices:
56
+ choice_text = f"[cyan]Options: {', '.join(choices)}[/cyan]"
57
+ console.print(Panel(choice_text, box=box.ROUNDED, justify="center"))
58
+
59
+ # Center the prompt with padding
60
+ padding = (term_width - len(message)) // 2
61
+ padded_message = " " * padding + message
62
+ return Prompt.ask(f"[bold cyan]{padded_message}[/bold cyan]")
63
+
64
+ def validate_option_letter(letter: str, options: Dict[str, AnalysisOption]) -> bool:
65
+ """Validate if the given letter is a valid option or 'M' for modify"""
66
+ if letter.upper() == 'M':
67
+ return True
68
+ return letter.upper() in options
69
+
70
+ def get_option_selection() -> str:
71
+ """Get user input for option selection with modify option"""
72
+ console = Console()
73
+ term_width = console.width or 80
74
+ message = "Enter option letter or 'M' to modify request"
75
+ padding = (term_width - len(message)) // 2
76
+ padded_message = " " * padding + message
77
+
78
+ console.print(f"\n[cyan]{padded_message}[/cyan]")
79
+ while True:
80
+ letter = prompt_user("Select option").strip().upper()
81
+ if letter == 'M' or (letter.isalpha() and len(letter) == 1):
82
+ return letter
83
+
84
+ error_msg = "Please enter a valid letter or 'M'"
85
+ error_padding = (term_width - len(error_msg)) // 2
86
+ padded_error = " " * error_padding + error_msg
87
+ console.print(f"[red]{padded_error}[/red]")
88
+
89
+ def build_request_analysis_prompt(request: str, files_content_xml: str) -> str:
90
+ """Build prompt for information requests"""
91
+ return CHANGE_ANALYSIS_PROMPT.format(
92
+ files_content=files_content_xml,
93
+ request=request
94
+ )
95
+
96
+ def build_request_analysis_prompt(request: str, files_content_xml: str) -> str:
97
+ """Build analysis prompt with minimal formatting."""
98
+ return f"Current files:\n{files_content_xml}\n\nRequest:\n{request}"
@@ -0,0 +1,9 @@
1
+ """View package for analysis UI components."""
2
+
3
+ from .terminal import format_analysis, prompt_user, get_option_selection
4
+
5
+ __all__ = [
6
+ 'format_analysis',
7
+ 'prompt_user',
8
+ 'get_option_selection'
9
+ ]
@@ -0,0 +1,171 @@
1
+ """Terminal UI components for analysis display."""
2
+
3
+ from typing import Dict, List, Optional
4
+ from rich.console import Console
5
+ from rich.columns import Columns
6
+ from rich.text import Text
7
+ from rich.panel import Panel
8
+ from rich.rule import Rule
9
+ from rich.padding import Padding
10
+ from rich.prompt import Prompt
11
+ from rich import box
12
+ from pathlib import Path
13
+
14
+ from ..options import AnalysisOption
15
+ from ..formatting import (
16
+ COLUMN_SPACING,
17
+ MIN_PANEL_WIDTH,
18
+ SECTION_PADDING,
19
+ STATUS_COLORS,
20
+ STRUCTURAL_COLORS,
21
+ create_header,
22
+ create_section_header,
23
+ format_file_path
24
+ )
25
+
26
+ def prompt_user(message: str, choices: List[str] = None) -> str:
27
+ """Display a prominent user prompt with optional choices"""
28
+ console = Console()
29
+ term_width = console.width or 80
30
+ console.print()
31
+ console.print(Rule(" User Input Required ", style="bold cyan", align="center"))
32
+
33
+ if choices:
34
+ choice_text = f"[cyan]Options: {', '.join(choices)}[/cyan]"
35
+ console.print(Panel(choice_text, box=box.ROUNDED, justify="center"))
36
+
37
+ padding = (term_width - len(message)) // 2
38
+ padded_message = " " * padding + message
39
+ return Prompt.ask(f"[bold cyan]{padded_message}[/bold cyan]")
40
+
41
+ def get_option_selection() -> str:
42
+ """Get user input for option selection with modify option"""
43
+ console = Console()
44
+ term_width = console.width or 80
45
+ message = "Enter option letter or 'M' to modify request"
46
+ padding = (term_width - len(message)) // 2
47
+ padded_message = " " * padding + message
48
+
49
+ console.print(f"\n[cyan]{padded_message}[/cyan]")
50
+ while True:
51
+ letter = prompt_user("Select option").strip().upper()
52
+ if letter == 'M' or (letter.isalpha() and len(letter) == 1):
53
+ return letter
54
+
55
+ error_msg = "Please enter a valid letter or 'M'"
56
+ error_padding = (term_width - len(error_msg)) // 2
57
+ padded_error = " " * error_padding + error_msg
58
+ console.print(f"[red]{padded_error}[/red]")
59
+
60
+ def _create_option_content(option: AnalysisOption) -> Text:
61
+ """Create rich formatted content for a single option."""
62
+ content = Text()
63
+ content.append("\n")
64
+
65
+ header = create_header(f"Option {option.letter} - {option.summary}")
66
+ content.append(header)
67
+ content.append("\n")
68
+
69
+ if option.description_items:
70
+ for item in option.description_items:
71
+ content.append("• ", style="cyan")
72
+ content.append(f"{item}\n")
73
+ content.append("\n")
74
+
75
+ # Add consistent padding before file list
76
+ content.append("\n" * 2)
77
+
78
+ if option.affected_files:
79
+ files = {status: [] for status in ['New', 'Modified', 'Removed']}
80
+ for file in option.affected_files:
81
+ if '(new)' in file.lower():
82
+ files['New'].append(file)
83
+ elif '(removed)' in file.lower():
84
+ files['Removed'].append(file)
85
+ else:
86
+ files['Modified'].append(file)
87
+
88
+ for status, status_files in files.items():
89
+ if status_files:
90
+ content.append(create_section_header(f"{status} Files"))
91
+ content.append("\n")
92
+ sorted_files = sorted(status_files)
93
+ prev_path = None
94
+ seen_dirs = {}
95
+ for file in sorted_files:
96
+ path = option.get_clean_path(file)
97
+ current_parts = Path(path).parts
98
+ parent_dir = str(Path(path).parent)
99
+
100
+ if parent_dir != '.':
101
+ is_repeated = parent_dir in seen_dirs
102
+ if not is_repeated:
103
+ content.append(parent_dir, style=STRUCTURAL_COLORS['directory'])
104
+ content.append("/", style=STRUCTURAL_COLORS['separator'])
105
+ seen_dirs[parent_dir] = True
106
+ else:
107
+ padding = " " * (len(parent_dir) - 1)
108
+ content.append(padding)
109
+ content.append("↑ ", style=STRUCTURAL_COLORS['repeat'])
110
+ content.append("/", style=STRUCTURAL_COLORS['separator'])
111
+ content.append(current_parts[-1], style=STATUS_COLORS[status.lower()])
112
+ else:
113
+ content.append(current_parts[-1], style=STATUS_COLORS[status.lower()])
114
+ content.append("\n")
115
+ content.append("\n")
116
+
117
+ content.append("\n")
118
+
119
+ return content
120
+
121
+ def create_columns_layout(options_content: List[Text], term_width: int) -> Columns:
122
+ """Create a columns layout with consistent spacing."""
123
+ num_columns = len(options_content)
124
+ spacing = COLUMN_SPACING * (num_columns - 1)
125
+ safety_margin = 4 + 2
126
+
127
+ usable_width = term_width - spacing - safety_margin
128
+ column_width = max((usable_width // num_columns), MIN_PANEL_WIDTH)
129
+
130
+ rendered_columns = [
131
+ Padding(content, (0, COLUMN_SPACING // 2))
132
+ for content in options_content
133
+ ]
134
+
135
+ return Columns(
136
+ rendered_columns,
137
+ equal=True,
138
+ expand=True,
139
+ width=column_width,
140
+ align="left",
141
+ padding=(0, 0),
142
+ )
143
+
144
+ def format_analysis(analysis: str, raw: bool = False) -> None:
145
+ """Format and display the analysis output."""
146
+ from ..options import parse_analysis_options
147
+
148
+ console = Console()
149
+ term_width = console.width or 100
150
+
151
+ if raw:
152
+ console.print(analysis)
153
+ return
154
+
155
+ options = parse_analysis_options(analysis)
156
+ if not options:
157
+ console.print("\n[yellow]Warning: No valid options found in response.[/yellow]\n")
158
+ console.print(analysis)
159
+ return
160
+
161
+ columns_content = [_create_option_content(options[letter])
162
+ for letter in sorted(options.keys())]
163
+
164
+ columns = create_columns_layout(columns_content, term_width)
165
+
166
+ console.print("\n")
167
+ console.print(Text("Analysis Options", style="bold cyan"))
168
+ console.print(Text("─" * term_width, style="cyan dim"))
169
+ console.print(columns)
170
+ console.print(Text("─" * term_width, style="cyan dim"))
171
+ console.print("\n")
@@ -0,0 +1,5 @@
1
+ from .file import FileChangeApplier
2
+ from .text import TextChangeApplier
3
+ from .workspace_dir import apply_changes
4
+
5
+ __all__ = ['FileChangeApplier', 'TextChangeApplier', 'apply_changes']
@@ -0,0 +1,58 @@
1
+ from pathlib import Path
2
+ from typing import Tuple, Optional
3
+ from rich.console import Console
4
+ from ..parser import FileChange, ChangeOperation
5
+
6
+ class FileChangeApplier:
7
+ def __init__(self, preview_dir: Path, console: Console = None):
8
+ self.preview_dir = preview_dir
9
+ self.console = console or Console()
10
+
11
+ def apply_file_operation(self, change: FileChange) -> Tuple[bool, Optional[str]]:
12
+ """Apply a file operation (create/replace/remove/rename/move)
13
+ Returns: (success, error_message)"""
14
+ path = self.preview_dir / change.name
15
+ path.parent.mkdir(parents=True, exist_ok=True)
16
+
17
+ # Store original content before any changes
18
+ if path.exists():
19
+ change.original_content = path.read_text()
20
+
21
+ if change.operation == ChangeOperation.REMOVE_FILE:
22
+ return self._handle_remove(path)
23
+ elif change.operation in (ChangeOperation.CREATE_FILE, ChangeOperation.REPLACE_FILE):
24
+ return self._handle_create_replace(path, change)
25
+ elif change.operation in (ChangeOperation.RENAME_FILE, ChangeOperation.MOVE_FILE):
26
+ return self._handle_move(path, change)
27
+
28
+ return False, f"Unsupported operation: {change.operation}"
29
+
30
+ def _handle_remove(self, path: Path) -> Tuple[bool, Optional[str]]:
31
+ """Handle file removal"""
32
+ if path.exists():
33
+ path.unlink()
34
+ return True, None
35
+
36
+ def _handle_create_replace(self, path: Path, change: FileChange) -> Tuple[bool, Optional[str]]:
37
+ """Handle file creation or replacement"""
38
+ if change.operation == ChangeOperation.CREATE_FILE and path.exists():
39
+ return False, f"Cannot create file {path} - already exists"
40
+
41
+ if change.content is not None:
42
+ path.write_text(change.content)
43
+ return True, None
44
+
45
+ return False, "No content provided for create/replace operation"
46
+
47
+ def _handle_move(self, path: Path, change: FileChange) -> Tuple[bool, Optional[str]]:
48
+ """Handle file move/rename operations"""
49
+ if not path.exists():
50
+ return False, f"Cannot move/rename non-existent file {path}"
51
+
52
+ if not change.target:
53
+ return False, "No target path provided for move/rename operation"
54
+
55
+ new_path = self.preview_dir / change.target
56
+ new_path.parent.mkdir(parents=True, exist_ok=True)
57
+ path.rename(new_path)
58
+ return True, None