tunacode-cli 0.0.4__py3-none-any.whl → 0.0.6__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.
- tunacode/cli/commands.py +91 -33
- tunacode/cli/model_selector.py +178 -0
- tunacode/cli/repl.py +11 -10
- tunacode/configuration/models.py +11 -1
- tunacode/constants.py +11 -11
- tunacode/context.py +1 -3
- tunacode/core/agents/main.py +52 -94
- tunacode/core/agents/tinyagent_main.py +171 -0
- tunacode/core/setup/git_safety_setup.py +39 -51
- tunacode/core/setup/optimized_coordinator.py +73 -0
- tunacode/exceptions.py +13 -15
- tunacode/services/enhanced_undo_service.py +322 -0
- tunacode/services/project_undo_service.py +311 -0
- tunacode/services/undo_service.py +18 -21
- tunacode/tools/base.py +11 -20
- tunacode/tools/tinyagent_tools.py +103 -0
- tunacode/tools/update_file.py +24 -14
- tunacode/tools/write_file.py +9 -7
- tunacode/types.py +2 -2
- tunacode/ui/completers.py +98 -33
- tunacode/ui/input.py +8 -7
- tunacode/ui/keybindings.py +1 -3
- tunacode/ui/lexers.py +16 -17
- tunacode/ui/output.py +9 -3
- tunacode/ui/panels.py +4 -4
- tunacode/ui/prompt_manager.py +6 -4
- tunacode/utils/lazy_imports.py +59 -0
- tunacode/utils/regex_cache.py +33 -0
- tunacode/utils/system.py +13 -13
- tunacode_cli-0.0.6.dist-info/METADATA +235 -0
- {tunacode_cli-0.0.4.dist-info → tunacode_cli-0.0.6.dist-info}/RECORD +35 -27
- tunacode_cli-0.0.4.dist-info/METADATA +0 -247
- {tunacode_cli-0.0.4.dist-info → tunacode_cli-0.0.6.dist-info}/WHEEL +0 -0
- {tunacode_cli-0.0.4.dist-info → tunacode_cli-0.0.6.dist-info}/entry_points.txt +0 -0
- {tunacode_cli-0.0.4.dist-info → tunacode_cli-0.0.6.dist-info}/licenses/LICENSE +0 -0
- {tunacode_cli-0.0.4.dist-info → tunacode_cli-0.0.6.dist-info}/top_level.txt +0 -0
|
@@ -7,18 +7,18 @@ Manages automatic commits and rollback operations.
|
|
|
7
7
|
|
|
8
8
|
import subprocess
|
|
9
9
|
import time
|
|
10
|
+
from datetime import datetime, timezone
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
from typing import Optional, Tuple
|
|
12
13
|
|
|
13
|
-
from
|
|
14
|
-
|
|
15
|
-
from tunacode.constants import (ERROR_UNDO_INIT, UNDO_DISABLED_HOME, UNDO_DISABLED_NO_GIT,
|
|
16
|
-
UNDO_GIT_TIMEOUT, UNDO_INITIAL_COMMIT)
|
|
14
|
+
from tunacode.constants import (ERROR_UNDO_INIT, UNDO_DISABLED_HOME, UNDO_GIT_TIMEOUT,
|
|
15
|
+
UNDO_INITIAL_COMMIT)
|
|
17
16
|
from tunacode.core.state import StateManager
|
|
18
17
|
from tunacode.exceptions import GitOperationError
|
|
19
|
-
from tunacode.ui import console as ui
|
|
20
18
|
from tunacode.utils.system import get_session_dir
|
|
21
19
|
|
|
20
|
+
# Removed pydantic_ai import - using dict-based messages now
|
|
21
|
+
|
|
22
22
|
|
|
23
23
|
def is_in_git_project(directory: Optional[Path] = None) -> bool:
|
|
24
24
|
"""
|
|
@@ -59,7 +59,7 @@ def init_undo_system(state_manager: StateManager) -> bool:
|
|
|
59
59
|
home_dir = Path.home()
|
|
60
60
|
|
|
61
61
|
if cwd == home_dir:
|
|
62
|
-
|
|
62
|
+
print(f"⚠️ {UNDO_DISABLED_HOME}")
|
|
63
63
|
return False
|
|
64
64
|
|
|
65
65
|
if not is_in_git_project():
|
|
@@ -100,11 +100,11 @@ def init_undo_system(state_manager: StateManager) -> bool:
|
|
|
100
100
|
return True
|
|
101
101
|
except subprocess.TimeoutExpired as e:
|
|
102
102
|
error = GitOperationError(operation="init", message=UNDO_GIT_TIMEOUT, original_error=e)
|
|
103
|
-
|
|
103
|
+
print(f"⚠️ {str(error)}")
|
|
104
104
|
return False
|
|
105
105
|
except Exception as e:
|
|
106
106
|
error = GitOperationError(operation="init", message=str(e), original_error=e)
|
|
107
|
-
|
|
107
|
+
print(f"⚠️ {ERROR_UNDO_INIT.format(e=e)}")
|
|
108
108
|
return False
|
|
109
109
|
|
|
110
110
|
|
|
@@ -156,11 +156,11 @@ def commit_for_undo(
|
|
|
156
156
|
error = GitOperationError(
|
|
157
157
|
operation="commit", message="Git commit timed out", original_error=e
|
|
158
158
|
)
|
|
159
|
-
|
|
159
|
+
print(f"⚠️ {str(error)}")
|
|
160
160
|
return False
|
|
161
161
|
except Exception as e:
|
|
162
162
|
error = GitOperationError(operation="commit", message=str(e), original_error=e)
|
|
163
|
-
|
|
163
|
+
print(f"⚠️ Error creating undo commit: {e}")
|
|
164
164
|
return False
|
|
165
165
|
|
|
166
166
|
|
|
@@ -220,17 +220,14 @@ def perform_undo(state_manager: StateManager) -> Tuple[bool, str]:
|
|
|
220
220
|
# Add a system message to the chat history to inform the AI
|
|
221
221
|
# about the undo operation
|
|
222
222
|
state_manager.session.messages.append(
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
],
|
|
232
|
-
kind="response",
|
|
233
|
-
)
|
|
223
|
+
{
|
|
224
|
+
"role": "system",
|
|
225
|
+
"content": (
|
|
226
|
+
f"The last changes were undone. "
|
|
227
|
+
f"Commit message of undone changes: {commit_msg}"
|
|
228
|
+
),
|
|
229
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
230
|
+
}
|
|
234
231
|
)
|
|
235
232
|
|
|
236
233
|
return True, "Successfully undid last change"
|
tunacode/tools/base.py
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
"""Base tool class for all Sidekick tools.
|
|
2
2
|
|
|
3
3
|
This module provides a base class that implements common patterns
|
|
4
|
-
for all tools including error handling
|
|
4
|
+
for all tools including error handling and UI logging.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from abc import ABC, abstractmethod
|
|
8
8
|
|
|
9
|
-
from pydantic_ai.exceptions import ModelRetry
|
|
10
|
-
|
|
11
9
|
from tunacode.exceptions import FileOperationError, ToolExecutionError
|
|
12
10
|
from tunacode.types import FilePath, ToolName, ToolResult, UILogger
|
|
13
11
|
|
|
@@ -28,31 +26,25 @@ class BaseTool(ABC):
|
|
|
28
26
|
|
|
29
27
|
This method wraps the tool-specific logic with:
|
|
30
28
|
- UI logging of the operation
|
|
31
|
-
- Exception handling
|
|
29
|
+
- Exception handling
|
|
32
30
|
- Consistent error message formatting
|
|
33
31
|
|
|
34
32
|
Returns:
|
|
35
33
|
str: Success message
|
|
36
34
|
|
|
37
35
|
Raises:
|
|
38
|
-
|
|
39
|
-
ToolExecutionError: Raised for all other errors with structured information
|
|
36
|
+
ToolExecutionError: Raised for all errors with structured information
|
|
40
37
|
"""
|
|
41
38
|
try:
|
|
42
39
|
if self.ui:
|
|
43
40
|
await self.ui.info(f"{self.tool_name}({self._format_args(*args, **kwargs)})")
|
|
44
41
|
result = await self._execute(*args, **kwargs)
|
|
45
|
-
|
|
42
|
+
|
|
46
43
|
# For file operations, try to create a git commit for undo tracking
|
|
47
44
|
if isinstance(self, FileBasedTool):
|
|
48
45
|
await self._commit_for_undo()
|
|
49
|
-
|
|
46
|
+
|
|
50
47
|
return result
|
|
51
|
-
except ModelRetry as e:
|
|
52
|
-
# Log as warning and re-raise for pydantic-ai
|
|
53
|
-
if self.ui:
|
|
54
|
-
await self.ui.warning(str(e))
|
|
55
|
-
raise
|
|
56
48
|
except ToolExecutionError:
|
|
57
49
|
# Already properly formatted, just re-raise
|
|
58
50
|
raise
|
|
@@ -76,8 +68,7 @@ class BaseTool(ABC):
|
|
|
76
68
|
str: Success message describing what was done
|
|
77
69
|
|
|
78
70
|
Raises:
|
|
79
|
-
|
|
80
|
-
Exception: Any other errors will be caught and handled
|
|
71
|
+
Exception: Any errors will be caught and handled
|
|
81
72
|
"""
|
|
82
73
|
pass
|
|
83
74
|
|
|
@@ -148,10 +139,10 @@ class FileBasedTool(BaseTool):
|
|
|
148
139
|
- Encoding handling
|
|
149
140
|
- Git commit for undo tracking
|
|
150
141
|
"""
|
|
151
|
-
|
|
142
|
+
|
|
152
143
|
async def _commit_for_undo(self) -> None:
|
|
153
144
|
"""Create a git commit for undo tracking after file operations.
|
|
154
|
-
|
|
145
|
+
|
|
155
146
|
This method gracefully handles cases where git is not available:
|
|
156
147
|
- No git repository: Warns user about limited undo functionality
|
|
157
148
|
- Git command fails: Warns but doesn't break the main operation
|
|
@@ -160,13 +151,13 @@ class FileBasedTool(BaseTool):
|
|
|
160
151
|
try:
|
|
161
152
|
# Import here to avoid circular imports
|
|
162
153
|
from tunacode.services.undo_service import commit_for_undo, is_in_git_project
|
|
163
|
-
|
|
154
|
+
|
|
164
155
|
# Check if we're in a git project first
|
|
165
156
|
if not is_in_git_project():
|
|
166
157
|
if self.ui:
|
|
167
158
|
await self.ui.muted("⚠️ No git repository - undo functionality limited")
|
|
168
159
|
return
|
|
169
|
-
|
|
160
|
+
|
|
170
161
|
# Try to create commit with tool name as prefix
|
|
171
162
|
success = commit_for_undo(message_prefix=f"tunacode {self.tool_name.lower()}")
|
|
172
163
|
if success and self.ui:
|
|
@@ -179,7 +170,7 @@ class FileBasedTool(BaseTool):
|
|
|
179
170
|
if self.ui:
|
|
180
171
|
try:
|
|
181
172
|
await self.ui.muted("⚠️ Git commit failed - undo functionality limited")
|
|
182
|
-
except:
|
|
173
|
+
except Exception:
|
|
183
174
|
# Even the warning failed, just continue silently
|
|
184
175
|
pass
|
|
185
176
|
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""TinyAgent tool implementations with decorators."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from tinyagent.decorators import tool
|
|
6
|
+
|
|
7
|
+
from tunacode.exceptions import ToolExecutionError
|
|
8
|
+
from tunacode.ui import console as ui
|
|
9
|
+
|
|
10
|
+
# Import the existing tool classes to reuse their logic
|
|
11
|
+
from .read_file import ReadFileTool
|
|
12
|
+
from .run_command import RunCommandTool
|
|
13
|
+
from .update_file import UpdateFileTool
|
|
14
|
+
from .write_file import WriteFileTool
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@tool
|
|
18
|
+
async def read_file(filepath: str) -> str:
|
|
19
|
+
"""Read the contents of a file.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
filepath: The path to the file to read.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
The contents of the file.
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
Exception: If file cannot be read.
|
|
29
|
+
"""
|
|
30
|
+
tool_instance = ReadFileTool(ui)
|
|
31
|
+
try:
|
|
32
|
+
result = await tool_instance.execute(filepath)
|
|
33
|
+
return result
|
|
34
|
+
except ToolExecutionError as e:
|
|
35
|
+
# tinyAgent expects exceptions to be raised, not returned as strings
|
|
36
|
+
raise Exception(str(e))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@tool
|
|
40
|
+
async def write_file(filepath: str, content: str) -> str:
|
|
41
|
+
"""Write content to a file.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
filepath: The path to the file to write.
|
|
45
|
+
content: The content to write to the file.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Success message.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
Exception: If file cannot be written.
|
|
52
|
+
"""
|
|
53
|
+
tool_instance = WriteFileTool(ui)
|
|
54
|
+
try:
|
|
55
|
+
result = await tool_instance.execute(filepath, content)
|
|
56
|
+
return result
|
|
57
|
+
except ToolExecutionError as e:
|
|
58
|
+
raise Exception(str(e))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@tool
|
|
62
|
+
async def update_file(filepath: str, old_content: str, new_content: str) -> str:
|
|
63
|
+
"""Update specific content in a file.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
filepath: The path to the file to update.
|
|
67
|
+
old_content: The content to find and replace.
|
|
68
|
+
new_content: The new content to insert.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Success message.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
Exception: If file cannot be updated.
|
|
75
|
+
"""
|
|
76
|
+
tool_instance = UpdateFileTool(ui)
|
|
77
|
+
try:
|
|
78
|
+
result = await tool_instance.execute(filepath, old_content, new_content)
|
|
79
|
+
return result
|
|
80
|
+
except ToolExecutionError as e:
|
|
81
|
+
raise Exception(str(e))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@tool
|
|
85
|
+
async def run_command(command: str, timeout: Optional[int] = None) -> str:
|
|
86
|
+
"""Run a shell command.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
command: The command to run.
|
|
90
|
+
timeout: Optional timeout in seconds.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
The command output.
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
Exception: If command fails.
|
|
97
|
+
"""
|
|
98
|
+
tool_instance = RunCommandTool(ui)
|
|
99
|
+
try:
|
|
100
|
+
result = await tool_instance.execute(command, timeout)
|
|
101
|
+
return result
|
|
102
|
+
except ToolExecutionError as e:
|
|
103
|
+
raise Exception(str(e))
|
tunacode/tools/update_file.py
CHANGED
|
@@ -7,8 +7,6 @@ Enables safe text replacement in existing files with target/patch semantics.
|
|
|
7
7
|
|
|
8
8
|
import os
|
|
9
9
|
|
|
10
|
-
from pydantic_ai.exceptions import ModelRetry
|
|
11
|
-
|
|
12
10
|
from tunacode.exceptions import ToolExecutionError
|
|
13
11
|
from tunacode.tools.base import FileBasedTool
|
|
14
12
|
from tunacode.types import FileContent, FilePath, ToolResult
|
|
@@ -36,13 +34,17 @@ class UpdateFileTool(FileBasedTool):
|
|
|
36
34
|
ToolResult: A message indicating success.
|
|
37
35
|
|
|
38
36
|
Raises:
|
|
39
|
-
|
|
37
|
+
ToolExecutionError: If file not found or target not found
|
|
40
38
|
Exception: Any file operation errors
|
|
41
39
|
"""
|
|
42
40
|
if not os.path.exists(filepath):
|
|
43
|
-
raise
|
|
44
|
-
|
|
45
|
-
|
|
41
|
+
raise ToolExecutionError(
|
|
42
|
+
tool_name=self.tool_name,
|
|
43
|
+
message=(
|
|
44
|
+
f"File '{filepath}' not found. Cannot update. "
|
|
45
|
+
"Verify the filepath or use `write_file` if it's a new file."
|
|
46
|
+
),
|
|
47
|
+
original_error=None,
|
|
46
48
|
)
|
|
47
49
|
|
|
48
50
|
with open(filepath, "r", encoding="utf-8") as f:
|
|
@@ -53,20 +55,28 @@ class UpdateFileTool(FileBasedTool):
|
|
|
53
55
|
context_lines = 10
|
|
54
56
|
lines = original.splitlines()
|
|
55
57
|
snippet = "\n".join(lines[:context_lines])
|
|
56
|
-
#
|
|
57
|
-
raise
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
# Raise error to guide the LLM
|
|
59
|
+
raise ToolExecutionError(
|
|
60
|
+
tool_name=self.tool_name,
|
|
61
|
+
message=(
|
|
62
|
+
f"Target block not found in '{filepath}'. "
|
|
63
|
+
"Ensure the `target` argument exactly matches the content you want to replace. "
|
|
64
|
+
f"File starts with:\n---\n{snippet}\n---"
|
|
65
|
+
),
|
|
66
|
+
original_error=None,
|
|
61
67
|
)
|
|
62
68
|
|
|
63
69
|
new_content = original.replace(target, patch, 1) # Replace only the first occurrence
|
|
64
70
|
|
|
65
71
|
if original == new_content:
|
|
66
72
|
# This could happen if target and patch are identical
|
|
67
|
-
raise
|
|
68
|
-
|
|
69
|
-
|
|
73
|
+
raise ToolExecutionError(
|
|
74
|
+
tool_name=self.tool_name,
|
|
75
|
+
message=(
|
|
76
|
+
f"Update target found, but replacement resulted in no changes to '{filepath}'. "
|
|
77
|
+
"Was the `target` identical to the `patch`? Please check the file content."
|
|
78
|
+
),
|
|
79
|
+
original_error=None,
|
|
70
80
|
)
|
|
71
81
|
|
|
72
82
|
with open(filepath, "w", encoding="utf-8") as f:
|
tunacode/tools/write_file.py
CHANGED
|
@@ -7,8 +7,6 @@ Creates new files with automatic directory creation and overwrite protection.
|
|
|
7
7
|
|
|
8
8
|
import os
|
|
9
9
|
|
|
10
|
-
from pydantic_ai.exceptions import ModelRetry
|
|
11
|
-
|
|
12
10
|
from tunacode.exceptions import ToolExecutionError
|
|
13
11
|
from tunacode.tools.base import FileBasedTool
|
|
14
12
|
from tunacode.types import FileContent, FilePath, ToolResult
|
|
@@ -33,15 +31,19 @@ class WriteFileTool(FileBasedTool):
|
|
|
33
31
|
ToolResult: A message indicating success.
|
|
34
32
|
|
|
35
33
|
Raises:
|
|
36
|
-
|
|
34
|
+
ToolExecutionError: If the file already exists
|
|
37
35
|
Exception: Any file writing errors
|
|
38
36
|
"""
|
|
39
37
|
# Prevent overwriting existing files with this tool.
|
|
40
38
|
if os.path.exists(filepath):
|
|
41
|
-
#
|
|
42
|
-
raise
|
|
43
|
-
|
|
44
|
-
|
|
39
|
+
# Raise error to guide the LLM
|
|
40
|
+
raise ToolExecutionError(
|
|
41
|
+
tool_name=self.tool_name,
|
|
42
|
+
message=(
|
|
43
|
+
f"File '{filepath}' already exists. Use the `update_file` tool "
|
|
44
|
+
"to modify it, or choose a different filepath."
|
|
45
|
+
),
|
|
46
|
+
original_error=None,
|
|
45
47
|
)
|
|
46
48
|
|
|
47
49
|
# Create directories if they don't exist
|
tunacode/types.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Centralized type definitions for
|
|
2
|
+
Centralized type definitions for TunaCode CLI.
|
|
3
3
|
|
|
4
4
|
This module contains all type aliases, protocols, and type definitions
|
|
5
|
-
used throughout the
|
|
5
|
+
used throughout the TunaCode codebase.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from dataclasses import dataclass
|
tunacode/ui/completers.py
CHANGED
|
@@ -10,54 +10,117 @@ from ..cli.commands import CommandRegistry
|
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class CommandCompleter(Completer):
|
|
13
|
-
"""Completer for slash commands."""
|
|
14
|
-
|
|
13
|
+
"""Completer for slash commands and their arguments."""
|
|
14
|
+
|
|
15
15
|
def __init__(self, command_registry: Optional[CommandRegistry] = None):
|
|
16
16
|
self.command_registry = command_registry
|
|
17
|
-
|
|
17
|
+
self._model_selector = None
|
|
18
|
+
|
|
18
19
|
def get_completions(
|
|
19
20
|
self, document: Document, complete_event: CompleteEvent
|
|
20
21
|
) -> Iterable[Completion]:
|
|
21
|
-
"""Get completions for slash commands."""
|
|
22
|
+
"""Get completions for slash commands and model names."""
|
|
22
23
|
# Get the text before cursor
|
|
23
24
|
text = document.text_before_cursor
|
|
24
|
-
|
|
25
|
+
|
|
26
|
+
# Check if we're completing model names after /model or /m command
|
|
27
|
+
if self._should_complete_model_names(text):
|
|
28
|
+
yield from self._get_model_completions(document, text)
|
|
29
|
+
return
|
|
30
|
+
|
|
25
31
|
# Check if we're at the start of a line or after whitespace
|
|
26
|
-
if text and not text.isspace() and text[-1] !=
|
|
32
|
+
if text and not text.isspace() and text[-1] != "\n":
|
|
27
33
|
# Only complete commands at the start of input or after a newline
|
|
28
|
-
last_newline = text.rfind(
|
|
29
|
-
line_start = text[last_newline + 1:] if last_newline >= 0 else text
|
|
30
|
-
|
|
34
|
+
last_newline = text.rfind("\n")
|
|
35
|
+
line_start = text[last_newline + 1 :] if last_newline >= 0 else text # noqa: E203
|
|
36
|
+
|
|
31
37
|
# Skip if not at the beginning of a line
|
|
32
|
-
if line_start and not line_start.startswith(
|
|
38
|
+
if line_start and not line_start.startswith("/"):
|
|
33
39
|
return
|
|
34
|
-
|
|
40
|
+
|
|
35
41
|
# Get the word before cursor
|
|
36
42
|
word_before_cursor = document.get_word_before_cursor(WORD=True)
|
|
37
|
-
|
|
43
|
+
|
|
38
44
|
# Only complete if word starts with /
|
|
39
|
-
if not word_before_cursor.startswith(
|
|
45
|
+
if not word_before_cursor.startswith("/"):
|
|
40
46
|
return
|
|
41
|
-
|
|
47
|
+
|
|
42
48
|
# Get command names from registry
|
|
43
49
|
if self.command_registry:
|
|
44
50
|
command_names = self.command_registry.get_command_names()
|
|
45
51
|
else:
|
|
46
52
|
# Fallback list of commands
|
|
47
|
-
command_names = [
|
|
48
|
-
|
|
49
|
-
|
|
53
|
+
command_names = [
|
|
54
|
+
"/help",
|
|
55
|
+
"/clear",
|
|
56
|
+
"/dump",
|
|
57
|
+
"/yolo",
|
|
58
|
+
"/undo",
|
|
59
|
+
"/branch",
|
|
60
|
+
"/compact",
|
|
61
|
+
"/model",
|
|
62
|
+
"/m",
|
|
63
|
+
"/init",
|
|
64
|
+
]
|
|
65
|
+
|
|
50
66
|
# Get the partial command (without /)
|
|
51
67
|
partial = word_before_cursor[1:].lower()
|
|
52
|
-
|
|
68
|
+
|
|
53
69
|
# Yield completions for matching commands
|
|
54
70
|
for cmd in command_names:
|
|
55
|
-
if cmd.startswith(
|
|
71
|
+
if cmd.startswith("/") and cmd[1:].lower().startswith(partial):
|
|
56
72
|
yield Completion(
|
|
57
73
|
text=cmd,
|
|
58
74
|
start_position=-len(word_before_cursor),
|
|
59
75
|
display=cmd,
|
|
60
|
-
display_meta=
|
|
76
|
+
display_meta="command",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def _should_complete_model_names(self, text: str) -> bool:
|
|
80
|
+
"""Check if we should complete model names."""
|
|
81
|
+
# Look for /model or /m command followed by space
|
|
82
|
+
import re
|
|
83
|
+
|
|
84
|
+
pattern = r"(?:^|\n)\s*(?:/model|/m)\s+\S*$"
|
|
85
|
+
return bool(re.search(pattern, text))
|
|
86
|
+
|
|
87
|
+
def _get_model_completions(self, document: Document, text: str) -> Iterable[Completion]:
|
|
88
|
+
"""Get completions for model names."""
|
|
89
|
+
# Lazy import and cache
|
|
90
|
+
if self._model_selector is None:
|
|
91
|
+
try:
|
|
92
|
+
from ..cli.model_selector import ModelSelector
|
|
93
|
+
|
|
94
|
+
self._model_selector = ModelSelector()
|
|
95
|
+
except ImportError:
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
# Get the partial model name
|
|
99
|
+
word_before_cursor = document.get_word_before_cursor(WORD=True)
|
|
100
|
+
partial = word_before_cursor.lower()
|
|
101
|
+
|
|
102
|
+
# Yield model short names and indices
|
|
103
|
+
seen = set()
|
|
104
|
+
for i, model in enumerate(self._model_selector.models):
|
|
105
|
+
# Complete by index
|
|
106
|
+
index_str = str(i)
|
|
107
|
+
if index_str.startswith(partial) and index_str not in seen:
|
|
108
|
+
seen.add(index_str)
|
|
109
|
+
yield Completion(
|
|
110
|
+
text=index_str,
|
|
111
|
+
start_position=-len(word_before_cursor),
|
|
112
|
+
display=f"{index_str} - {model.display_name}",
|
|
113
|
+
display_meta=model.provider.value[2],
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Complete by short name
|
|
117
|
+
if model.short_name.lower().startswith(partial) and model.short_name not in seen:
|
|
118
|
+
seen.add(model.short_name)
|
|
119
|
+
yield Completion(
|
|
120
|
+
text=model.short_name,
|
|
121
|
+
start_position=-len(word_before_cursor),
|
|
122
|
+
display=f"{model.short_name} - {model.display_name}",
|
|
123
|
+
display_meta=model.provider.value[2],
|
|
61
124
|
)
|
|
62
125
|
|
|
63
126
|
|
|
@@ -70,14 +133,14 @@ class FileReferenceCompleter(Completer):
|
|
|
70
133
|
"""Get completions for @file references."""
|
|
71
134
|
# Get the word before cursor
|
|
72
135
|
word_before_cursor = document.get_word_before_cursor(WORD=True)
|
|
73
|
-
|
|
136
|
+
|
|
74
137
|
# Check if we're in an @file reference
|
|
75
138
|
if not word_before_cursor.startswith("@"):
|
|
76
139
|
return
|
|
77
|
-
|
|
140
|
+
|
|
78
141
|
# Get the path part after @
|
|
79
142
|
path_part = word_before_cursor[1:] # Remove @
|
|
80
|
-
|
|
143
|
+
|
|
81
144
|
# Determine directory and prefix
|
|
82
145
|
if "/" in path_part:
|
|
83
146
|
# Path includes directory
|
|
@@ -87,18 +150,18 @@ class FileReferenceCompleter(Completer):
|
|
|
87
150
|
# Just filename, search in current directory
|
|
88
151
|
dir_path = "."
|
|
89
152
|
prefix = path_part
|
|
90
|
-
|
|
153
|
+
|
|
91
154
|
# Get matching files
|
|
92
155
|
try:
|
|
93
156
|
if os.path.exists(dir_path) and os.path.isdir(dir_path):
|
|
94
157
|
for item in sorted(os.listdir(dir_path)):
|
|
95
158
|
if item.startswith(prefix):
|
|
96
159
|
full_path = os.path.join(dir_path, item) if dir_path != "." else item
|
|
97
|
-
|
|
160
|
+
|
|
98
161
|
# Skip hidden files unless explicitly requested
|
|
99
162
|
if item.startswith(".") and not prefix.startswith("."):
|
|
100
163
|
continue
|
|
101
|
-
|
|
164
|
+
|
|
102
165
|
# Add / for directories
|
|
103
166
|
if os.path.isdir(full_path):
|
|
104
167
|
display = item + "/"
|
|
@@ -106,15 +169,15 @@ class FileReferenceCompleter(Completer):
|
|
|
106
169
|
else:
|
|
107
170
|
display = item
|
|
108
171
|
completion = full_path
|
|
109
|
-
|
|
172
|
+
|
|
110
173
|
# Calculate how much to replace
|
|
111
174
|
start_position = -len(path_part)
|
|
112
|
-
|
|
175
|
+
|
|
113
176
|
yield Completion(
|
|
114
177
|
text=completion,
|
|
115
178
|
start_position=start_position,
|
|
116
179
|
display=display,
|
|
117
|
-
display_meta="dir" if os.path.isdir(full_path) else "file"
|
|
180
|
+
display_meta="dir" if os.path.isdir(full_path) else "file",
|
|
118
181
|
)
|
|
119
182
|
except (OSError, PermissionError):
|
|
120
183
|
# Silently ignore inaccessible directories
|
|
@@ -123,7 +186,9 @@ class FileReferenceCompleter(Completer):
|
|
|
123
186
|
|
|
124
187
|
def create_completer(command_registry: Optional[CommandRegistry] = None) -> Completer:
|
|
125
188
|
"""Create a merged completer for both commands and file references."""
|
|
126
|
-
return merge_completers(
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
189
|
+
return merge_completers(
|
|
190
|
+
[
|
|
191
|
+
CommandCompleter(command_registry),
|
|
192
|
+
FileReferenceCompleter(),
|
|
193
|
+
]
|
|
194
|
+
)
|
tunacode/ui/input.py
CHANGED
|
@@ -4,10 +4,9 @@ from typing import Optional
|
|
|
4
4
|
|
|
5
5
|
from prompt_toolkit.formatted_text import HTML
|
|
6
6
|
from prompt_toolkit.key_binding import KeyBindings
|
|
7
|
-
from prompt_toolkit.styles import Style
|
|
8
7
|
from prompt_toolkit.validation import Validator
|
|
9
8
|
|
|
10
|
-
from tunacode.constants import
|
|
9
|
+
from tunacode.constants import UI_PROMPT_PREFIX
|
|
11
10
|
from tunacode.core.state import StateManager
|
|
12
11
|
|
|
13
12
|
from .completers import create_completer
|
|
@@ -72,7 +71,9 @@ async def input(
|
|
|
72
71
|
return await manager.get_input(session_key, pretext, config)
|
|
73
72
|
|
|
74
73
|
|
|
75
|
-
async def multiline_input(
|
|
74
|
+
async def multiline_input(
|
|
75
|
+
state_manager: Optional[StateManager] = None, command_registry=None
|
|
76
|
+
) -> str:
|
|
76
77
|
"""Get multiline input from the user with @file completion and highlighting."""
|
|
77
78
|
kb = create_key_bindings()
|
|
78
79
|
placeholder = formatted_text(
|
|
@@ -85,11 +86,11 @@ async def multiline_input(state_manager: Optional[StateManager] = None, command_
|
|
|
85
86
|
)
|
|
86
87
|
)
|
|
87
88
|
return await input(
|
|
88
|
-
"multiline",
|
|
89
|
-
key_bindings=kb,
|
|
90
|
-
multiline=True,
|
|
89
|
+
"multiline",
|
|
90
|
+
key_bindings=kb,
|
|
91
|
+
multiline=True,
|
|
91
92
|
placeholder=placeholder,
|
|
92
93
|
completer=create_completer(command_registry),
|
|
93
94
|
lexer=FileReferenceLexer(),
|
|
94
|
-
state_manager=state_manager
|
|
95
|
+
state_manager=state_manager,
|
|
95
96
|
)
|
tunacode/ui/keybindings.py
CHANGED
|
@@ -7,8 +7,6 @@ def create_key_bindings() -> KeyBindings:
|
|
|
7
7
|
"""Create and configure key bindings for the UI."""
|
|
8
8
|
kb = KeyBindings()
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
10
|
@kb.add("enter")
|
|
13
11
|
def _submit(event):
|
|
14
12
|
"""Submit the current buffer."""
|
|
@@ -18,7 +16,7 @@ def create_key_bindings() -> KeyBindings:
|
|
|
18
16
|
def _newline(event):
|
|
19
17
|
"""Insert a newline character."""
|
|
20
18
|
event.current_buffer.insert_text("\n")
|
|
21
|
-
|
|
19
|
+
|
|
22
20
|
@kb.add("escape", "enter")
|
|
23
21
|
def _escape_enter(event):
|
|
24
22
|
"""Insert a newline when escape then enter is pressed."""
|