tunacode-cli 0.0.1__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.

Potentially problematic release.


This version of tunacode-cli might be problematic. Click here for more details.

Files changed (65) hide show
  1. tunacode/__init__.py +0 -0
  2. tunacode/cli/__init__.py +4 -0
  3. tunacode/cli/commands.py +632 -0
  4. tunacode/cli/main.py +47 -0
  5. tunacode/cli/repl.py +251 -0
  6. tunacode/configuration/__init__.py +1 -0
  7. tunacode/configuration/defaults.py +26 -0
  8. tunacode/configuration/models.py +69 -0
  9. tunacode/configuration/settings.py +32 -0
  10. tunacode/constants.py +129 -0
  11. tunacode/context.py +83 -0
  12. tunacode/core/__init__.py +0 -0
  13. tunacode/core/agents/__init__.py +0 -0
  14. tunacode/core/agents/main.py +119 -0
  15. tunacode/core/setup/__init__.py +17 -0
  16. tunacode/core/setup/agent_setup.py +41 -0
  17. tunacode/core/setup/base.py +37 -0
  18. tunacode/core/setup/config_setup.py +179 -0
  19. tunacode/core/setup/coordinator.py +45 -0
  20. tunacode/core/setup/environment_setup.py +62 -0
  21. tunacode/core/setup/git_safety_setup.py +188 -0
  22. tunacode/core/setup/undo_setup.py +32 -0
  23. tunacode/core/state.py +43 -0
  24. tunacode/core/tool_handler.py +57 -0
  25. tunacode/exceptions.py +105 -0
  26. tunacode/prompts/system.txt +71 -0
  27. tunacode/py.typed +0 -0
  28. tunacode/services/__init__.py +1 -0
  29. tunacode/services/mcp.py +86 -0
  30. tunacode/services/undo_service.py +244 -0
  31. tunacode/setup.py +50 -0
  32. tunacode/tools/__init__.py +0 -0
  33. tunacode/tools/base.py +244 -0
  34. tunacode/tools/read_file.py +89 -0
  35. tunacode/tools/run_command.py +107 -0
  36. tunacode/tools/update_file.py +117 -0
  37. tunacode/tools/write_file.py +82 -0
  38. tunacode/types.py +259 -0
  39. tunacode/ui/__init__.py +1 -0
  40. tunacode/ui/completers.py +129 -0
  41. tunacode/ui/console.py +74 -0
  42. tunacode/ui/constants.py +16 -0
  43. tunacode/ui/decorators.py +59 -0
  44. tunacode/ui/input.py +95 -0
  45. tunacode/ui/keybindings.py +27 -0
  46. tunacode/ui/lexers.py +46 -0
  47. tunacode/ui/output.py +109 -0
  48. tunacode/ui/panels.py +156 -0
  49. tunacode/ui/prompt_manager.py +117 -0
  50. tunacode/ui/tool_ui.py +187 -0
  51. tunacode/ui/validators.py +23 -0
  52. tunacode/utils/__init__.py +0 -0
  53. tunacode/utils/bm25.py +55 -0
  54. tunacode/utils/diff_utils.py +69 -0
  55. tunacode/utils/file_utils.py +41 -0
  56. tunacode/utils/ripgrep.py +17 -0
  57. tunacode/utils/system.py +336 -0
  58. tunacode/utils/text_utils.py +87 -0
  59. tunacode/utils/user_configuration.py +54 -0
  60. tunacode_cli-0.0.1.dist-info/METADATA +242 -0
  61. tunacode_cli-0.0.1.dist-info/RECORD +65 -0
  62. tunacode_cli-0.0.1.dist-info/WHEEL +5 -0
  63. tunacode_cli-0.0.1.dist-info/entry_points.txt +2 -0
  64. tunacode_cli-0.0.1.dist-info/licenses/LICENSE +21 -0
  65. tunacode_cli-0.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,117 @@
