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/changeviewer.py CHANGED
@@ -1,64 +1,350 @@
1
1
  from pathlib import Path
2
2
  from rich.console import Console
3
3
  from rich.text import Text
4
- from typing import TypedDict
5
- import difflib
4
+ from rich.panel import Panel
5
+ from rich.table import Table
6
+ from rich.rule import Rule # Add this import
7
+ from typing import List, Optional, Dict
8
+ from rich import box
9
+ from janito.fileparser import FileChange
10
+ from janito.analysis import AnalysisOption # Add this import
11
+ from rich.columns import Columns # Add this import at the top with other imports
6
12
 
7
- class FileChange(TypedDict):
8
- """Type definition for a file change"""
9
- description: str
10
- new_content: str
13
+ MIN_PANEL_WIDTH = 40 # Minimum width for each panel
11
14
 
12
- def show_file_changes(console: Console, filepath: Path, original: str, new_content: str, description: str) -> None:
13
- """Display side by side comparison of file changes"""
14
- half_width = (console.width - 3) // 2
15
+
16
+ def format_sequence_preview(lines: List[str]) -> Text:
17
+ """Format a sequence of prefixed lines into rich text with colors"""
18
+ text = Text()
19
+ last_was_empty = False
15
20
 
16
- # Show header
17
- console.print(f"\n[bold blue]Changes for {filepath}[/bold blue]")
18
- console.print(f"[dim]{description}[/dim]\n")
21
+ for line in lines:
22
+ if not line:
23
+ # Preserve empty lines but don't duplicate them
24
+ if not last_was_empty:
25
+ text.append("\n")
26
+ last_was_empty = True
27
+ continue
28
+
29
+ last_was_empty = False
30
+ prefix = line[0] if line[0] in ('=', '>', '<') else ' '
31
+ content = line[1:] if line[0] in ('=', '>', '<') else line
32
+
33
+ if prefix == '=':
34
+ text.append(f" {content}\n", style="dim")
35
+ elif prefix == '>':
36
+ text.append(f"+{content}\n", style="green")
37
+ elif prefix == '<':
38
+ text.append(f"-{content}\n", style="red")
39
+ else:
40
+ text.append(f" {content}\n", style="yellow dim")
19
41
 
20
- # Show side by side content
21
- console.print(Text("OLD".center(half_width) + "│" + "NEW".center(half_width), style="blue bold"))
22
- console.print(Text("─" * half_width + "┼" + "─" * half_width, style="blue"))
42
+ return text
43
+
44
+
45
+
46
+
47
+
48
+
49
+ def show_changes_legend(console: Console) -> None:
50
+ """Display a legend explaining the colors and symbols used in change previews in a horizontal layout"""
51
+ # Create a list of colored text objects
52
+ legend_items = [
53
+ Text("Unchanged", style="#98C379"),
54
+ Text(" • ", style="dim"),
55
+ Text("Removed", style="#E06C75"),
56
+ Text(" • ", style="dim"),
57
+ Text("Relocated", style="#61AFEF"),
58
+ Text(" • ", style="dim"),
59
+ Text("New", style="#C678DD")
60
+ ]
23
61
 
24
- old_lines = original.splitlines()
25
- new_lines = new_content.splitlines()
62
+ # Combine all items into a single text object
63
+ legend_text = Text()
64
+ for item in legend_items:
65
+ legend_text.append_text(item)
26
66
 
