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.

Files changed (47) hide show
  1. tunacode/cli/commands/implementations/plan.py +50 -0
  2. tunacode/cli/commands/registry.py +3 -0
  3. tunacode/cli/repl.py +327 -186
  4. tunacode/cli/repl_components/command_parser.py +37 -4
  5. tunacode/cli/repl_components/error_recovery.py +79 -1
  6. tunacode/cli/repl_components/output_display.py +21 -1
  7. tunacode/cli/repl_components/tool_executor.py +12 -0
  8. tunacode/configuration/defaults.py +8 -0
  9. tunacode/constants.py +10 -2
  10. tunacode/core/agents/agent_components/agent_config.py +212 -22
  11. tunacode/core/agents/agent_components/node_processor.py +46 -40
  12. tunacode/core/code_index.py +83 -29
  13. tunacode/core/state.py +44 -0
  14. tunacode/core/token_usage/usage_tracker.py +2 -2
  15. tunacode/core/tool_handler.py +20 -0
  16. tunacode/prompts/system.md +117 -490
  17. tunacode/services/mcp.py +29 -7
  18. tunacode/tools/base.py +110 -0
  19. tunacode/tools/bash.py +96 -1
  20. tunacode/tools/exit_plan_mode.py +273 -0
  21. tunacode/tools/glob.py +366 -33
  22. tunacode/tools/grep.py +226 -77
  23. tunacode/tools/grep_components/result_formatter.py +98 -4
  24. tunacode/tools/list_dir.py +132 -2
  25. tunacode/tools/present_plan.py +288 -0
  26. tunacode/tools/read_file.py +91 -0
  27. tunacode/tools/run_command.py +99 -0
  28. tunacode/tools/schema_assembler.py +167 -0
  29. tunacode/tools/todo.py +108 -1
  30. tunacode/tools/update_file.py +94 -0
  31. tunacode/tools/write_file.py +86 -0
  32. tunacode/types.py +58 -0
  33. tunacode/ui/input.py +14 -2
  34. tunacode/ui/keybindings.py +25 -4
  35. tunacode/ui/panels.py +53 -8
  36. tunacode/ui/prompt_manager.py +25 -2
  37. tunacode/ui/tool_ui.py +3 -2
  38. tunacode/utils/json_utils.py +206 -0
  39. tunacode/utils/message_utils.py +14 -4
  40. tunacode/utils/ripgrep.py +332 -9
  41. {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.57.dist-info}/METADATA +8 -3
  42. {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.57.dist-info}/RECORD +46 -42
  43. tunacode/tools/read_file_async_poc.py +0 -196
  44. {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.57.dist-info}/WHEEL +0 -0
  45. {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.57.dist-info}/entry_points.txt +0 -0
  46. {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.57.dist-info}/licenses/LICENSE +0 -0
  47. {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 typing import List, Literal, Optional, Union
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
 
@@ -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
 
@@ -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
- return await input(
97
+
98
+ # Display input area (Plan Mode indicator is handled dynamically in prompt manager)
99
+ result = await input(
90
100
  "multiline",
91
- pretext="> ", # Default prompt
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
@@ -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 - raises KeyboardInterrupt for unified abort handling."""
34
- logger.debug("ESC key pressed - raising KeyboardInterrupt")
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
- # Raise KeyboardInterrupt to trigger unified abort handling in REPL
48
- raise KeyboardInterrupt()
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 = False
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
- content_renderable: Union[Text, Markdown] = Text.from_markup(UI_THINKING_MESSAGE)
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.5)
154
+ await asyncio.sleep(0.2) # Faster animation cycle
147
155
  current_time = time.time()
148
- # Only show dots after 1 second of no updates
149
- if current_time - self._last_update_time > 1.0:
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
- self._show_dots = False
156
- self._dots_count = 0
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
- self._last_update_time = time.time()
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())
@@ -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
- return HTML(prompt) if isinstance(prompt, str) else prompt
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
- markdown_obj = self._create_code_block(args["filepath"], args["content"])
80
- return str(markdown_obj)
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 = ""