1
+ """Prompt configuration and management for Sidekick UI."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional
5
+
6
+ from prompt_toolkit.completion import Completer
7
+ from prompt_toolkit.formatted_text import FormattedText
8
+ from prompt_toolkit.key_binding import KeyBindings
9
+ from prompt_toolkit.lexers import Lexer
10
+ from prompt_toolkit.shortcuts import PromptSession
11
+ from prompt_toolkit.styles import Style
12
+ from prompt_toolkit.validation import Validator
13
+
14
+ from tunacode.constants import UI_COLORS
15
+ from tunacode.core.state import StateManager
16
+ from tunacode.exceptions import UserAbortError
17
+
18
+
19
+ @dataclass
20
+ class PromptConfig:
21
+ """Configuration for prompt sessions."""
22
+
23
+ multiline: bool = False
24
+ is_password: bool = False
25
+ validator: Optional[Validator] = None
26
+ key_bindings: Optional[KeyBindings] = None
27
+ placeholder: Optional[FormattedText] = None
28
+ completer: Optional[Completer] = None
29
+ lexer: Optional[Lexer] = None
30
+ timeoutlen: float = 0.05
31
+
32
+
33
+ class PromptManager:
34
+ """Manages prompt sessions and their lifecycle."""
35
+
36
+ def __init__(self, state_manager: Optional[StateManager] = None):
37
+ """Initialize the prompt manager.
38
+
39
+ Args:
40
+ state_manager: Optional state manager for session persistence
41
+ """
42
+ self.state_manager = state_manager
43
+ self._temp_sessions = {} # For when no state manager is available
44
+ self._style = self._create_style()
45
+
46
+ def _create_style(self) -> Style:
47
+ """Create the style for the prompt with file reference highlighting."""
48
+ return Style.from_dict({
49
+ 'file-reference': UI_COLORS.get('file_ref', 'light_blue'),
50
+ })
51
+
52
+ def get_session(self, session_key: str, config: PromptConfig) -> PromptSession:
53
+ """Get or create a prompt session.
54
+
55
+ Args:
56
+ session_key: Unique key for the session
57
+ config: Configuration for the session
58
+
59
+ Returns:
60
+ PromptSession instance
61
+ """
62
+ if self.state_manager:
63
+ # Use state manager's session storage
64
+ if session_key not in self.state_manager.session.input_sessions:
65
+ self.state_manager.session.input_sessions[session_key] = PromptSession(
66
+ key_bindings=config.key_bindings,
67
+ placeholder=config.placeholder,
68
+ completer=config.completer,
69
+ lexer=config.lexer,
70
+ style=self._style,
71
+ )
72
+ return self.state_manager.session.input_sessions[session_key]
73
+ else:
74
+ # Use temporary storage
75
+ if session_key not in self._temp_sessions:
76
+ self._temp_sessions[session_key] = PromptSession(
77
+ key_bindings=config.key_bindings,
78
+ placeholder=config.placeholder,
79
+ completer=config.completer,
80
+ lexer=config.lexer,
81
+ style=self._style,
82
+ )
83
+ return self._temp_sessions[session_key]
84
+
85
+ async def get_input(self, session_key: str, prompt: str, config: PromptConfig) -> str:
86
+ """Get user input using the specified configuration.
87
+
88
+ Args:
89
+ session_key: Unique key for the session
90
+ prompt: The prompt text to display
91
+ config: Configuration for the input
92
+
93
+ Returns:
94
+ User input string
95
+
96
+ Raises:
97
+ UserAbortError: If user cancels input
98
+ """
99
+ session = self.get_session(session_key, config)
100
+
101
+ try:
102
+ # Get user input
103
+ response = await session.prompt_async(
104
+ prompt,
105
+ is_password=config.is_password,
106
+ validator=config.validator,
107
+ multiline=config.multiline,
108
+ )
109
+
110
+ # Clean up response
111
+ if isinstance(response, str):
112
+ response = response.strip()
113
+
114
+ return response
115
+
116
+ except (KeyboardInterrupt, EOFError):
117
+ raise UserAbortError
tunacode/ui/tool_ui.py ADDED
@@ -0,0 +1,187 @@
1
+ """
2
+ Tool confirmation UI components, separated from business logic.
3
+ """
4
+
5
+ from rich.markdown import Markdown
6
+ from rich.padding import Padding
7
+ from rich.panel import Panel
8
+
9
+ from tunacode.configuration.settings import ApplicationSettings
10
+ from tunacode.constants import APP_NAME, TOOL_UPDATE_FILE, TOOL_WRITE_FILE, UI_COLORS
11
+ from tunacode.core.tool_handler import ToolConfirmationRequest, ToolConfirmationResponse
12
+ from tunacode.types import ToolArgs
13
+ from tunacode.ui import console as ui
14
+ from tunacode.utils.diff_utils import render_file_diff
15
+ from tunacode.utils.file_utils import DotDict
16
+ from tunacode.utils.text_utils import ext_to_lang, key_to_title
17
+
18
+
19
+ class ToolUI:
20
+ """Handles tool confirmation UI presentation."""
21
+
22
+ def __init__(self):
23
+ self.colors = DotDict(UI_COLORS)
24
+
25
+ def _get_tool_title(self, tool_name: str) -> str:
26
+ """
27
+ Get the display title for a tool.
28
+
29
+ Args:
30
+ tool_name: Name of the tool.
31
+
32
+ Returns:
33
+ str: Display title.
34
+ """
35
+ app_settings = ApplicationSettings()
36
+ if tool_name in app_settings.internal_tools:
37
+ return f"Tool({tool_name})"
38
+ else:
39
+ return f"MCP({tool_name})"
40
+
41
+ def _create_code_block(self, filepath: str, content: str) -> Markdown:
42
+ """
43
+ Create a code block for the given file path and content.
44
+
45
+ Args:
46
+ filepath: The path to the file.
47
+ content: The content of the file.
48
+
49
+ Returns:
50
+ Markdown: A Markdown object representing the code block.
51
+ """
52
+ lang = ext_to_lang(filepath)
53
+ code_block = f"```{lang}\n{content}\n```"
54
+ return ui.markdown(code_block)
55
+
56
+ def _render_args(self, tool_name: str, args: ToolArgs) -> str:
57
+ """
58
+ Render the tool arguments for display.
59
+
60
+ Args:
61
+ tool_name: Name of the tool.
62
+ args: Tool arguments.
63
+
64
+ Returns:
65
+ str: Formatted arguments for display.
66
+ """
67
+ # Show diff between `target` and `patch` on file updates
68
+ if tool_name == TOOL_UPDATE_FILE:
69
+ return render_file_diff(args["target"], args["patch"], self.colors)
70
+
71
+ # Show file content on write_file
72
+ elif tool_name == TOOL_WRITE_FILE:
73
+ return self._create_code_block(args["filepath"], args["content"])
74
+
75
+ # Default to showing key and value on new line
76
+ content = ""
77
+ for key, value in args.items():
78
+ if isinstance(value, list):
79
+ content += f"{key_to_title(key)}:\n"
80
+ for item in value:
81
+ content += f" - {item}\n"
82
+ content += "\n"
83
+ else:
84
+ # If string length is over 200 characters, split to new line
85
+ value = str(value)
86
+ content += f"{key_to_title(key)}:"
87
+ if len(value) > 200:
88
+ content += f"\n{value}\n\n"
89
+ else:
90
+ content += f" {value}\n\n"
91
+ return content.strip()
92
+
93
+ async def show_confirmation(
94
+ self, request: ToolConfirmationRequest, state_manager=None
95
+ ) -> ToolConfirmationResponse:
96
+ """
97
+ Show tool confirmation UI and get user response.
98
+
99
+ Args:
100
+ request: The confirmation request.
101
+
102
+ Returns:
103
+ ToolConfirmationResponse: User's response to the confirmation.
104
+ """
105
+ title = self._get_tool_title(request.tool_name)
106
+ content = self._render_args(request.tool_name, request.args)
107
+
108
+ await ui.tool_confirm(title, content, filepath=request.filepath)
109
+
110
+ # If tool call has filepath, show it under panel
111
+ if request.filepath:
112
+ await ui.usage(f"File: {request.filepath}")
113
+
114
+ await ui.print(" 1. Yes (default)")
115
+ await ui.print(" 2. Yes, and don't ask again for commands like this")
116
+ await ui.print(f" 3. No, and tell {APP_NAME} what to do differently")
117
+ resp = (
118
+ await ui.input(
119
+ session_key="tool_confirm",
120
+ pretext=" Choose an option [1/2/3]: ",
121
+ state_manager=state_manager,
122
+ )
123
+ or "1"
124
+ )
125
+
126
+ if resp == "2":
127
+ return ToolConfirmationResponse(approved=True, skip_future=True)
128
+ elif resp == "3":
129
+ return ToolConfirmationResponse(approved=False, abort=True)
130
+ else:
131
+ return ToolConfirmationResponse(approved=True)
132
+
133
+ def show_sync_confirmation(self, request: ToolConfirmationRequest) -> ToolConfirmationResponse:
134
+ """
135
+ Show tool confirmation UI synchronously and get user response.
136
+
137
+ Args:
138
+ request: The confirmation request.
139
+
140
+ Returns:
141
+ ToolConfirmationResponse: User's response to the confirmation.
142
+ """
143
+ title = self._get_tool_title(request.tool_name)
144
+ content = self._render_args(request.tool_name, request.args)
145
+
146
+ # Display styled confirmation panel using direct console output
147
+ # Avoid using sync wrappers that might create event loop conflicts
148
+ panel_obj = Panel(
149
+ Padding(content, 1), title=title, title_align="left", border_style=self.colors.warning
150
+ )
151
+ # Add consistent spacing above panels
152
+ ui.console.print(Padding(panel_obj, (1, 0, 0, 0)))
153
+
154
+ if request.filepath:
155
+ ui.console.print(f"File: {request.filepath}", style=self.colors.muted)
156
+
157
+ ui.console.print(" 1. Yes (default)")
158
+ ui.console.print(" 2. Yes, and don't ask again for commands like this")
159
+ ui.console.print(f" 3. No, and tell {APP_NAME} what to do differently")
160
+ resp = input(" Choose an option [1/2/3]: ").strip() or "1"
161
+
162
+ # Add spacing after user choice for better readability
163
+ print()
164
+
165
+ if resp == "2":
166
+ return ToolConfirmationResponse(approved=True, skip_future=True)
167
+ elif resp == "3":
168
+ return ToolConfirmationResponse(approved=False, abort=True)
169
+ else:
170
+ return ToolConfirmationResponse(approved=True)
171
+
172
+ async def log_mcp(self, title: str, args: ToolArgs) -> None:
173
+ """
174
+ Display MCP tool with its arguments.
175
+
176
+ Args:
177
+ title: Title to display.
178
+ args: Arguments to display.
179
+ """
180
+ if not args:
181
+ return
182
+
183
+ await ui.info(title)
184
+ for key, value in args.items():
185
+ if isinstance(value, list):
186
+ value = ", ".join(value)
187
+ await ui.muted(f"{key}: {value}", spaces=4)
@@ -0,0 +1,23 @@
1
+ """Input validators for Sidekick UI."""
2
+
3
+ from prompt_toolkit.validation import ValidationError, Validator
4
+
5
+
6
+ class ModelValidator(Validator):
7
+ """Validate default provider selection"""
8
+
9
+ def __init__(self, index):
10
+ self.index = index
11
+
12
+ def validate(self, document) -> None:
13
+ text = document.text.strip()
14
+ if not text:
15
+ raise ValidationError(message="Provider number cannot be empty")
16
+ elif text and not text.isdigit():
17
+ raise ValidationError(message="Invalid provider number")
18
+ elif text.isdigit():
19
+ number = int(text)
20
+ if number < 0 or number >= self.index:
21
+ raise ValidationError(
22
+ message="Invalid provider number",
23
+ )
File without changes
tunacode/utils/bm25.py ADDED
@@ -0,0 +1,55 @@
1
+ import math
2
+ import re
3
+ from collections import Counter
4
+ from typing import Iterable, List
5
+
6
+
7
+ def tokenize(text: str) -> List[str]:
8
+ """Simple whitespace and punctuation tokenizer."""
9
+ return re.findall(r"\w+", text.lower())
10
+
11
+
12
+ class BM25:
13
+ """Minimal BM25 implementation for small corpora."""
14
+
15
+ def __init__(self, corpus: Iterable[str], k1: float = 1.5, b: float = 0.75):
16
+ self.k1 = k1
17
+ self.b = b
18
+ self.documents = [tokenize(doc) for doc in corpus]
19
+ self.doc_freqs = []
20
+ self.doc_lens = []
21
+ self.idf = {}
22
+ self.avgdl = 0.0
23
+ self._initialize()
24
+
25
+ def _initialize(self) -> None:
26
+ df = Counter()
27
+ for doc in self.documents:
28
+ freqs = Counter(doc)
29
+ self.doc_freqs.append(freqs)
30
+ self.doc_lens.append(len(doc))
31
+ for word in freqs:
32
+ df[word] += 1
33
+ self.avgdl = sum(self.doc_lens) / len(self.documents) if self.documents else 0.0
34
+ total_docs = len(self.documents)
35
+ for word, freq in df.items():
36
+ self.idf[word] = math.log(1 + (total_docs - freq + 0.5) / (freq + 0.5))
37
+
38
+ def get_scores(self, query: Iterable[str]) -> List[float]:
39
+ """Calculate BM25 scores for a query."""
40
+ scores = [0.0] * len(self.documents)
41
+ query_terms = list(query)
42
+ for idx, doc in enumerate(self.documents):
43
+ freqs = self.doc_freqs[idx]
44
+ doc_len = self.doc_lens[idx]
45
+ score = 0.0
46
+ for term in query_terms:
47
+ if term not in freqs:
48
+ continue
49
+ idf = self.idf.get(term, 0.0)
50
+ tf = freqs[term]
51
+ numerator = tf * (self.k1 + 1)
52
+ denominator = tf + self.k1 * (1 - self.b + self.b * doc_len / self.avgdl)
53
+ score += idf * numerator / denominator
54
+ scores[idx] = score
55
+ return scores
@@ -0,0 +1,69 @@
1
+ """
2
+ Module: sidekick.utils.diff_utils
3
+
4
+ Provides diff visualization utilities for file changes.
5
+ Generates styled text diffs between original and modified content using the difflib library.
6
+ """
7
+
8
+ import difflib
9
+
10
+ from rich.text import Text
11
+
12
+
13
+ def render_file_diff(target: str, patch: str, colors=None) -> Text:
14
+ """
15
+ Create a formatted diff between target and patch text.
16
+
17
+ Args:
18
+ target (str): The original text to be replaced.
19
+ patch (str): The new text to insert.
20
+ colors (dict, optional): Dictionary containing style colors.
21
+ If None, no styling will be applied.
22
+
23
+ Returns:
24
+ Text: A Rich Text object containing the formatted diff.
25
+ """
26
+ # Create a clean diff with styled text
27
+ diff_text = Text()
28
+
29
+ # Get lines and create a diff sequence
30
+ target_lines = target.splitlines()
31
+ patch_lines = patch.splitlines()
32
+
33
+ # Use difflib to identify changes
34
+ matcher = difflib.SequenceMatcher(None, target_lines, patch_lines)
35
+
36
+ for op, i1, i2, j1, j2 in matcher.get_opcodes():
37
+ if op == "equal":
38
+ # Unchanged lines
39
+ for line in target_lines[i1:i2]:
40
+ diff_text.append(f" {line}\n")
41
+ elif op == "delete":
42
+ # Removed lines - show in red with (-) prefix
43
+ for line in target_lines[i1:i2]:
44
+ if colors:
45
+ diff_text.append(f"- {line}\n", style=colors.error)
46
+ else:
47
+ diff_text.append(f"- {line}\n")
48
+ elif op == "insert":
49
+ # Added lines - show in green with (+) prefix
50
+ for line in patch_lines[j1:j2]:
51
+ if colors:
52
+ diff_text.append(f"+ {line}\n", style=colors.success)
53
+ else:
54
+ diff_text.append(f"+ {line}\n")
55
+ elif op == "replace":
56
+ # Removed lines with (-) prefix
57
+ for line in target_lines[i1:i2]:
58
+ if colors:
59
+ diff_text.append(f"- {line}\n", style=colors.error)
60
+ else:
61
+ diff_text.append(f"- {line}\n")
62
+ # Added lines with (+) prefix
63
+ for line in patch_lines[j1:j2]:
64
+ if colors:
65
+ diff_text.append(f"+ {line}\n", style=colors.success)
66
+ else:
67
+ diff_text.append(f"+ {line}\n")
68
+
69
+ return diff_text
@@ -0,0 +1,41 @@
1
+ """
2
+ Module: sidekick.utils.file_utils
3
+
4
+ Provides file system utilities and helper classes.
5
+ Includes DotDict for dot notation access and stdout capture functionality.
6
+ """
7
+
8
+ import io
9
+ import sys
10
+ from contextlib import contextmanager
11
+
12
+
13
+ class DotDict(dict):
14
+ """dot.notation access to dictionary attributes"""
15
+
16
+ __getattr__ = dict.get
17
+ __setattr__ = dict.__setitem__
18
+ __delattr__ = dict.__delitem__
19
+
20
+
21
+ @contextmanager
22
+ def capture_stdout():
23
+ """
24
+ Context manager to capture stdout output.
25
+
26
+ Example:
27
+ with capture_stdout() as stdout_capture:
28
+ print("This will be captured")
29
+
30
+ captured_output = stdout_capture.getvalue()
31
+
32
+ Returns:
33
+ StringIO object containing the captured output
34
+ """
35
+ stdout_capture = io.StringIO()
36
+ original_stdout = sys.stdout
37
+ sys.stdout = stdout_capture
38
+ try:
39
+ yield stdout_capture
40
+ finally:
41
+ sys.stdout = original_stdout
@@ -0,0 +1,17 @@
1
+ import subprocess
2
+ from typing import List
3
+
4
+
5
+ def ripgrep(pattern: str, directory: str = ".") -> List[str]:
6
+ """Return a list of file paths matching a pattern using ripgrep."""
7
+ try:
8
+ result = subprocess.run(
9
+ ["rg", "--files", "-g", pattern, directory],
10
+ capture_output=True,
11
+ text=True,
12
+ check=True,
13
+ timeout=5,
14
+ )
15
+ return [line.strip() for line in result.stdout.splitlines() if line.strip()]
16
+ except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
17
+ return []