27
- for i in range(max(len(old_lines), len(new_lines))):
28
- old = old_lines[i] if i < len(old_lines) else ""
29
- new = new_lines[i] if i < len(new_lines) else ""
30
-
31
- old_text = Text(f"{old:<{half_width}}", style="red" if old != new else None)
32
- new_text = Text(f"{new:<{half_width}}", style="green" if old != new else None)
33
- console.print(old_text + Text("│", style="blue") + new_text)
34
-
35
- def show_diff_changes(console: Console, filepath: Path, original: str, new_content: str, description: str) -> None:
36
- """Display file changes using unified diff format"""
37
- # Show header
38
- console.print(f"\n[bold blue]Changes for {filepath}[/bold blue]")
39
- console.print(f"[dim]{description}[/dim]\n")
40
-
41
- # Generate diff
42
- diff = difflib.unified_diff(
43
- original.splitlines(keepends=True),
44
- new_content.splitlines(keepends=True),
45
- fromfile='old',
46
- tofile='new',
47
- lineterm=''
67
+ # Create a simple panel with the horizontal legend
68
+ legend_panel = Panel(
69
+ legend_text,
70
+ title="Changes Legend",
71
+ title_align="left",
72
+ border_style="white",
73
+ box=box.ROUNDED,
74
+ padding=(0, 1)
48
75
  )
49
76
 
50
- # Print diff with colors
51
- for line in diff:
52
- if line.startswith('+++'):
53
- console.print(Text(line.rstrip(), style="bold green"))
54
- elif line.startswith('---'):
55
- console.print(Text(line.rstrip(), style="bold red"))
56
- elif line.startswith('+'):
57
- console.print(Text(line.rstrip(), style="green"))
58
- elif line.startswith('-'):
59
- console.print(Text(line.rstrip(), style="red"))
60
- elif line.startswith('@@'):
61
- console.print(Text(line.rstrip(), style="cyan"))
77
+ # Center the legend panel horizontally
78
+ console.print(Columns([legend_panel], align="center"))
79
+ console.print() # Add extra line for spacing
80
+
81
+
82
+ def show_change_preview(console: Console, filepath: Path, change: FileChange) -> None:
83
+ """Display a preview of changes for a single file with side-by-side comparison"""
84
+ # Show changes legend first
85
+ show_changes_legend(console)
86
+
87
+ # Create main file panel content
88
+ main_content = []
89
+
90
+ # Handle new file preview
91
+ if change.is_new_file:
92
+ new_file_panel = Panel(
93
+ Text(change.content),
94
+ title="New File Content",
95
+ title_align="left",
96
+ border_style="green",
97
+ box=box.ROUNDED
98
+ )
99
+ main_content.append(new_file_panel)
100
+
101
+ # Create and display main file panel
102
+ file_panel = Panel(
103
+ Columns(main_content),
104
+ title=str(filepath),
105
+ title_align="left",
106
+ border_style="white",
107
+ box=box.ROUNDED )
108
+ return
109
+
110
+
111
+
112
+ # For modifications, create side-by-side comparison for each change
113
+ for i, (search, replace, description) in enumerate(change.search_blocks, 1):
114
+ # Show change header with description
115
+ header = f"Change {i}"
116
+ if description:
117
+ header += f": {description}"
118
+
119
+ if replace is None:
120
+ # For deletions, show single panel with content to be deleted
121
+ change_panel = Panel(
122
+ Text(search, style="red"),
123
+ title=f"Content to Delete{' - ' + description if description else ''}",
124
+ title_align="left",
125
+ border_style="#E06C75", # Brighter red
126
+ box=box.ROUNDED
127
+ )
128
+ main_content.append(change_panel)
62
129
  else:
63
- console.print(Text(line.rstrip()))
130
+ # For replacements, show side-by-side panels
131
+
132
+
133
+ # Find common content between search and replace
134
+ search_lines = search.splitlines()
135
+ replace_lines = replace.splitlines()
136
+
137
+ # Find common lines from top
138
+ common_top = []
139
+ for s, r in zip(search_lines, replace_lines):
140
+ if s == r:
141
+ common_top.append(s)
142
+ else:
143
+ break
144
+
145
+ # Find common lines from bottom
146
+ search_remaining = search_lines[len(common_top):]
147
+ replace_remaining = replace_lines[len(common_top):]
148
+
149
+ common_bottom = []
150
+ for s, r in zip(reversed(search_remaining), reversed(replace_remaining)):
151
+ if s == r:
152
+ common_bottom.insert(0, s)
153
+ else:
154
+ break
155
+
156
+ # Get the unique middle sections
157
+ search_middle = search_remaining[:-len(common_bottom)] if common_bottom else search_remaining
158
+ replace_middle = replace_remaining[:-len(common_bottom)] if common_bottom else replace_remaining
159
+
160
+
161
+
162
+
163
+ # Format content with highlighting using consistent colors and line numbers
164
+
165
+
166
+ def format_content(lines: List[str], is_search: bool) -> Text:
167
+ text = Text()
168
+
169
+ COLORS = {
170
+ 'unchanged': '#98C379', # Brighter green for unchanged lines
171
+ 'removed': '#E06C75', # Clearer red for removed lines
172
+ 'added': '#61AFEF', # Bright blue for added lines
173
+ 'new': '#C678DD', # Purple for completely new lines
174
+ 'relocated': '#61AFEF' # Use same blue for relocated lines
175
+ }
176
+
177
+ # Create sets of lines for comparison
178
+ search_set = set(search_lines)
179
+ replace_set = set(replace_lines)
180
+ common_lines = search_set & replace_set
181
+ new_lines = replace_set - search_set
182
+ relocated_lines = common_lines - set(common_top) - set(common_bottom)
183
+
184
+ def add_line(line: str, style: str, prefix: str = " "):
185
+ # Special handling for icons
186
+ if style == COLORS['relocated']:
187
+ prefix = "⇄"
188
+ elif style == COLORS['removed'] and prefix == "-":
189
+ prefix = "✕"
190
+ elif style == COLORS['new'] or (style == COLORS['added'] and prefix == "+"):
191
+ prefix = "✚"
192
+ text.append(prefix, style=style)
193
+ text.append(f" {line}\n", style=style)
194
+
195
+ # Format common top section
196
+ for line in common_top:
197
+ add_line(line, COLORS['unchanged'], "=")
198
+
199
+ # Format changed middle section
200
+ for line in (search_middle if is_search else replace_middle):
201
+ if line in relocated_lines:
202
+ add_line(line, COLORS['relocated'], "⇄")
203
+ elif not is_search and line in new_lines:
204
+ add_line(line, COLORS['new'], "+")
205
+ else:
206
+ style = COLORS['removed'] if is_search else COLORS['added']
207
+ prefix = "✕" if is_search else "+"
208
+ add_line(line, style, prefix)
209
+
210
+ # Format common bottom section
211
+ for line in common_bottom:
212
+ add_line(line, COLORS['unchanged'], "=")
213
+
214
+ return text
215
+
216
+
217
+
218
+ # Create panels for old and new content without width constraints
219
+ old_panel = Panel(
220
+ format_content(search_lines, True),
221
+ title="Current Content",
222
+ title_align="left",
223
+ border_style="#E06C75",
224
+ box=box.ROUNDED
225
+ )
226
+
227
+ new_panel = Panel(
228
+ format_content(replace_lines, False),
229
+ title="New Content",
230
+ title_align="left",
231
+ border_style="#61AFEF",
232
+ box=box.ROUNDED
233
+ )
234
+
235
+ # Add change panels to main content with auto-fitting columns
236
+ change_columns = Columns([old_panel, new_panel], equal=True, align="center")
237
+ change_panel = Panel(
238
+ change_columns,
239
+ title=header,
240
+ title_align="left",
241
+ border_style="cyan",
242
+ box=box.ROUNDED
243
+ )
244
+ main_content.append(change_panel)
245
+
246
+ # Create and display main file panel
247
+ file_panel = Panel(
248
+ Columns(main_content, align="center"),
249
+ title=f"Modifying {filepath}",
250
+ title_align="left",
251
+ border_style="white",
252
+ box=box.ROUNDED
253
+ )
254
+ console.print(file_panel)
255
+ console.print()
256
+
257
+ # Remove or comment out the unused unified panel code since we're using direct column display
258
+
259
+ def preview_all_changes(console: Console, changes: Dict[Path, FileChange]) -> None:
260
+ """Show preview for all file changes"""
261
+ console.print("\n[bold blue]Change Preview[/bold blue]")
262
+
263
+ for filepath, change in changes.items():
264
+ show_change_preview(console, filepath, change)
265
+
266
+
267
+ def _display_options(options: Dict[str, AnalysisOption]) -> None:
268
+ """Display available options in a centered, responsive layout with consistent spacing."""
269
+ console = Console()
270
+
271
+ # Display centered header with decorative rule
272
+ console.print()
273
+ console.print(Rule(" Available Options ", style="bold cyan", align="center"))
274
+ console.print()
275
+
276
+ # Safety check for empty options
277
+ if not options:
278
+ console.print(Panel("[yellow]No options available[/yellow]", border_style="yellow"))
279
+ return
280
+
281
+ # Calculate optimal layout dimensions based on terminal width
282
+ terminal_width = console.width or 100
283
+ panel_padding = (1, 2) # Consistent padding for all panels
284
+ available_width = terminal_width - 4 # Account for margins
285
+
286
+ # Determine optimal panel width and number of columns
287
+ min_panels_per_row = 1
288
+ max_panels_per_row = 3
289
+ optimal_panel_width = min(
290
+ available_width // max_panels_per_row,
291
+ available_width // min_panels_per_row
292
+ )
293
+
294
+ if optimal_panel_width < MIN_PANEL_WIDTH:
295
+ optimal_panel_width = MIN_PANEL_WIDTH
296
+
297
+ # Create panels with consistent styling and spacing
298
+ panels = []
299
+ for letter, option in options.items():
300
+ # Build content with consistent formatting
301
+ content = Text()
302
+
303
+ # Add description section
304
+ content.append("Description:\n", style="bold cyan")
305
+ for item in option.description_items:
306
+ content.append(f"• {item}\n", style="white")
307
+ content.append("\n")
308
+
309
+ # Add affected files section if present
310
+ if option.affected_files:
311
+ content.append("Affected files:\n", style="bold cyan")
312
+ for file in option.affected_files:
313
+ content.append(f"• {file}\n", style="yellow")
314
+
315
+ # Create panel with consistent styling
316
+ panel = Panel(
317
+ content,
318
+ box=box.ROUNDED,
319
+ border_style="cyan",
320
+ title=f"Option {letter}: {option.summary}",
321
+ title_align="center",
322
+ padding=panel_padding,
323
+ width=optimal_panel_width
324
+ )
325
+ panels.append(panel)
326
+
327
+ # Calculate optimal number of columns based on available width
328
+ num_columns = max(1, min(
329
+ len(panels), # Don't exceed number of panels
330
+ available_width // optimal_panel_width, # Width-based limit
331
+ max_panels_per_row # Maximum columns limit
332
+ ))
333
+
334
+ # Create a centered container panel for all options
335
+ container = Panel(
336
+ Columns(
337
+ panels,
338
+ num_columns=num_columns,
339
+ equal=True,
340
+ align="center",
341
+ padding=(0, 2) # Consistent spacing between columns
342
+ ),
343
+ box=box.SIMPLE,
344
+ padding=(1, 4), # Add padding around the columns for better centering
345
+ width=min(terminal_width - 4, num_columns * optimal_panel_width + (num_columns - 1) * 4)
346
+ )
64
347
 
348
+ # Display the centered container
349
+ console.print(Columns([container], align="center"))
350
+ console.print()
janito/claude.py CHANGED
@@ -1,13 +1,8 @@
1
- from rich.traceback import install
2
1
  import anthropic
3
2
  import os
4
3
  from typing import Optional
5
- from rich.progress import Progress, SpinnerColumn, TextColumn
6
4
  from threading import Event
7
5
 
8
- # Install rich traceback handler
9
- install(show_locals=True)
10
-
11
6
  class ClaudeAPIAgent:
12
7
  """Handles interaction with Claude API, including message handling"""
13
8
  def __init__(self, api_key: Optional[str] = None, system_prompt: str = None):
@@ -18,7 +13,6 @@ class ClaudeAPIAgent:
18
13
  raise ValueError("ANTHROPIC_API_KEY environment variable is required")
19
14
  self.client = anthropic.Client(api_key=self.api_key)
20
15
  self.model = "claude-3-5-sonnet-20241022"
21
- self.stop_progress = Event()
22
16
  self.system_message = system_prompt
23
17
  self.last_prompt = None
24
18
  self.last_full_message = None
@@ -29,46 +23,37 @@ class ClaudeAPIAgent:
29
23
 
30
24
  def send_message(self, message: str, stop_event: Event = None) -> str:
31
25
  """Send message to Claude API and return response"""
26
+ self.messages_history.append(("user", message))
27
+ # Store the full message
28
+ self.last_full_message = message
29
+
32
30
  try:
33
- self.messages_history.append(("user", message))
34
- # Store the full message
35
- self.last_full_message = message
36
-
37
- try:
38
- # Check if already cancelled
39
- if stop_event and stop_event.is_set():
40
- return ""
41
-
42
- # Start API request
43
- response = self.client.messages.create(
44
- model=self.model, # Use discovered model
45
- system=self.system_message,
46
- max_tokens=4000,
47
- messages=[
48
- {"role": "user", "content": message}
49
- ],
50
- temperature=0,
51
- )
52
-
53
- # Handle response
54
- response_text = response.content[0].text
55
-
56
- # Only store and process response if not cancelled
57
- if not (stop_event and stop_event.is_set()):
58
- self.last_response = response_text
59
- self.messages_history.append(("assistant", response_text))
60
-
61
- # Always return the response, let caller handle cancellation
62
- return response_text
63
-
64
- except KeyboardInterrupt:
65
- if stop_event:
66
- stop_event.set()
67
- return ""
68
-
69
- except Exception as e:
70
- error_msg = f"Error: {str(e)}"
71
- self.messages_history.append(("error", error_msg))
31
+ # Check if already cancelled
72
32
  if stop_event and stop_event.is_set():
73
33
  return ""
74
- return error_msg
34
+
35
+ response = self.client.messages.create(
36
+ model=self.model, # Use discovered model
37
+ system=self.system_message,
38
+ max_tokens=4000,
39
+ messages=[
40
+ {"role": "user", "content": message}
41
+ ],
42
+ temperature=0,
43
+ )
44
+
45
+ # Handle response
46
+ response_text = response.content[0].text
47
+
48
+ # Only store and process response if not cancelled
49
+ if not (stop_event and stop_event.is_set()):
50
+ self.last_response = response_text
51
+ self.messages_history.append(("assistant", response_text))
52
+
53
+ # Always return the response, let caller handle cancellation
54
+ return response_text
55
+
56
+ except KeyboardInterrupt:
57
+ if stop_event:
58
+ stop_event.set()
59
+ return ""
janito/common.py ADDED
@@ -0,0 +1,23 @@
1
+ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TimeElapsedColumn
2
+ from janito.claude import ClaudeAPIAgent
3
+
4
+ def progress_send_message(claude: ClaudeAPIAgent, message: str) -> str:
5
+ """
6
+ Send a message to Claude with a progress indicator and elapsed time.
7
+
8
+ Args:
9
+ claude: The Claude API agent instance
10
+ message: The message to send
11
+
12
+ Returns:
13
+ The response from Claude
14
+ """
15
+ with Progress(
16
+ SpinnerColumn(),
17
+ TextColumn("[progress.description]{task.description}"),
18
+ TimeElapsedColumn(),
19
+ ) as progress:
20
+ task = progress.add_task("Waiting for response from Claude...", total=None)
21
+ response = claude.send_message(message)
22
+ progress.update(task, completed=True)
23
+ return response
janito/config.py CHANGED
@@ -7,7 +7,8 @@ class ConfigManager:
7
7
  def __init__(self):
8
8
  self.debug = False
9
9
  self.verbose = False
10
- self.debug_line = None # Add this line
10
+ self.debug_line = None
11
+ self.test_cmd = os.getenv('JANITO_TEST_CMD')
11
12
 
12
13
  @classmethod
13
14
  def get_instance(cls) -> "ConfigManager":
@@ -21,12 +22,16 @@ class ConfigManager:
21
22
  def set_verbose(self, enabled: bool) -> None:
22
23
  self.verbose = enabled
23
24
 
24
- def set_debug_line(self, line: Optional[int]) -> None: # Add this method
25
+ def set_debug_line(self, line: Optional[int]) -> None:
25
26
  self.debug_line = line
26
27
 
27
- def should_debug_line(self, line: int) -> bool: # Add this method
28
+ def should_debug_line(self, line: int) -> bool:
28
29
  """Return True if we should show debug for this line number"""
29
30
  return self.debug and (self.debug_line is None or self.debug_line == line)
30
31
 
32
+ def set_test_cmd(self, cmd: Optional[str]) -> None:
33
+ """Set the test command, overriding environment variable"""
34
+ self.test_cmd = cmd if cmd is not None else os.getenv('JANITO_TEST_CMD')
35
+
31
36
  # Create a singleton instance
32
37
  config = ConfigManager.get_instance()