janito 0.4.0__py3-none-any.whl → 0.5.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 (49) hide show
  1. janito/__init__.py +48 -1
  2. janito/__main__.py +29 -334
  3. janito/agents/__init__.py +22 -0
  4. janito/agents/agent.py +21 -0
  5. janito/{claude.py → agents/claudeai.py} +10 -5
  6. janito/agents/openai.py +53 -0
  7. janito/agents/test.py +34 -0
  8. janito/analysis/__init__.py +33 -0
  9. janito/analysis/display.py +149 -0
  10. janito/analysis/options.py +112 -0
  11. janito/analysis/prompts.py +75 -0
  12. janito/change/__init__.py +19 -0
  13. janito/change/applier.py +269 -0
  14. janito/{contentchange.py → change/content.py} +5 -27
  15. janito/change/indentation.py +33 -0
  16. janito/change/position.py +169 -0
  17. janito/changehistory.py +46 -0
  18. janito/changeviewer/__init__.py +12 -0
  19. janito/changeviewer/diff.py +28 -0
  20. janito/changeviewer/panels.py +268 -0
  21. janito/changeviewer/styling.py +59 -0
  22. janito/changeviewer/themes.py +57 -0
  23. janito/cli/__init__.py +2 -0
  24. janito/cli/commands.py +53 -0
  25. janito/cli/functions.py +286 -0
  26. janito/cli/registry.py +26 -0
  27. janito/common.py +9 -9
  28. janito/console/__init__.py +3 -0
  29. janito/console/commands.py +112 -0
  30. janito/console/core.py +62 -0
  31. janito/console/display.py +157 -0
  32. janito/fileparser.py +292 -83
  33. janito/prompts.py +21 -6
  34. janito/qa.py +7 -5
  35. janito/review.py +13 -0
  36. janito/scan.py +44 -5
  37. janito/tests/test_fileparser.py +26 -0
  38. janito-0.5.0.dist-info/METADATA +146 -0
  39. janito-0.5.0.dist-info/RECORD +45 -0
  40. janito/analysis.py +0 -281
  41. janito/changeapplier.py +0 -436
  42. janito/changeviewer.py +0 -350
  43. janito/console.py +0 -330
  44. janito-0.4.0.dist-info/METADATA +0 -164
  45. janito-0.4.0.dist-info/RECORD +0 -21
  46. /janito/{contextparser.py → _contextparser.py} +0 -0
  47. {janito-0.4.0.dist-info → janito-0.5.0.dist-info}/WHEEL +0 -0
  48. {janito-0.4.0.dist-info → janito-0.5.0.dist-info}/entry_points.txt +0 -0
  49. {janito-0.4.0.dist-info → janito-0.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,33 @@
1
+
2
+ def adjust_indentation(original: str, replacement: str) -> str:
3
+ """Adjust replacement text indentation based on original text"""
4
+ if not original or not replacement:
5
+ return replacement
6
+
7
+ # Get first non-empty lines to compare indentation
8
+ orig_lines = original.splitlines()
9
+ repl_lines = replacement.splitlines()
10
+
11
+ orig_first = next((l for l in orig_lines if l.strip()), '')
12
+ repl_first = next((l for l in repl_lines if l.strip()), '')
13
+
14
+ # Calculate indentation difference
15
+ orig_indent = len(orig_first) - len(orig_first.lstrip())
16
+ repl_indent = len(repl_first) - len(repl_first.lstrip())
17
+ indent_delta = orig_indent - repl_indent
18
+
19
+ if indent_delta == 0:
20
+ return replacement
21
+
22
+ # Adjust indentation for all lines
23
+ adjusted_lines = []
24
+ for line in repl_lines:
25
+ if not line.strip(): # Preserve empty lines
26
+ adjusted_lines.append(line)
27
+ continue
28
+
29
+ current_indent = len(line) - len(line.lstrip())
30
+ new_indent = max(0, current_indent + indent_delta)
31
+ adjusted_lines.append(' ' * new_indent + line.lstrip())
32
+
33
+ return '\n'.join(adjusted_lines)
@@ -0,0 +1,169 @@
1
+
2
+ from typing import List, Tuple
3
+ from janito.config import config
4
+ from rich.console import Console
5
+
6
+ def get_line_boundaries(text: str) -> List[Tuple[int, int, int, int]]:
7
+ """Return list of (content_start, content_end, full_start, full_end) for each line.
8
+ content_start/end exclude leading/trailing whitespace
9
+ full_start/end include the whitespace and line endings"""
10
+ boundaries = []
11
+ start = 0
12
+ for line in text.splitlines(keepends=True):
13
+ content = line.strip()
14
+ if content:
15
+ content_start = start + len(line) - len(line.lstrip())
16
+ content_end = start + len(line.rstrip())
17
+ boundaries.append((content_start, content_end, start, start + len(line)))
18
+ else:
19
+ # Empty or whitespace-only lines
20
+ boundaries.append((start, start, start, start + len(line)))
21
+ start += len(line)
22
+ return boundaries
23
+
24
+ def normalize_content(text: str) -> Tuple[str, List[Tuple[int, int, int, int]]]:
25
+ """Normalize text for searching while preserving position mapping.
26
+ Returns (normalized_text, line_boundaries)"""
27
+ # Replace Windows line endings
28
+ text = text.replace('\r\n', '\n')
29
+ text = text.replace('\r', '\n')
30
+
31
+ # Get line boundaries before normalization
32
+ boundaries = get_line_boundaries(text)
33
+
34
+ # Create normalized version with stripped lines
35
+ normalized = '\n'.join(line.strip() for line in text.splitlines())
36
+
37
+ return normalized, boundaries
38
+
39
+ def find_text_positions(text: str, search: str) -> List[Tuple[int, int]]:
40
+ """Find all non-overlapping positions of search text in content,
41
+ comparing without leading/trailing whitespace but returning original positions."""
42
+ normalized_text, text_boundaries = normalize_content(text)
43
+ normalized_search, search_boundaries = normalize_content(search)
44
+
45
+ positions = []
46
+ start = 0
47
+ while True:
48
+ # Find next occurrence in normalized text
49
+ pos = normalized_text.find(normalized_search, start)
50
+ if pos == -1:
51
+ break
52
+
53
+ # Find the corresponding original text boundaries
54
+ search_lines = normalized_search.count('\n') + 1
55
+
56
+ # Get text line number at position
57
+ line_num = normalized_text.count('\n', 0, pos)
58
+
59
+ if line_num + search_lines <= len(text_boundaries):
60
+ # Get original start position from first line
61
+ orig_start = text_boundaries[line_num][2] # full_start
62
+ # Get original end position from last line
63
+ orig_end = text_boundaries[line_num + search_lines - 1][3] # full_end
64
+
65
+ positions.append((orig_start, orig_end))
66
+
67
+ start = pos + len(normalized_search)
68
+
69
+ return positions
70
+
71
+ def format_whitespace_debug(text: str) -> str:
72
+ """Format text with visible whitespace markers"""
73
+ return text.replace(' ', '·').replace('\t', '→').replace('\n', '↵\n')
74
+
75
+ def format_context_preview(lines: List[str], max_lines: int = 5) -> str:
76
+ """Format context lines for display, limiting the number of lines shown"""
77
+ if not lines:
78
+ return "No context lines"
79
+ preview = lines[:max_lines]
80
+ suffix = f"\n... and {len(lines) - max_lines} more lines" if len(lines) > max_lines else ""
81
+ return "\n".join(preview) + suffix
82
+
83
+ def parse_and_apply_changes_sequence(input_text: str, changes_text: str) -> str:
84
+ """
85
+ Parse and apply changes to text:
86
+ = Find and keep line (preserving whitespace)
87
+ < Remove line at current position
88
+ > Add line at current position
89
+ """
90
+ def find_initial_start(text_lines, sequence):
91
+ for i in range(len(text_lines) - len(sequence) + 1):
92
+ matches = True
93
+ for j, seq_line in enumerate(sequence):
94
+ if text_lines[i + j] != seq_line:
95
+ matches = False
96
+ break
97
+ if matches:
98
+ return i
99
+
100
+ if config.debug and i < 20: # Show first 20 attempted matches
101
+ console = Console()
102
+ console.print(f"\n[cyan]Checking position {i}:[/cyan]")
103
+ for j, seq_line in enumerate(sequence):
104
+ if i + j < len(text_lines):
105
+ match_status = "=" if text_lines[i + j] == seq_line else "≠"
106
+ console.print(f" {match_status} Expected: '{seq_line}'")
107
+ console.print(f" Found: '{text_lines[i + j]}'")
108
+ return -1
109
+
110
+ input_lines = input_text.splitlines()
111
+ changes = changes_text.splitlines()
112
+
113
+ sequence = []
114
+ # Find the context sequence in the input text
115
+ for line in changes:
116
+ if line[0] == '=':
117
+ sequence.append(line[1:])
118
+ else:
119
+ break
120
+
121
+ start_pos = find_initial_start(input_lines, sequence)
122
+
123
+ if start_pos == -1:
124
+ if config.debug:
125
+ console = Console()
126
+ console.print("\n[red]Failed to find context sequence match in file:[/red]")
127
+ console.print("[yellow]File content:[/yellow]")
128
+ for i, line in enumerate(input_lines):
129
+ console.print(f" {i+1:2d} | '{line}'")
130
+ return input_text
131
+
132
+ if config.debug:
133
+ console = Console()
134
+ console.print(f"\n[green]Found context match at line {start_pos + 1}[/green]")
135
+
136
+ result_lines = input_lines[:start_pos]
137
+ i = start_pos
138
+
139
+ for change in changes:
140
+ if not change:
141
+ if config.debug:
142
+ console.print(f" Preserving empty line")
143
+ continue
144
+
145
+ prefix = change[0]
146
+ content = change[1:]
147
+
148
+ if prefix == '=':
149
+ if config.debug:
150
+ console.print(f" Keep: '{content}'")
151
+ result_lines.append(content)
152
+ i += 1
153
+ elif prefix == '<':
154
+ if config.debug:
155
+ console.print(f" Delete: '{content}'")
156
+ i += 1
157
+ elif prefix == '>':
158
+ if config.debug:
159
+ console.print(f" Add: '{content}'")
160
+ result_lines.append(content)
161
+
162
+ result_lines.extend(input_lines[i:])
163
+
164
+ if config.debug:
165
+ console.print("\n[yellow]Final result:[/yellow]")
166
+ for i, line in enumerate(result_lines):
167
+ console.print(f" {i+1:2d} | '{line}'")
168
+
169
+ return '\n'.join(result_lines)
@@ -0,0 +1,46 @@
1
+ from pathlib import Path
2
+ from datetime import datetime
3
+ from typing import Optional
4
+
5
+ # Set fixed timestamp when module is loaded
6
+ APP_TIMESTAMP = datetime.now().strftime("%Y%m%d_%H%M%S")
7
+
8
+ def get_history_path(workdir: Path) -> Path:
9
+ """Create and return the history directory path"""
10
+ history_dir = workdir / '.janito' / 'change_history'
11
+ history_dir.mkdir(parents=True, exist_ok=True)
12
+ return history_dir
13
+
14
+ def determine_history_file_type(filepath: Path) -> str:
15
+ """Determine the type of saved file based on its name"""
16
+ name = filepath.name.lower()
17
+ if 'changes' in name:
18
+ return 'changes'
19
+ elif 'selected' in name:
20
+ return 'selected'
21
+ elif 'analysis' in name:
22
+ return 'analysis'
23
+ elif 'response' in name:
24
+ return 'response'
25
+ return 'unknown'
26
+
27
+ def save_changes_to_history(content: str, request: str, workdir: Path) -> Path:
28
+ """Save change content to history folder with timestamp and request info"""
29
+ history_dir = get_history_path(workdir)
30
+
31
+ # Create history entry with request and changes
32
+ history_file = history_dir / f"changes_{APP_TIMESTAMP}.txt"
33
+
34
+ history_content = f"""Request: {request}
35
+ Timestamp: {APP_TIMESTAMP}
36
+
37
+ Changes:
38
+ {content}
39
+ """
40
+ history_file.write_text(history_content)
41
+ return history_file
42
+
43
+ def get_history_file_path(workdir: Path) -> Path:
44
+ """Get path for a history file with app timestamp"""
45
+ history_path = get_history_path(workdir)
46
+ return history_path / f"{APP_TIMESTAMP}_changes.txt"
@@ -0,0 +1,12 @@
1
+ from .panels import show_change_preview, preview_all_changes
2
+ from .styling import set_theme
3
+ from .themes import ColorTheme, ThemeType, get_theme_by_type
4
+
5
+ __all__ = [
6
+ 'show_change_preview',
7
+ 'preview_all_changes',
8
+ 'set_theme',
9
+ 'ColorTheme',
10
+ 'ThemeType',
11
+ 'get_theme_by_type'
12
+ ]
@@ -0,0 +1,28 @@
1
+ from typing import List, Tuple
2
+
3
+ def find_common_sections(search_lines: List[str], replace_lines: List[str]) -> Tuple[List[str], List[str], List[str], List[str], List[str]]:
4
+ """Find common sections between search and replace content"""
5
+ # Find common lines from top
6
+ common_top = []
7
+ for s, r in zip(search_lines, replace_lines):
8
+ if s == r:
9
+ common_top.append(s)
10
+ else:
11
+ break
12
+
13
+ # Find common lines from bottom
14
+ search_remaining = search_lines[len(common_top):]
15
+ replace_remaining = replace_lines[len(common_top):]
16
+
17
+ common_bottom = []
18
+ for s, r in zip(reversed(search_remaining), reversed(replace_remaining)):
19
+ if s == r:
20
+ common_bottom.insert(0, s)
21
+ else:
22
+ break
23
+
24
+ # Get the unique middle sections
25
+ search_middle = search_remaining[:-len(common_bottom)] if common_bottom else search_remaining
26
+ replace_middle = replace_remaining[:-len(common_bottom)] if common_bottom else replace_remaining
27
+
28
+ return common_top, search_middle, replace_middle, common_bottom, search_lines
@@ -0,0 +1,268 @@
1
+ from rich.console import Console
2
+ from rich.panel import Panel
3
+ from rich.columns import Columns
4
+ from rich.text import Text
5
+ from rich.syntax import Syntax
6
+ from rich.table import Table
7
+ from rich import box
8
+ from pathlib import Path
9
+ from typing import List
10
+ from datetime import datetime
11
+ from janito.fileparser import FileChange
12
+ from janito.config import config
13
+ from .styling import format_content, create_legend_items, current_theme
14
+ from .themes import ColorTheme
15
+ from .diff import find_common_sections
16
+
17
+
18
+ def create_new_file_panel(filepath: Path, content: str) -> Panel:
19
+ """Create a panel for new file creation"""
20
+ size_bytes = len(content.encode('utf-8'))
21
+ size_str = f"{size_bytes} bytes" if size_bytes < 1024 else f"{size_bytes/1024:.1f} KB"
22
+
23
+ # Create metadata table
24
+ metadata = Table.grid(padding=(0, 1))
25
+ metadata.add_row("File Size:", size_str)
26
+ metadata.add_row("Created:", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
27
+
28
+ # Create content preview with empty left panel
29
+ content_table = Table.grid(padding=(0, 2))
30
+ content_table.add_column("Before", justify="left")
31
+ content_table.add_column("After", justify="left")
32
+
33
+ empty_panel = Panel(
34
+ Text("(No previous content)", style="dim"),
35
+ title="Previous Content",
36
+ title_align="left",
37
+ border_style="#E06C75",
38
+ box=box.ROUNDED
39
+ )
40
+
41
+ content_display = content
42
+ if filepath.suffix in ['.py', '.js', '.ts', '.java', '.cpp', '.c']:
43
+ try:
44
+ content_display = Syntax(content, filepath.suffix.lstrip('.'), theme="monokai")
45
+ except:
46
+ pass
47
+
48
+ new_panel = Panel(
49
+ content_display,
50
+ title="New Content",
51
+ title_align="left",
52
+ border_style="#61AFEF",
53
+ box=box.ROUNDED
54
+ )
55
+
56
+ content_table.add_row(empty_panel, new_panel)
57
+
58
+ content = Table.grid(padding=(1, 0))
59
+ content.add_row(Panel(metadata, title="File Metadata", border_style="white"))
60
+ content.add_row(Panel(content_table, title="Content Preview", border_style="white"))
61
+
62
+ return Panel(
63
+ content,
64
+ title=f"[bold]✨ Creating {filepath}[/bold]",
65
+ title_align="left",
66
+ border_style="#8AE234",
67
+ box=box.ROUNDED
68
+ )
69
+
70
+ def create_change_panel(search: str, replace: str | None, description: str, index: int) -> Panel:
71
+ """Create a panel for file changes"""
72
+ operation = 'delete' if replace is None else 'modify'
73
+
74
+ if replace is None:
75
+ return Panel(
76
+ Text(search, style="red"),
77
+ title=f"- Content to Delete{' - ' + description if description else ''}",
78
+ title_align="left",
79
+ border_style="#E06C75",
80
+ box=box.ROUNDED
81
+ )
82
+
83
+ search_lines = search.splitlines()
84
+ replace_lines = replace.splitlines()
85
+ common_top, search_middle, replace_middle, common_bottom, all_search_lines = find_common_sections(search_lines, replace_lines)
86
+
87
+ content_table = Table.grid(padding=(0, 2))
88
+ content_table.add_column("Current", justify="left", ratio=1)
89
+ content_table.add_column("New", justify="left", ratio=1)
90
+
91
+ # Add column headers
92
+ content_table.add_row(
93
+ Text("Current Content", style="bold cyan"),
94
+ Text("New Content", style="bold cyan")
95
+ )
96
+
97
+ # Add the actual content
98
+ content_table.add_row(
99
+ format_content(search_lines, search_lines, replace_lines, True, operation),
100
+ format_content(replace_lines, search_lines, replace_lines, False, operation)
101
+ )
102
+
103
+ header = f"Change {index}"
104
+ if description:
105
+ header += f": {description}"
106
+
107
+ return Panel(
108
+ content_table,
109
+ title=header,
110
+ title_align="left",
111
+ border_style="cyan",
112
+ box=box.ROUNDED
113
+ )
114
+
115
+ def create_replace_panel(filepath: Path, change: FileChange) -> Panel:
116
+ """Create a panel for file replacement with metadata"""
117
+ old_size = len(change.original_content.encode('utf-8'))
118
+ new_size = len(change.content.encode('utf-8'))
119
+
120
+ # Create metadata table
121
+ metadata = Table.grid(padding=(0, 1))
122
+ metadata.add_row("Original Size:", f"{old_size/1024:.1f} KB")
123
+ metadata.add_row("New Size:", f"{new_size/1024:.1f} KB")
124
+ metadata.add_row("Size Change:", f"{(new_size - old_size)/1024:+.1f} KB")
125
+ metadata.add_row("Modified:", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
126
+
127
+ # Create unified diff preview
128
+ content_table = Table.grid(padding=(0, 2))
129
+ content_table.add_column("Current", justify="left")
130
+ content_table.add_column("New", justify="left")
131
+
132
+ # Add column headers
133
+ content_table.add_row(
134
+ Text("Current Content", style="bold cyan"),
135
+ Text("New Content", style="bold cyan")
136
+ )
137
+
138
+ # Add the actual content
139
+ content_table.add_row(
140
+ format_content(change.original_content.splitlines(),
141
+ change.original_content.splitlines(),
142
+ change.content.splitlines(), True),
143
+ format_content(change.content.splitlines(),
144
+ change.original_content.splitlines(),
145
+ change.content.splitlines(), False)
146
+ )
147
+
148
+ content = Table.grid(padding=(1, 0))
149
+ content.add_row(Panel(metadata, title="File Metadata", border_style="white"))
150
+ content.add_row(Panel(content_table, title="Content Preview", border_style="white"))
151
+
152
+ return Panel(
153
+ content,
154
+ title=f"[bold]🔄 Replacing {filepath}[/bold]",
155
+ title_align="left",
156
+ border_style="#FFB86C",
157
+ box=box.ROUNDED
158
+ )
159
+
160
+ def create_remove_file_panel(filepath: Path) -> Panel:
161
+ """Create a panel for file removal"""
162
+ return Panel(
163
+ Text(f"This file will be deleted", style="red"),
164
+ title=f"[bold]- Removing {filepath}[/bold]",
165
+ title_align="left",
166
+ border_style="#F44336",
167
+ box=box.HEAVY,
168
+ padding=(1, 2)
169
+ )
170
+
171
+ def show_change_preview(console: Console, filepath: Path, change: FileChange) -> None:
172
+ """Display a preview of changes for a single file"""
173
+ if change.remove_file:
174
+ panel = create_remove_file_panel(filepath)
175
+ console.print(panel)
176
+ console.print()
177
+ return
178
+
179
+ if change.is_new_file:
180
+ panel = create_new_file_panel(filepath, change.content)
181
+ console.print(panel)
182
+ console.print()
183
+ return
184
+
185
+ if change.replace_file:
186
+ panel = create_replace_panel(filepath, change)
187
+ console.print(panel)
188
+ console.print()
189
+ return
190
+
191
+ main_content = []
192
+ for i, (search, replace, description) in enumerate(change.search_blocks, 1):
193
+ panel = create_change_panel(search, replace, description, i)
194
+ main_content.append(panel)
195
+
196
+ file_panel = Panel(
197
+ Columns(main_content, align="center"),
198
+ title=f"Modifying {filepath} - {change.description}",
199
+ title_align="left",
200
+ border_style="white",
201
+ box=box.ROUNDED
202
+ )
203
+ console.print(file_panel)
204
+ console.print()
205
+
206
+ def preview_all_changes(console: Console, changes: List[FileChange]) -> None:
207
+ """Show preview for all file changes"""
208
+ if config.debug:
209
+ _print_debug_info(console, changes)
210
+
211
+ console.print("\n[bold blue]Change Preview[/bold blue]")
212
+
213
+ has_modified_files = any(not change.is_new_file for change in changes)
214
+ if has_modified_files:
215
+ _show_legend(console)
216
+
217
+ new_files = [change for change in changes if change.is_new_file]
218
+ modified_files = [change for change in changes if not change.is_new_file]
219
+
220
+ for change in new_files:
221
+ show_change_preview(console, change.path, change)
222
+ for change in modified_files:
223
+ show_change_preview(console, change.path, change)
224
+
225
+ def _print_debug_info(console: Console, changes: List[FileChange]) -> None:
226
+ """Print debug information about file changes"""
227
+ console.print("\n[blue]Debug: File Changes to Preview:[/blue]")
228
+ for change in changes:
229
+ console.print(f"\n[cyan]File:[/cyan] {change.path}")
230
+ console.print(f" [yellow]Is New File:[/yellow] {change.is_new_file}")
231
+ console.print(f" [yellow]Description:[/yellow] {change.description}")
232
+ if change.search_blocks:
233
+ console.print(" [yellow]Search Blocks:[/yellow]")
234
+ for i, (search, replace, desc) in enumerate(change.search_blocks, 1):
235
+ console.print(f" Block {i}:")
236
+ console.print(f" Description: {desc or 'No description'}")
237
+ console.print(f" Operation: {'Replace' if replace else 'Delete'}")
238
+ console.print(f" Search Length: {len(search)} chars")
239
+ if replace:
240
+ console.print(f" Replace Length: {len(replace)} chars")
241
+ console.print("\n[blue]End Debug File Changes[/blue]\n")
242
+
243
+ def _show_legend(console: Console) -> None:
244
+ """Show the unified legend status bar"""
245
+ legend = create_legend_items()
246
+
247
+ # Calculate panel width based on legend content
248
+ legend_width = len(str(legend)) + 10 # Add padding for borders
249
+
250
+ legend_panel = Panel(
251
+ legend,
252
+ title="Changes Legend",
253
+ title_align="center",
254
+ border_style="white",
255
+ box=box.ROUNDED,
256
+ padding=(0, 2),
257
+ width=legend_width
258
+ )
259
+
260
+ # Create a full-width container and center the legend panel
261
+ container = Columns(
262
+ [legend_panel],
263
+ align="center",
264
+ expand=True
265
+ )
266
+
267
+ console.print(container)
268
+ console.print()
@@ -0,0 +1,59 @@
1
+ from rich.text import Text
2
+ from rich.console import Console
3
+ from typing import List
4
+ from .themes import DEFAULT_THEME, ColorTheme, ThemeType, get_theme_by_type
5
+
6
+ current_theme = DEFAULT_THEME
7
+
8
+ def set_theme(theme: ColorTheme) -> None:
9
+ """Set the current color theme"""
10
+ global current_theme
11
+ current_theme = theme
12
+
13
+ def format_content(lines: List[str], search_lines: List[str], replace_lines: List[str], is_search: bool, operation: str = 'modify') -> Text:
14
+ """Format content with highlighting using consistent colors and line numbers"""
15
+ text = Text()
16
+
17
+ # Create sets of lines for comparison
18
+ search_set = set(search_lines)
19
+ replace_set = set(replace_lines)
20
+ common_lines = search_set & replace_set
21
+ new_lines = replace_set - search_set
22
+
23
+ def add_line(line: str, prefix: str = " ", line_type: str = 'unchanged'):
24
+ # Ensure line_type is one of the valid types
25
+ valid_types = {'unchanged', 'deleted', 'modified', 'added'}
26
+ if line_type not in valid_types:
27
+ line_type = 'unchanged'
28
+
29
+ bg_color = current_theme.line_backgrounds.get(line_type, current_theme.line_backgrounds['unchanged'])
30
+ style = f"{current_theme.text_color} on {bg_color}"
31
+
32
+ # Add prefix with background
33
+ text.append(prefix, style=style)
34
+ # Add line content with background and pad with spaces
35
+ text.append(" " + line, style=style)
36
+ # Add newline with same background
37
+ text.append(" " * 1000 + "\n", style=style)
38
+
39
+ for line in lines:
40
+ if line in common_lines:
41
+ add_line(line, " ", 'unchanged')
42
+ elif not is_search and line in new_lines:
43
+ add_line(line, "✚", 'added')
44
+ else:
45
+ prefix = "✕" if is_search else "✚"
46
+ line_type = 'deleted' if is_search else 'modified'
47
+ add_line(line, prefix, line_type)
48
+
49
+ return text
50
+
51
+ def create_legend_items() -> Text:
52
+ """Create unified legend status bar"""
53
+ legend = Text()
54
+ legend.append(" Unchanged ", style=f"{current_theme.text_color} on {current_theme.line_backgrounds['unchanged']}")
55
+ legend.append(" │ ", style="dim")
56
+ legend.append(" ✕ Deleted ", style=f"{current_theme.text_color} on {current_theme.line_backgrounds['deleted']}")
57
+ legend.append(" │ ", style="dim")
58
+ legend.append(" ✚ Added ", style=f"{current_theme.text_color} on {current_theme.line_backgrounds['added']}")
59
+ return legend
@@ -0,0 +1,57 @@
1
+ from dataclasses import dataclass
2
+ from typing import Dict
3
+ from enum import Enum
4
+
5
+ class ThemeType(Enum):
6
+ DARK = "dark"
7
+ LIGHT = "light"
8
+
9
+ # Dark theme colors
10
+ DARK_LINE_BACKGROUNDS = {
11
+ 'unchanged': '#1A1A1A',
12
+ 'added': '#003300',
13
+ 'deleted': '#660000',
14
+ 'modified': '#000030'
15
+ }
16
+
17
+ # Light theme colors
18
+ LIGHT_LINE_BACKGROUNDS = {
19
+ 'unchanged': 'grey93', # Light gray for unchanged
20
+ 'added': 'light_green', # Semantic light green for additions
21
+ 'deleted': 'light_red', # Semantic light red for deletions
22
+ 'modified': 'light_blue' # Semantic light blue for modifications
23
+ }
24
+
25
+ @dataclass
26
+ class ColorTheme:
27
+ text_color: str
28
+ line_backgrounds: Dict[str, str]
29
+ theme_type: ThemeType
30
+
31
+ # Predefined themes
32
+ DARK_THEME = ColorTheme(
33
+ text_color="white",
34
+ line_backgrounds=DARK_LINE_BACKGROUNDS,
35
+ theme_type=ThemeType.DARK
36
+ )
37
+
38
+ LIGHT_THEME = ColorTheme(
39
+ text_color="black",
40
+ line_backgrounds=LIGHT_LINE_BACKGROUNDS,
41
+ theme_type=ThemeType.LIGHT
42
+ )
43
+
44
+ DEFAULT_THEME = DARK_THEME
45
+
46
+ def validate_theme(theme: ColorTheme) -> bool:
47
+ """Validate that a theme contains all required colors"""
48
+ required_line_backgrounds = {'unchanged', 'added', 'deleted', 'modified'}
49
+
50
+ if not isinstance(theme, ColorTheme):
51
+ return False
52
+
53
+ return all(bg in theme.line_backgrounds for bg in required_line_backgrounds)
54
+
55
+ def get_theme_by_type(theme_type: ThemeType) -> ColorTheme:
56
+ """Get a predefined theme by type"""
57
+ return LIGHT_THEME if theme_type == ThemeType.LIGHT else DARK_THEME
janito/cli/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+
2
+ # This file is intentionally left empty to make the cli directory a package.