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,20 @@
1
+ """Command history management for Janito shell."""
2
+ from pathlib import Path
3
+ from typing import List, Optional
4
+ from prompt_toolkit.history import FileHistory
5
+
6
+ class CommandHistory:
7
+ """Manages shell command history."""
8
+
9
+ def __init__(self, history_file: Optional[Path] = None):
10
+ if history_file is None:
11
+ history_file = Path.home() / ".janito_history"
12
+ self.history = FileHistory(str(history_file))
13
+
14
+ def add(self, command: str) -> None:
15
+ """Add a command to history."""
16
+ self.history.append_string(command)
17
+
18
+ def get_last(self, n: int = 10) -> List[str]:
19
+ """Get last n commands from history."""
20
+ return list(self.history.get_strings())[-n:]
@@ -0,0 +1,52 @@
1
+ """Command processor for Janito shell."""
2
+ from typing import Optional
3
+ from rich.console import Console
4
+ from .commands import CommandSystem, register_commands
5
+
6
+ class CommandProcessor:
7
+ """Processes shell commands."""
8
+
9
+ def __init__(self):
10
+ self.console = Console()
11
+ self.workspace_content: Optional[str] = None
12
+ register_commands()
13
+
14
+ def process_command(self, command_line: str) -> None:
15
+ """Process a command line input."""
16
+ command_line = command_line.strip()
17
+ if not command_line:
18
+ return
19
+
20
+ parts = command_line.split(maxsplit=1)
21
+ cmd = parts[0].lower()
22
+ args = parts[1] if len(parts) > 1 else ""
23
+
24
+ system = CommandSystem()
25
+ if command := system.get_command(cmd):
26
+ # Special handling for /rinc command to support interactive completion
27
+ if cmd in ["/rinc", "/rinclude"] and args:
28
+ from prompt_toolkit.completion import PathCompleter
29
+ from prompt_toolkit.document import Document
30
+
31
+ completer = PathCompleter(only_directories=True)
32
+ doc = Document(args)
33
+ completions = list(completer.get_completions(doc, None))
34
+
35
+ if completions:
36
+ if len(completions) == 1:
37
+ # Single completion - use it
38
+ args = completions[0].text
39
+ else:
40
+ # Show available completions
41
+ self.console.print("\nAvailable directories:")
42
+ for comp in completions:
43
+ self.console.print(f" {comp.text}")
44
+ return
45
+
46
+ command.handler(args)
47
+ else:
48
+ # Treat as request command
49
+ if request_cmd := system.get_command("/request"):
50
+ request_cmd.handler(command_line)
51
+ else:
52
+ self.console.print("[red]Error: Request command not registered[/red]")
janito/tui/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """Terminal User Interface package for Janito."""
2
+ from .base import BaseTuiApp
3
+ from typing import Dict, Optional
4
+ from janito.change.analysis.options import AnalysisOption
5
+
6
+ class TuiApp(BaseTuiApp):
7
+ """Main TUI application with flow-based navigation"""
8
+
9
+ def on_mount(self) -> None:
10
+ """Initialize appropriate flow based on input"""
11
+ if self.options:
12
+ from .flows.selection import SelectionFlow
13
+ self.push_screen(SelectionFlow(self.options))
14
+ elif self.changes:
15
+ from .flows.changes import ChangesFlow
16
+ self.push_screen(ChangesFlow(self.changes))
17
+ elif self.content:
18
+ from .flows.content import ContentFlow
19
+ self.push_screen(ContentFlow(self.content))
20
+
21
+ __all__ = ['TuiApp']
janito/tui/base.py ADDED
@@ -0,0 +1,22 @@
1
+ from textual.app import App
2
+ from typing import List, Optional, Dict
3
+ from janito.change.parser import FileChange
4
+ from janito.change.analysis.options import AnalysisOption
5
+
6
+ class BaseTuiApp(App):
7
+ """Base class for TUI applications with common functionality"""
8
+ CSS = """
9
+ Screen {
10
+ align: center middle;
11
+ }
12
+ """
13
+
14
+ def __init__(self,
15
+ content: Optional[str] = None,
16
+ options: Optional[Dict[str, AnalysisOption]] = None,
17
+ changes: Optional[List[FileChange]] = None):
18
+ super().__init__()
19
+ self.content = content
20
+ self.options = options
21
+ self.changes = changes
22
+ self.selected_option = None
@@ -0,0 +1,5 @@
1
+ from .selection import SelectionFlow
2
+ from .content import ContentFlow
3
+ from .changes import ChangesFlow
4
+
5
+ __all__ = ['SelectionFlow', 'ContentFlow', 'ChangesFlow']
@@ -0,0 +1,65 @@
1
+ from textual.app import ComposeResult
2
+ from textual.containers import ScrollableContainer
3
+ from textual.screen import Screen
4
+ from textual.binding import Binding
5
+ from textual.widgets import Header, Footer, Static
6
+ from typing import List
7
+ from ...change.viewer.styling import format_content, create_legend_items
8
+ from ...change.viewer.panels import create_change_panel, create_new_file_panel, create_replace_panel, create_remove_file_panel
9
+ from ...change.parser import FileChange
10
+
11
+ class ChangesFlow(Screen):
12
+ """Screen for changes preview flow"""
13
+ CSS = """
14
+ ScrollableContainer {
15
+ width: 100%;
16
+ height: 100%;
17
+ border: solid green;
18
+ background: $surface;
19
+ color: $text;
20
+ padding: 1;
21
+ }
22
+
23
+ Container.panel {
24
+ margin: 1;
25
+ padding: 1;
26
+ border: solid $primary;
27
+ width: 100%;
28
+ }
29
+ """
30
+
31
+ BINDINGS = [
32
+ Binding("q", "quit", "Quit"),
33
+ Binding("escape", "quit", "Quit"),
34
+ Binding("up", "previous", "Previous"),
35
+ Binding("down", "next", "Next"),
36
+ ]
37
+
38
+ def __init__(self, changes: List[FileChange]):
39
+ super().__init__()
40
+ self.changes = changes
41
+ self.current_index = 0
42
+
43
+ def compose(self) -> ComposeResult:
44
+ yield Header()
45
+ with ScrollableContainer():
46
+ for change in self.changes:
47
+ if change.operation == 'create_file':
48
+ yield Static(create_new_file_panel(change.name, change.content))
49
+ elif change.operation == 'replace_file':
50
+ yield Static(create_replace_panel(change.name, change))
51
+ elif change.operation == 'remove_file':
52
+ yield Static(create_remove_file_panel(change.name))
53
+ elif change.operation == 'modify_file':
54
+ for mod in change.text_changes:
55
+ yield Static(create_change_panel(mod.search_content, mod.replace_content, change.description, 1))
56
+ yield Footer()
57
+
58
+ def action_quit(self):
59
+ self.app.exit()
60
+
61
+ def action_previous(self):
62
+ self.scroll_up()
63
+
64
+ def action_next(self):
65
+ self.scroll_down()
@@ -0,0 +1,128 @@
1
+ from textual.app import ComposeResult
2
+ from textual.containers import ScrollableContainer
3
+ from textual.screen import Screen
4
+ from textual.binding import Binding
5
+ from textual.widgets import Header, Footer, Static
6
+ from rich.panel import Panel
7
+ from rich.text import Text
8
+ from rich import box
9
+ from pathlib import Path
10
+ from typing import Dict, List
11
+
12
+ class ContentFlow(Screen):
13
+ """Screen for content viewing flow with unified display format"""
14
+ CSS = """
15
+ ScrollableContainer {
16
+ width: 100%;
17
+ height: 100%;
18
+ border: solid green;
19
+ background: $surface;
20
+ color: $text;
21
+ padding: 1;
22
+ }
23
+
24
+ Container.panel {
25
+ margin: 1;
26
+ padding: 1;
27
+ border: solid $primary;
28
+ width: 100%;
29
+ }
30
+ """
31
+
32
+ BINDINGS = [
33
+ Binding("q", "quit", "Quit"),
34
+ Binding("escape", "quit", "Quit"),
35
+ Binding("up", "previous", "Previous"),
36
+ Binding("down", "next", "Next"),
37
+ ]
38
+
39
+ def __init__(self, content: str):
40
+ super().__init__()
41
+ self.content = content
42
+ self.files_by_type = self._organize_content()
43
+
44
+ def _organize_content(self) -> Dict[str, List[str]]:
45
+ """Organize content into file groups"""
46
+ files = {
47
+ 'Modified': [],
48
+ 'New': [],
49
+ 'Deleted': []
50
+ }
51
+
52
+ # Parse content to extract file information
53
+ for line in self.content.split('\n'):
54
+ if line.strip().startswith('- '):
55
+ file_path = line[2:].strip()
56
+ if '(new)' in file_path:
57
+ files['New'].append(file_path)
58
+ elif '(removed)' in file_path:
59
+ files['Deleted'].append(file_path)
60
+ else:
61
+ files['Modified'].append(file_path)
62
+
63
+ return files
64
+
65
+ def _format_files_group(self, group_name: str, files: List[str], style: str) -> Text:
66
+ """Format a group of files with consistent styling"""
67
+ content = Text()
68
+ if files:
69
+ content.append(Text(f"\n─── {group_name} ───\n", style="cyan"))
70
+
71
+ # Group files by directory
72
+ files_by_dir = {}
73
+ for file_path in files:
74
+ clean_path = file_path.split(' (')[0]
75
+ path = Path(clean_path)
76
+ dir_path = str(path.parent)
77
+ if dir_path not in files_by_dir:
78
+ files_by_dir[dir_path] = []
79
+ files_by_dir[dir_path].append(path)
80
+
81
+ # Display files by directory
82
+ for dir_path, paths in sorted(files_by_dir.items()):
83
+ first_in_dir = True
84
+ for path in sorted(paths):
85
+ if first_in_dir:
86
+ display_path = dir_path
87
+ else:
88
+ pad_left = (len(dir_path) - 3) // 2
89
+ pad_right = len(dir_path) - 3 - pad_left
90
+ display_path = " " * pad_left + "..." + " " * pad_right
91
+
92
+ content.append(Text(f"• {display_path}/{path.name}\n", style=style))
93
+ first_in_dir = False
94
+
95
+ return content
96
+
97
+ def compose(self) -> ComposeResult:
98
+ yield Header()
99
+ with ScrollableContainer():
100
+ # Format content with consistent styling
101
+ content = Text()
102
+
103
+ # Add each file group with appropriate styling
104
+ content.append(self._format_files_group("Modified", self.files_by_type['Modified'], "yellow"))
105
+ content.append(self._format_files_group("New", self.files_by_type['New'], "green"))
106
+ content.append(self._format_files_group("Deleted", self.files_by_type['Deleted'], "red"))
107
+
108
+ # Create panel with formatted content
109
+ panel = Panel(
110
+ content,
111
+ box=box.ROUNDED,
112
+ border_style="cyan",
113
+ title="Content Changes",
114
+ title_align="center",
115
+ padding=(1, 2)
116
+ )
117
+
118
+ yield Static(panel)
119
+ yield Footer()
120
+
121
+ def action_quit(self):
122
+ self.app.exit()
123
+
124
+ def action_previous(self):
125
+ self.scroll_up()
126
+
127
+ def action_next(self):
128
+ self.scroll_down()
@@ -0,0 +1,117 @@
1
+ from textual.app import ComposeResult
2
+ from textual.containers import Container
3
+ from textual.screen import Screen
4
+ from textual.binding import Binding
5
+ from textual.widgets import Header, Footer, Static
6
+ from typing import Dict, Optional
7
+ from rich.panel import Panel
8
+ from rich import box
9
+ from janito.change.analysis.options import AnalysisOption
10
+ from janito.agents import agent
11
+ from janito.common import progress_send_message
12
+ from janito.change.parser import parse_response
13
+ from .changes import ChangesFlow
14
+
15
+ class SelectionFlow(Screen):
16
+ """Selection screen with direct navigation to changes preview"""
17
+
18
+ CSS = """
19
+ #options-container {
20
+ layout: horizontal;
21
+ height: 100%;
22
+ margin: 1;
23
+ align: center middle;
24
+ }
25
+
26
+ .option-panel {
27
+ width: 1fr;
28
+ height: 100%;
29
+ border: solid $primary;
30
+ margin: 0 1;
31
+ padding: 1;
32
+ }
33
+
34
+ .option-panel.selected {
35
+ border: double $secondary;
36
+ background: $boost;
37
+ }
38
+ """
39
+
40
+ BINDINGS = [
41
+ Binding("left", "previous", "Previous"),
42
+ Binding("right", "next", "Next"),
43
+ Binding("enter", "select", "Select"),
44
+ Binding("escape", "quit", "Quit"),
45
+ ]
46
+
47
+ def __init__(self, options: Dict[str, AnalysisOption]):
48
+ super().__init__()
49
+ self.options = options
50
+ self.current_index = 0
51
+ self.panels = []
52
+
53
+ def compose(self) -> ComposeResult:
54
+ yield Header()
55
+ with Container(id="options-container"):
56
+ for letter, option in self.options.items():
57
+ panel = Static(self._format_option(letter, option), classes="option-panel")
58
+ self.panels.append(panel)
59
+ yield panel
60
+ yield Footer()
61
+ # Set initial selection
62
+ if self.panels:
63
+ self.panels[0].add_class("selected")
64
+
65
+ def _format_option(self, letter: str, option: AnalysisOption) -> str:
66
+ """Format option content"""
67
+ content = [f"Option {letter}: {option.summary}\n"]
68
+ content.append("\nDescription:")
69
+ for item in option.description_items:
70
+ content.append(f"• {item}")
71
+ content.append("\nAffected files:")
72
+ for file in option.affected_files:
73
+ content.append(f"• {file}")
74
+ return "\n".join(content)
75
+
76
+ def action_previous(self) -> None:
77
+ """Handle left arrow key"""
78
+ if self.panels:
79
+ self.panels[self.current_index].remove_class("selected")
80
+ self.current_index = (self.current_index - 1) % len(self.panels)
81
+ self.panels[self.current_index].add_class("selected")
82
+
83
+ def action_next(self) -> None:
84
+ """Handle right arrow key"""
85
+ if self.panels:
86
+ self.panels[self.current_index].remove_class("selected")
87
+ self.current_index = (self.current_index + 1) % len(self.panels)
88
+ self.panels[self.current_index].add_class("selected")
89
+
90
+ def action_select(self) -> None:
91
+ """Handle enter key - request changes and show preview"""
92
+ if self.panels:
93
+ letter = list(self.options.keys())[self.current_index]
94
+ option = self.options[letter]
95
+
96
+ # Build and send change request
97
+ from janito.change import build_change_request_prompt
98
+ from janito.workspace import collect_files_content
99
+
100
+ files_content = collect_files_content([option.get_clean_path(f) for f in option.affected_files])
101
+ prompt = build_change_request_prompt(option.format_option_text(), "", files_content)
102
+ response = progress_send_message(prompt)
103
+
104
+ if response:
105
+ changes = parse_response(response)
106
+ if changes:
107
+ # Show changes preview
108
+ self.app.push_screen(ChangesFlow(changes))
109
+ return
110
+
111
+ self.app.selected_option = option
112
+ self.app.exit()
113
+
114
+ def action_quit(self) -> None:
115
+ """Handle escape key"""
116
+ self.app.selected_option = None
117
+ self.app.exit()
@@ -0,0 +1,3 @@
1
+ from .selection import SelectionScreen
2
+
3
+ __all__ = ['SelectionScreen']
@@ -0,0 +1 @@
1
+ # This file is intentionally left empty as functionality is moved to base.py and __init__.py
@@ -0,0 +1,7 @@
1
+ from .manager import WorkspaceManager
2
+ from .scan import preview_scan, collect_files_content, is_dir_empty
3
+
4
+ # Create singleton instance
5
+ workspace = WorkspaceManager.get_instance()
6
+
7
+ __all__ = ['workspace', 'preview_scan', 'collect_files_content', 'is_dir_empty']
@@ -0,0 +1,121 @@
1
+ from collections import defaultdict
2
+ from pathlib import Path
3
+ from typing import Dict, List
4
+
5
+ from rich.columns import Columns
6
+ from rich.console import Console, Group
7
+ from rich.panel import Panel
8
+ from rich.rule import Rule
9
+ from janito.config import config
10
+
11
+ def analyze_workspace_content(content: str) -> None:
12
+ """Show statistics about the scanned content"""
13
+ if not content:
14
+ return
15
+
16
+ # Collect include paths
17
+ paths = []
18
+ if config.include:
19
+ for path in config.include:
20
+ is_recursive = path in config.recursive
21
+ path_str = str(path.relative_to(config.workspace_dir))
22
+ paths.append(f"{path_str}/*" if is_recursive else f"{path_str}/")
23
+ else:
24
+ # Use workspace_dir as fallback when no include paths specified
25
+ paths.append("./")
26
+
27
+ console = Console()
28
+
29
+ dir_counts: Dict[str, int] = defaultdict(int)
30
+ dir_sizes: Dict[str, int] = defaultdict(int)
31
+ file_types: Dict[str, int] = defaultdict(int)
32
+ current_path = None
33
+ current_content = []
34
+
35
+ for line in content.split('\n'):
36
+ if line.startswith('<path>'):
37
+ path = Path(line.replace('<path>', '').replace('</path>', '').strip())
38
+ current_path = str(path.parent)
39
+ dir_counts[current_path] += 1
40
+ file_types[path.suffix.lower() or 'no_ext'] += 1
41
+ elif line.startswith('<content>'):
42
+ current_content = []
43
+ elif line.startswith('</content>'):
44
+ content_size = sum(len(line.encode('utf-8')) for line in current_content)
45
+ if current_path:
46
+ dir_sizes[current_path] += content_size
47
+ current_content = []
48
+ elif current_content is not None:
49
+ current_content.append(line)
50
+
51
+ console = Console()
52
+
53
+ # Directory statistics
54
+ dir_stats = [
55
+ f"📁 {directory}/ [{count} file(s), {_format_size(size)}]"
56
+ for directory, (count, size) in (
57
+ (d, (dir_counts[d], dir_sizes[d]))
58
+ for d in sorted(dir_counts.keys())
59
+ )
60
+ ]
61
+
62
+ # File type statistics
63
+ type_stats = [
64
+ f"📄 .{ext.lstrip('.')} [{count} file(s)]" if ext != 'no_ext' else f"📄 {ext} [{count} file(s)]"
65
+ for ext, count in sorted(file_types.items())
66
+ ]
67
+
68
+ # Create grouped content with styled separators
69
+ content_sections = []
70
+
71
+ if paths:
72
+ # Group paths with their stats
73
+ path_stats = []
74
+ for path in sorted(set(paths)):
75
+ base_path = Path(path.rstrip("/*"))
76
+ total_files = sum(1 for d, count in dir_counts.items()
77
+ if Path(d).is_relative_to(base_path))
78
+ total_size = sum(size for d, size in dir_sizes.items()
79
+ if Path(d).is_relative_to(base_path))
80
+ path_stats.append(f"{path} [{total_files} file(s), {_format_size(total_size)}]")
81
+
82
+ content_sections.extend([
83
+ "[bold yellow]📌 Included Paths[/bold yellow]",
84
+ Rule(style="yellow"),
85
+ Columns(path_stats, equal=True, expand=True),
86
+ "\n"
87
+ ])
88
+
89
+ # Add directory structure section only in verbose mode
90
+ if config.verbose:
91
+ content_sections.extend([
92
+ "[bold magenta]📂 Directory Structure[/bold magenta]",
93
+ Rule(style="magenta"),
94
+ Columns(dir_stats, equal=True, expand=True),
95
+ "\n"
96
+ ])
97
+
98
+ # Always show file types section
99
+ content_sections.extend([
100
+ "[bold cyan]📑 File Types[/bold cyan]",
101
+ Rule(style="cyan"),
102
+ Columns(type_stats, equal=True, expand=True)
103
+ ])
104
+
105
+ content = Group(*content_sections)
106
+
107
+ # Display workspace analysis in panel
108
+ console.print("\n")
109
+ console.print(Panel(
110
+ content,
111
+ title="[bold blue]Workspace Analysis[/bold blue]",
112
+ title_align="center"
113
+ ))
114
+
115
+ def _format_size(size_bytes: int) -> str:
116
+ """Format size in bytes to human readable format"""
117
+ for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
118
+ if size_bytes < 1024.0:
119
+ break
120
+ size_bytes /= 1024.0
121
+ return f"{size_bytes:.1f} {unit}"
@@ -0,0 +1,48 @@
1
+ from pathlib import Path
2
+ from typing import Dict, List, Optional, Set
3
+ from collections import defaultdict
4
+
5
+ class WorkspaceManager:
6
+ """Manages workspace state and operations using singleton pattern."""
7
+ _instance = None
8
+
9
+ def __init__(self):
10
+ if WorkspaceManager._instance is not None:
11
+ raise RuntimeError("Use WorkspaceManager.get_instance() instead")
12
+ self.content: str = ""
13
+ self.scan_completed: bool = False
14
+ self._analyzed: bool = False
15
+
16
+ @classmethod
17
+ def get_instance(cls) -> "WorkspaceManager":
18
+ """Get singleton instance of WorkspaceManager."""
19
+ if cls._instance is None:
20
+ cls._instance = cls()
21
+ return cls._instance
22
+
23
+ def collect_content(self, paths: List[Path]) -> None:
24
+ """Collect and store content from specified paths."""
25
+ from .scan import _scan_paths
26
+ content_parts, _, _, _ = _scan_paths(paths)
27
+ self.content = "\n".join(content_parts)
28
+ self.scan_completed = True
29
+ self._analyzed = False
30
+
31
+ def analyze(self) -> None:
32
+ """Analyze workspace content and update statistics."""
33
+ from .analysis import analyze_workspace_content
34
+ if not self.scan_completed:
35
+ return
36
+ if not self._analyzed and self.content:
37
+ analyze_workspace_content(self.content)
38
+ self._analyzed = True
39
+
40
+ def get_content(self) -> str:
41
+ """Get collected workspace content."""
42
+ return self.content
43
+
44
+ def clear(self) -> None:
45
+ """Clear workspace content and stats."""
46
+ self.content = ""
47
+ self.scan_completed = False
48
+ self._analyzed = False