tunacode-cli 0.0.55__py3-none-any.whl → 0.0.57__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/implementations/plan.py +50 -0
- tunacode/cli/commands/registry.py +3 -0
- tunacode/cli/repl.py +327 -186
- tunacode/cli/repl_components/command_parser.py +37 -4
- tunacode/cli/repl_components/error_recovery.py +79 -1
- tunacode/cli/repl_components/output_display.py +21 -1
- tunacode/cli/repl_components/tool_executor.py +12 -0
- tunacode/configuration/defaults.py +8 -0
- tunacode/constants.py +10 -2
- tunacode/core/agents/agent_components/agent_config.py +212 -22
- tunacode/core/agents/agent_components/node_processor.py +46 -40
- tunacode/core/code_index.py +83 -29
- tunacode/core/state.py +44 -0
- tunacode/core/token_usage/usage_tracker.py +2 -2
- tunacode/core/tool_handler.py +20 -0
- tunacode/prompts/system.md +117 -490
- tunacode/services/mcp.py +29 -7
- tunacode/tools/base.py +110 -0
- tunacode/tools/bash.py +96 -1
- tunacode/tools/exit_plan_mode.py +273 -0
- tunacode/tools/glob.py +366 -33
- tunacode/tools/grep.py +226 -77
- tunacode/tools/grep_components/result_formatter.py +98 -4
- tunacode/tools/list_dir.py +132 -2
- tunacode/tools/present_plan.py +288 -0
- tunacode/tools/read_file.py +91 -0
- tunacode/tools/run_command.py +99 -0
- tunacode/tools/schema_assembler.py +167 -0
- tunacode/tools/todo.py +108 -1
- tunacode/tools/update_file.py +94 -0
- tunacode/tools/write_file.py +86 -0
- tunacode/types.py +58 -0
- tunacode/ui/input.py +14 -2
- tunacode/ui/keybindings.py +25 -4
- tunacode/ui/panels.py +53 -8
- tunacode/ui/prompt_manager.py +25 -2
- tunacode/ui/tool_ui.py +3 -2
- tunacode/utils/json_utils.py +206 -0
- tunacode/utils/message_utils.py +14 -4
- tunacode/utils/ripgrep.py +332 -9
- {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.57.dist-info}/METADATA +8 -3
- {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.57.dist-info}/RECORD +46 -42
- tunacode/tools/read_file_async_poc.py +0 -196
- {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.57.dist-info}/WHEEL +0 -0
- {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.57.dist-info}/entry_points.txt +0 -0
- {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.57.dist-info}/licenses/LICENSE +0 -0
- {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.57.dist-info}/top_level.txt +0 -0
tunacode/tools/todo.py
CHANGED
|
@@ -4,10 +4,13 @@ This tool allows the AI agent to manage todo items during task execution.
|
|
|
4
4
|
It provides functionality for creating, updating, and tracking tasks.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import logging
|
|
7
8
|
import uuid
|
|
8
9
|
from datetime import datetime
|
|
9
|
-
from
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, List, Literal, Optional, Union
|
|
10
12
|
|
|
13
|
+
import defusedxml.ElementTree as ET
|
|
11
14
|
from pydantic_ai.exceptions import ModelRetry
|
|
12
15
|
|
|
13
16
|
from tunacode.constants import (
|
|
@@ -21,10 +24,114 @@ from tunacode.types import TodoItem, ToolResult, UILogger
|
|
|
21
24
|
|
|
22
25
|
from .base import BaseTool
|
|
23
26
|
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
24
29
|
|
|
25
30
|
class TodoTool(BaseTool):
|
|
26
31
|
"""Tool for managing todo items from the AI agent."""
|
|
27
32
|
|
|
33
|
+
def _get_base_prompt(self) -> str:
|
|
34
|
+
"""Load and return the base prompt from XML file.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
str: The loaded prompt from XML or a default prompt
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
# Load prompt from XML file
|
|
41
|
+
prompt_file = Path(__file__).parent / "prompts" / "todo_prompt.xml"
|
|
42
|
+
if prompt_file.exists():
|
|
43
|
+
tree = ET.parse(prompt_file)
|
|
44
|
+
root = tree.getroot()
|
|
45
|
+
description = root.find("description")
|
|
46
|
+
if description is not None:
|
|
47
|
+
return description.text.strip()
|
|
48
|
+
except Exception as e:
|
|
49
|
+
logger.warning(f"Failed to load XML prompt for todo: {e}")
|
|
50
|
+
|
|
51
|
+
# Fallback to default prompt
|
|
52
|
+
return """Use this tool to create and manage a structured task list"""
|
|
53
|
+
|
|
54
|
+
def _get_parameters_schema(self) -> Dict[str, Any]:
|
|
55
|
+
"""Get the parameters schema for todo tool.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Dict containing the JSON schema for tool parameters
|
|
59
|
+
"""
|
|
60
|
+
# Try to load from XML first
|
|
61
|
+
try:
|
|
62
|
+
prompt_file = Path(__file__).parent / "prompts" / "todo_prompt.xml"
|
|
63
|
+
if prompt_file.exists():
|
|
64
|
+
tree = ET.parse(prompt_file)
|
|
65
|
+
root = tree.getroot()
|
|
66
|
+
parameters = root.find("parameters")
|
|
67
|
+
if parameters is not None:
|
|
68
|
+
schema: Dict[str, Any] = {"type": "object", "properties": {}, "required": []}
|
|
69
|
+
required_fields: List[str] = []
|
|
70
|
+
|
|
71
|
+
for param in parameters.findall("parameter"):
|
|
72
|
+
name = param.get("name")
|
|
73
|
+
required = param.get("required", "false").lower() == "true"
|
|
74
|
+
param_type = param.find("type")
|
|
75
|
+
description = param.find("description")
|
|
76
|
+
|
|
77
|
+
if name and param_type is not None:
|
|
78
|
+
prop = {
|
|
79
|
+
"type": param_type.text.strip(),
|
|
80
|
+
"description": description.text.strip()
|
|
81
|
+
if description is not None
|
|
82
|
+
else "",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# Handle array types and nested objects
|
|
86
|
+
if param_type.text.strip() == "array":
|
|
87
|
+
items = param.find("items")
|
|
88
|
+
if items is not None:
|
|
89
|
+
item_type = items.find("type")
|
|
90
|
+
if item_type is not None and item_type.text.strip() == "object":
|
|
91
|
+
# Handle object items
|
|
92
|
+
item_props = {}
|
|
93
|
+
item_properties = items.find("properties")
|
|
94
|
+
if item_properties is not None:
|
|
95
|
+
for item_prop in item_properties.findall("property"):
|
|
96
|
+
prop_name = item_prop.get("name")
|
|
97
|
+
prop_type_elem = item_prop.find("type")
|
|
98
|
+
if prop_name and prop_type_elem is not None:
|
|
99
|
+
item_props[prop_name] = {
|
|
100
|
+
"type": prop_type_elem.text.strip()
|
|
101
|
+
}
|
|
102
|
+
prop["items"] = {"type": "object", "properties": item_props}
|
|
103
|
+
else:
|
|
104
|
+
prop["items"] = {"type": items.text.strip()}
|
|
105
|
+
|
|
106
|
+
schema["properties"][name] = prop
|
|
107
|
+
if required:
|
|
108
|
+
required_fields.append(name)
|
|
109
|
+
|
|
110
|
+
schema["required"] = required_fields
|
|
111
|
+
return schema
|
|
112
|
+
except Exception as e:
|
|
113
|
+
logger.warning(f"Failed to load parameters from XML for todo: {e}")
|
|
114
|
+
|
|
115
|
+
# Fallback to hardcoded schema
|
|
116
|
+
return {
|
|
117
|
+
"type": "object",
|
|
118
|
+
"properties": {
|
|
119
|
+
"todos": {
|
|
120
|
+
"type": "array",
|
|
121
|
+
"description": "The updated todo list",
|
|
122
|
+
"items": {
|
|
123
|
+
"type": "object",
|
|
124
|
+
"properties": {
|
|
125
|
+
"id": {"type": "string"},
|
|
126
|
+
"content": {"type": "string"},
|
|
127
|
+
"status": {"type": "string"},
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
"required": ["todos"],
|
|
133
|
+
}
|
|
134
|
+
|
|
28
135
|
def __init__(self, state_manager, ui_logger: UILogger | None = None):
|
|
29
136
|
"""Initialize the todo tool.
|
|
30
137
|
|
tunacode/tools/update_file.py
CHANGED
|
@@ -5,14 +5,21 @@ File update tool for agent operations in the TunaCode application.
|
|
|
5
5
|
Provides targeted file content modification with diff-based updates.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
import logging
|
|
8
9
|
import os
|
|
10
|
+
from functools import lru_cache
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, List
|
|
9
13
|
|
|
14
|
+
import defusedxml.ElementTree as ET
|
|
10
15
|
from pydantic_ai.exceptions import ModelRetry
|
|
11
16
|
|
|
12
17
|
from tunacode.exceptions import ToolExecutionError
|
|
13
18
|
from tunacode.tools.base import FileBasedTool
|
|
14
19
|
from tunacode.types import ToolResult
|
|
15
20
|
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
16
23
|
|
|
17
24
|
class UpdateFileTool(FileBasedTool):
|
|
18
25
|
"""Tool for updating existing files by replacing text blocks."""
|
|
@@ -21,6 +28,93 @@ class UpdateFileTool(FileBasedTool):
|
|
|
21
28
|
def tool_name(self) -> str:
|
|
22
29
|
return "Update"
|
|
23
30
|
|
|
31
|
+
@lru_cache(maxsize=1)
|
|
32
|
+
def _get_base_prompt(self) -> str:
|
|
33
|
+
"""Load and return the base prompt from XML file.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
str: The loaded prompt from XML or a default prompt
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
# Load prompt from XML file
|
|
40
|
+
prompt_file = Path(__file__).parent / "prompts" / "update_file_prompt.xml"
|
|
41
|
+
if prompt_file.exists():
|
|
42
|
+
tree = ET.parse(prompt_file)
|
|
43
|
+
root = tree.getroot()
|
|
44
|
+
description = root.find("description")
|
|
45
|
+
if description is not None:
|
|
46
|
+
return description.text.strip()
|
|
47
|
+
except Exception as e:
|
|
48
|
+
logger.warning(f"Failed to load XML prompt for update_file: {e}")
|
|
49
|
+
|
|
50
|
+
# Fallback to default prompt
|
|
51
|
+
return """Performs exact string replacements in files"""
|
|
52
|
+
|
|
53
|
+
@lru_cache(maxsize=1)
|
|
54
|
+
def _get_parameters_schema(self) -> Dict[str, Any]:
|
|
55
|
+
"""Get the parameters schema for update_file tool.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Dict containing the JSON schema for tool parameters
|
|
59
|
+
"""
|
|
60
|
+
# Try to load from XML first
|
|
61
|
+
try:
|
|
62
|
+
prompt_file = Path(__file__).parent / "prompts" / "update_file_prompt.xml"
|
|
63
|
+
if prompt_file.exists():
|
|
64
|
+
tree = ET.parse(prompt_file)
|
|
65
|
+
root = tree.getroot()
|
|
66
|
+
parameters = root.find("parameters")
|
|
67
|
+
if parameters is not None:
|
|
68
|
+
schema: Dict[str, Any] = {"type": "object", "properties": {}, "required": []}
|
|
69
|
+
required_fields: List[str] = []
|
|
70
|
+
|
|
71
|
+
for param in parameters.findall("parameter"):
|
|
72
|
+
name = param.get("name")
|
|
73
|
+
required = param.get("required", "false").lower() == "true"
|
|
74
|
+
param_type = param.find("type")
|
|
75
|
+
description = param.find("description")
|
|
76
|
+
|
|
77
|
+
if name and param_type is not None:
|
|
78
|
+
prop = {
|
|
79
|
+
"type": param_type.text.strip(),
|
|
80
|
+
"description": description.text.strip()
|
|
81
|
+
if description is not None
|
|
82
|
+
else "",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
schema["properties"][name] = prop
|
|
86
|
+
if required:
|
|
87
|
+
required_fields.append(name)
|
|
88
|
+
|
|
89
|
+
schema["required"] = required_fields
|
|
90
|
+
return schema
|
|
91
|
+
except Exception as e:
|
|
92
|
+
logger.warning(f"Failed to load parameters from XML for update_file: {e}")
|
|
93
|
+
|
|
94
|
+
# Fallback to hardcoded schema
|
|
95
|
+
return {
|
|
96
|
+
"type": "object",
|
|
97
|
+
"properties": {
|
|
98
|
+
"file_path": {
|
|
99
|
+
"type": "string",
|
|
100
|
+
"description": "The absolute path to the file to modify",
|
|
101
|
+
},
|
|
102
|
+
"old_string": {
|
|
103
|
+
"type": "string",
|
|
104
|
+
"description": "The text to replace",
|
|
105
|
+
},
|
|
106
|
+
"new_string": {
|
|
107
|
+
"type": "string",
|
|
108
|
+
"description": "The text to replace it with",
|
|
109
|
+
},
|
|
110
|
+
"replace_all": {
|
|
111
|
+
"type": "boolean",
|
|
112
|
+
"description": "Replace all occurences of old_string",
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
"required": ["file_path", "old_string", "new_string"],
|
|
116
|
+
}
|
|
117
|
+
|
|
24
118
|
async def _execute(self, filepath: str, target: str, patch: str) -> ToolResult:
|
|
25
119
|
"""Update an existing file by replacing a target text block with a patch.
|
|
26
120
|
|
tunacode/tools/write_file.py
CHANGED
|
@@ -5,14 +5,21 @@ File writing tool for agent operations in the TunaCode application.
|
|
|
5
5
|
Provides safe file creation with conflict detection and encoding handling.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
import logging
|
|
8
9
|
import os
|
|
10
|
+
from functools import lru_cache
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, List
|
|
9
13
|
|
|
14
|
+
import defusedxml.ElementTree as ET
|
|
10
15
|
from pydantic_ai.exceptions import ModelRetry
|
|
11
16
|
|
|
12
17
|
from tunacode.exceptions import ToolExecutionError
|
|
13
18
|
from tunacode.tools.base import FileBasedTool
|
|
14
19
|
from tunacode.types import ToolResult
|
|
15
20
|
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
16
23
|
|
|
17
24
|
class WriteFileTool(FileBasedTool):
|
|
18
25
|
"""Tool for writing content to new files."""
|
|
@@ -21,6 +28,85 @@ class WriteFileTool(FileBasedTool):
|
|
|
21
28
|
def tool_name(self) -> str:
|
|
22
29
|
return "Write"
|
|
23
30
|
|
|
31
|
+
@lru_cache(maxsize=1)
|
|
32
|
+
def _get_base_prompt(self) -> str:
|
|
33
|
+
"""Load and return the base prompt from XML file.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
str: The loaded prompt from XML or a default prompt
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
# Load prompt from XML file
|
|
40
|
+
prompt_file = Path(__file__).parent / "prompts" / "write_file_prompt.xml"
|
|
41
|
+
if prompt_file.exists():
|
|
42
|
+
tree = ET.parse(prompt_file)
|
|
43
|
+
root = tree.getroot()
|
|
44
|
+
description = root.find("description")
|
|
45
|
+
if description is not None:
|
|
46
|
+
return description.text.strip()
|
|
47
|
+
except Exception as e:
|
|
48
|
+
logger.warning(f"Failed to load XML prompt for write_file: {e}")
|
|
49
|
+
|
|
50
|
+
# Fallback to default prompt
|
|
51
|
+
return """Writes a file to the local filesystem"""
|
|
52
|
+
|
|
53
|
+
@lru_cache(maxsize=1)
|
|
54
|
+
def _get_parameters_schema(self) -> Dict[str, Any]:
|
|
55
|
+
"""Get the parameters schema for write_file tool.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Dict containing the JSON schema for tool parameters
|
|
59
|
+
"""
|
|
60
|
+
# Try to load from XML first
|
|
61
|
+
try:
|
|
62
|
+
prompt_file = Path(__file__).parent / "prompts" / "write_file_prompt.xml"
|
|
63
|
+
if prompt_file.exists():
|
|
64
|
+
tree = ET.parse(prompt_file)
|
|
65
|
+
root = tree.getroot()
|
|
66
|
+
parameters = root.find("parameters")
|
|
67
|
+
if parameters is not None:
|
|
68
|
+
schema: Dict[str, Any] = {"type": "object", "properties": {}, "required": []}
|
|
69
|
+
required_fields: List[str] = []
|
|
70
|
+
|
|
71
|
+
for param in parameters.findall("parameter"):
|
|
72
|
+
name = param.get("name")
|
|
73
|
+
required = param.get("required", "false").lower() == "true"
|
|
74
|
+
param_type = param.find("type")
|
|
75
|
+
description = param.find("description")
|
|
76
|
+
|
|
77
|
+
if name and param_type is not None:
|
|
78
|
+
prop = {
|
|
79
|
+
"type": param_type.text.strip(),
|
|
80
|
+
"description": description.text.strip()
|
|
81
|
+
if description is not None
|
|
82
|
+
else "",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
schema["properties"][name] = prop
|
|
86
|
+
if required:
|
|
87
|
+
required_fields.append(name)
|
|
88
|
+
|
|
89
|
+
schema["required"] = required_fields
|
|
90
|
+
return schema
|
|
91
|
+
except Exception as e:
|
|
92
|
+
logger.warning(f"Failed to load parameters from XML for write_file: {e}")
|
|
93
|
+
|
|
94
|
+
# Fallback to hardcoded schema
|
|
95
|
+
return {
|
|
96
|
+
"type": "object",
|
|
97
|
+
"properties": {
|
|
98
|
+
"file_path": {
|
|
99
|
+
"type": "string",
|
|
100
|
+
"description": "The absolute path to the file to write",
|
|
101
|
+
},
|
|
102
|
+
"content": {
|
|
103
|
+
"type": "string",
|
|
104
|
+
"description": "The content to write to the file",
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
"required": ["file_path", "content"],
|
|
108
|
+
}
|
|
109
|
+
|
|
24
110
|
async def _execute(self, filepath: str, content: str) -> ToolResult:
|
|
25
111
|
"""Write content to a new file. Fails if the file already exists.
|
|
26
112
|
|
tunacode/types.py
CHANGED
|
@@ -7,6 +7,9 @@ used throughout the TunaCode codebase.
|
|
|
7
7
|
|
|
8
8
|
from dataclasses import dataclass, field
|
|
9
9
|
from datetime import datetime
|
|
10
|
+
|
|
11
|
+
# Plan types will be defined below
|
|
12
|
+
from enum import Enum
|
|
10
13
|
from pathlib import Path
|
|
11
14
|
from typing import (
|
|
12
15
|
Any,
|
|
@@ -192,6 +195,61 @@ class SimpleResult:
|
|
|
192
195
|
output: str
|
|
193
196
|
|
|
194
197
|
|
|
198
|
+
# =============================================================================
|
|
199
|
+
# Plan Types
|
|
200
|
+
# =============================================================================
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class PlanPhase(Enum):
|
|
204
|
+
"""Plan Mode phases."""
|
|
205
|
+
|
|
206
|
+
PLANNING_RESEARCH = "research"
|
|
207
|
+
PLANNING_DRAFT = "draft"
|
|
208
|
+
PLAN_READY = "ready"
|
|
209
|
+
REVIEW_DECISION = "review"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass
|
|
213
|
+
class PlanDoc:
|
|
214
|
+
"""Structured plan document with all required sections."""
|
|
215
|
+
|
|
216
|
+
# Required sections
|
|
217
|
+
title: str
|
|
218
|
+
overview: str
|
|
219
|
+
steps: List[str]
|
|
220
|
+
files_to_modify: List[str]
|
|
221
|
+
files_to_create: List[str]
|
|
222
|
+
|
|
223
|
+
# Optional but recommended sections
|
|
224
|
+
risks: List[str] = field(default_factory=list)
|
|
225
|
+
tests: List[str] = field(default_factory=list)
|
|
226
|
+
rollback: Optional[str] = None
|
|
227
|
+
open_questions: List[str] = field(default_factory=list)
|
|
228
|
+
success_criteria: List[str] = field(default_factory=list)
|
|
229
|
+
references: List[str] = field(default_factory=list)
|
|
230
|
+
|
|
231
|
+
def validate(self) -> Tuple[bool, List[str]]:
|
|
232
|
+
"""
|
|
233
|
+
Validate the plan document.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
tuple: (is_valid, list_of_missing_sections)
|
|
237
|
+
"""
|
|
238
|
+
missing = []
|
|
239
|
+
|
|
240
|
+
# Check required fields
|
|
241
|
+
if not self.title or not self.title.strip():
|
|
242
|
+
missing.append("title")
|
|
243
|
+
if not self.overview or not self.overview.strip():
|
|
244
|
+
missing.append("overview")
|
|
245
|
+
if not self.steps:
|
|
246
|
+
missing.append("steps")
|
|
247
|
+
if not self.files_to_modify and not self.files_to_create:
|
|
248
|
+
missing.append("files_to_modify or files_to_create")
|
|
249
|
+
|
|
250
|
+
return len(missing) == 0, missing
|
|
251
|
+
|
|
252
|
+
|
|
195
253
|
# =============================================================================
|
|
196
254
|
# Session and State Types
|
|
197
255
|
# =============================================================================
|
tunacode/ui/input.py
CHANGED
|
@@ -76,19 +76,29 @@ async def multiline_input(
|
|
|
76
76
|
) -> str:
|
|
77
77
|
"""Get multiline input from the user with @file completion and highlighting."""
|
|
78
78
|
kb = create_key_bindings(state_manager)
|
|
79
|
+
|
|
80
|
+
# Clear any residual terminal output
|
|
81
|
+
import sys
|
|
82
|
+
|
|
83
|
+
sys.stdout.flush()
|
|
84
|
+
|
|
85
|
+
# Full placeholder with all keyboard shortcuts
|
|
79
86
|
placeholder = formatted_text(
|
|
80
87
|
(
|
|
81
88
|
"<darkgrey>"
|
|
82
89
|
"<bold>Enter</bold> to submit • "
|
|
83
90
|
"<bold>Esc + Enter</bold> for new line • "
|
|
84
91
|
"<bold>Esc twice</bold> to cancel • "
|
|
92
|
+
"<bold>Shift + Tab</bold> toggle plan mode • "
|
|
85
93
|
"<bold>/help</bold> for commands"
|
|
86
94
|
"</darkgrey>"
|
|
87
95
|
)
|
|
88
96
|
)
|
|
89
|
-
|
|
97
|
+
|
|
98
|
+
# Display input area (Plan Mode indicator is handled dynamically in prompt manager)
|
|
99
|
+
result = await input(
|
|
90
100
|
"multiline",
|
|
91
|
-
pretext="> ",
|
|
101
|
+
pretext="> ",
|
|
92
102
|
key_bindings=kb,
|
|
93
103
|
multiline=True,
|
|
94
104
|
placeholder=placeholder,
|
|
@@ -96,3 +106,5 @@ async def multiline_input(
|
|
|
96
106
|
lexer=FileReferenceLexer(),
|
|
97
107
|
state_manager=state_manager,
|
|
98
108
|
)
|
|
109
|
+
|
|
110
|
+
return result
|
tunacode/ui/keybindings.py
CHANGED
|
@@ -30,8 +30,8 @@ def create_key_bindings(state_manager: StateManager = None) -> KeyBindings:
|
|
|
30
30
|
|
|
31
31
|
@kb.add("escape")
|
|
32
32
|
def _escape(event):
|
|
33
|
-
"""Handle ESC key -
|
|
34
|
-
logger.debug("ESC key pressed -
|
|
33
|
+
"""Handle ESC key - trigger Ctrl+C behavior."""
|
|
34
|
+
logger.debug("ESC key pressed - simulating Ctrl+C")
|
|
35
35
|
|
|
36
36
|
# Cancel any active task if present
|
|
37
37
|
if state_manager and hasattr(state_manager.session, "current_task"):
|
|
@@ -44,7 +44,28 @@ def create_key_bindings(state_manager: StateManager = None) -> KeyBindings:
|
|
|
44
44
|
except Exception as e:
|
|
45
45
|
logger.debug(f"Failed to cancel task: {e}")
|
|
46
46
|
|
|
47
|
-
#
|
|
48
|
-
|
|
47
|
+
# Trigger the same behavior as Ctrl+C by sending the signal
|
|
48
|
+
import os
|
|
49
|
+
import signal
|
|
50
|
+
|
|
51
|
+
os.kill(os.getpid(), signal.SIGINT)
|
|
52
|
+
|
|
53
|
+
@kb.add("s-tab") # shift+tab
|
|
54
|
+
def _toggle_plan_mode(event):
|
|
55
|
+
"""Toggle between Plan Mode and normal mode."""
|
|
56
|
+
if state_manager:
|
|
57
|
+
# Toggle the state
|
|
58
|
+
if state_manager.is_plan_mode():
|
|
59
|
+
state_manager.exit_plan_mode()
|
|
60
|
+
logger.debug("Toggled to normal mode via Shift+Tab")
|
|
61
|
+
else:
|
|
62
|
+
state_manager.enter_plan_mode()
|
|
63
|
+
logger.debug("Toggled to Plan Mode via Shift+Tab")
|
|
64
|
+
|
|
65
|
+
# Clear the current buffer and refresh the display
|
|
66
|
+
event.current_buffer.reset()
|
|
67
|
+
|
|
68
|
+
# Force a refresh of the application without exiting
|
|
69
|
+
event.app.invalidate()
|
|
49
70
|
|
|
50
71
|
return kb
|
tunacode/ui/panels.py
CHANGED
|
@@ -100,7 +100,7 @@ class StreamingAgentPanel:
|
|
|
100
100
|
self._last_update_time = 0.0
|
|
101
101
|
self._dots_task = None
|
|
102
102
|
self._dots_count = 0
|
|
103
|
-
self._show_dots =
|
|
103
|
+
self._show_dots = True # Start with dots enabled for "Thinking..."
|
|
104
104
|
|
|
105
105
|
def _create_panel(self) -> Padding:
|
|
106
106
|
"""Create a Rich panel with current content."""
|
|
@@ -111,7 +111,15 @@ class StreamingAgentPanel:
|
|
|
111
111
|
|
|
112
112
|
# Show "Thinking..." only when no content has arrived yet
|
|
113
113
|
if not self.content:
|
|
114
|
-
|
|
114
|
+
# Apply dots animation to "Thinking..." message too
|
|
115
|
+
thinking_msg = UI_THINKING_MESSAGE
|
|
116
|
+
if self._show_dots:
|
|
117
|
+
# Remove the existing ... from the message and add animated dots
|
|
118
|
+
base_msg = thinking_msg.replace("...", "")
|
|
119
|
+
dots_patterns = ["", ".", "..", "..."]
|
|
120
|
+
dots = dots_patterns[self._dots_count % len(dots_patterns)]
|
|
121
|
+
thinking_msg = base_msg + dots
|
|
122
|
+
content_renderable: Union[Text, Markdown] = Text.from_markup(thinking_msg)
|
|
115
123
|
else:
|
|
116
124
|
# Once we have content, show it with optional dots animation
|
|
117
125
|
display_content = self.content
|
|
@@ -143,17 +151,21 @@ class StreamingAgentPanel:
|
|
|
143
151
|
async def _animate_dots(self):
|
|
144
152
|
"""Animate dots after a pause in streaming."""
|
|
145
153
|
while True:
|
|
146
|
-
await asyncio.sleep(0.
|
|
154
|
+
await asyncio.sleep(0.2) # Faster animation cycle
|
|
147
155
|
current_time = time.time()
|
|
148
|
-
#
|
|
149
|
-
if
|
|
156
|
+
# Use shorter delay for initial "Thinking..." phase
|
|
157
|
+
delay_threshold = 0.3 if not self.content else 1.0
|
|
158
|
+
# Show dots after the delay threshold
|
|
159
|
+
if current_time - self._last_update_time > delay_threshold:
|
|
150
160
|
self._show_dots = True
|
|
151
161
|
self._dots_count += 1
|
|
152
162
|
if self.live:
|
|
153
163
|
self.live.update(self._create_panel())
|
|
154
164
|
else:
|
|
155
|
-
|
|
156
|
-
self.
|
|
165
|
+
# Only reset if we have content (keep dots for initial "Thinking...")
|
|
166
|
+
if self.content:
|
|
167
|
+
self._show_dots = False
|
|
168
|
+
self._dots_count = 0
|
|
157
169
|
|
|
158
170
|
async def start(self):
|
|
159
171
|
"""Start the live streaming display."""
|
|
@@ -161,7 +173,11 @@ class StreamingAgentPanel:
|
|
|
161
173
|
|
|
162
174
|
self.live = Live(self._create_panel(), console=console, refresh_per_second=4)
|
|
163
175
|
self.live.start()
|
|
164
|
-
|
|
176
|
+
# For "Thinking...", set time in past to trigger dots immediately
|
|
177
|
+
if not self.content:
|
|
178
|
+
self._last_update_time = time.time() - 0.4 # Triggers dots on first cycle
|
|
179
|
+
else:
|
|
180
|
+
self._last_update_time = time.time()
|
|
165
181
|
# Start the dots animation task
|
|
166
182
|
self._dots_task = asyncio.create_task(self._animate_dots())
|
|
167
183
|
|
|
@@ -170,6 +186,21 @@ class StreamingAgentPanel:
|
|
|
170
186
|
# Defensive: some providers may yield None chunks intermittently
|
|
171
187
|
if content_chunk is None:
|
|
172
188
|
content_chunk = ""
|
|
189
|
+
|
|
190
|
+
# Filter out plan mode system prompts and tool definitions from streaming
|
|
191
|
+
if any(
|
|
192
|
+
phrase in str(content_chunk)
|
|
193
|
+
for phrase in [
|
|
194
|
+
"🔧 PLAN MODE",
|
|
195
|
+
"TOOL EXECUTION ONLY",
|
|
196
|
+
"planning assistant that ONLY communicates",
|
|
197
|
+
"namespace functions {",
|
|
198
|
+
"namespace multi_tool_use {",
|
|
199
|
+
"You are trained on data up to",
|
|
200
|
+
]
|
|
201
|
+
):
|
|
202
|
+
return
|
|
203
|
+
|
|
173
204
|
# Ensure type safety for concatenation
|
|
174
205
|
self.content = (self.content or "") + str(content_chunk)
|
|
175
206
|
|
|
@@ -182,6 +213,20 @@ class StreamingAgentPanel:
|
|
|
182
213
|
|
|
183
214
|
async def set_content(self, content: str):
|
|
184
215
|
"""Set the complete content (overwrites previous)."""
|
|
216
|
+
# Filter out plan mode system prompts and tool definitions
|
|
217
|
+
if any(
|
|
218
|
+
phrase in str(content)
|
|
219
|
+
for phrase in [
|
|
220
|
+
"🔧 PLAN MODE",
|
|
221
|
+
"TOOL EXECUTION ONLY",
|
|
222
|
+
"planning assistant that ONLY communicates",
|
|
223
|
+
"namespace functions {",
|
|
224
|
+
"namespace multi_tool_use {",
|
|
225
|
+
"You are trained on data up to",
|
|
226
|
+
]
|
|
227
|
+
):
|
|
228
|
+
return
|
|
229
|
+
|
|
185
230
|
self.content = content
|
|
186
231
|
if self.live:
|
|
187
232
|
self.live.update(self._create_panel())
|
tunacode/ui/prompt_manager.py
CHANGED
|
@@ -100,15 +100,38 @@ class PromptManager:
|
|
|
100
100
|
"""
|
|
101
101
|
session = self.get_session(session_key, config)
|
|
102
102
|
|
|
103
|
-
# Create a custom prompt that changes based on input
|
|
103
|
+
# Create a custom prompt that changes based on input and plan mode
|
|
104
104
|
def get_prompt():
|
|
105
|
+
# Start with the base prompt
|
|
106
|
+
base_prompt = prompt
|
|
107
|
+
|
|
108
|
+
# Add Plan Mode indicator if active
|
|
109
|
+
if (
|
|
110
|
+
self.state_manager
|
|
111
|
+
and self.state_manager.is_plan_mode()
|
|
112
|
+
and "PLAN MODE ON" not in base_prompt
|
|
113
|
+
):
|
|
114
|
+
base_prompt = (
|
|
115
|
+
'<style fg="#40E0D0"><bold>⏸ PLAN MODE ON</bold></style>\n' + base_prompt
|
|
116
|
+
)
|
|
117
|
+
elif (
|
|
118
|
+
self.state_manager
|
|
119
|
+
and not self.state_manager.is_plan_mode()
|
|
120
|
+
and ("⏸" in base_prompt or "PLAN MODE ON" in base_prompt)
|
|
121
|
+
):
|
|
122
|
+
# Remove plan mode indicator if no longer in plan mode
|
|
123
|
+
lines = base_prompt.split("\n")
|
|
124
|
+
if len(lines) > 1 and ("⏸" in lines[0] or "PLAN MODE ON" in lines[0]):
|
|
125
|
+
base_prompt = "\n".join(lines[1:])
|
|
126
|
+
|
|
105
127
|
# Check if current buffer starts with "!"
|
|
106
128
|
if hasattr(session.app, "current_buffer") and session.app.current_buffer:
|
|
107
129
|
text = session.app.current_buffer.text
|
|
108
130
|
if text.startswith("!"):
|
|
109
131
|
# Use bright yellow background with black text for high visibility
|
|
110
132
|
return HTML('<style bg="#ffcc00" fg="black"><b> ◆ BASH MODE ◆ </b></style> ')
|
|
111
|
-
|
|
133
|
+
|
|
134
|
+
return HTML(base_prompt) if isinstance(base_prompt, str) else base_prompt
|
|
112
135
|
|
|
113
136
|
try:
|
|
114
137
|
# Get user input with dynamic prompt
|
tunacode/ui/tool_ui.py
CHANGED
|
@@ -76,8 +76,9 @@ class ToolUI:
|
|
|
76
76
|
|
|
77
77
|
# Show file content on write_file
|
|
78
78
|
elif tool_name == TOOL_WRITE_FILE:
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
lang = ext_to_lang(args["filepath"])
|
|
80
|
+
code_block = f"```{lang}\n{args['content']}\n```"
|
|
81
|
+
return code_block
|
|
81
82
|
|
|
82
83
|
# Default to showing key and value on new line
|
|
83
84
|
content = ""
|