tunacode-cli 0.0.56__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 (45) hide show
  1. tunacode/cli/commands/implementations/plan.py +8 -8
  2. tunacode/cli/commands/registry.py +2 -2
  3. tunacode/cli/repl.py +214 -407
  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 +14 -11
  7. tunacode/cli/repl_components/tool_executor.py +7 -4
  8. tunacode/configuration/defaults.py +8 -0
  9. tunacode/constants.py +8 -2
  10. tunacode/core/agents/agent_components/agent_config.py +128 -65
  11. tunacode/core/agents/agent_components/node_processor.py +6 -2
  12. tunacode/core/code_index.py +83 -29
  13. tunacode/core/state.py +1 -1
  14. tunacode/core/token_usage/usage_tracker.py +2 -2
  15. tunacode/core/tool_handler.py +3 -3
  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 +114 -32
  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 +111 -31
  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 +10 -9
  33. tunacode/ui/input.py +1 -0
  34. tunacode/ui/keybindings.py +1 -0
  35. tunacode/ui/panels.py +49 -27
  36. tunacode/ui/prompt_manager.py +13 -7
  37. tunacode/utils/json_utils.py +206 -0
  38. tunacode/utils/ripgrep.py +332 -9
  39. {tunacode_cli-0.0.56.dist-info → tunacode_cli-0.0.57.dist-info}/METADATA +5 -1
  40. {tunacode_cli-0.0.56.dist-info → tunacode_cli-0.0.57.dist-info}/RECORD +44 -43
  41. tunacode/tools/read_file_async_poc.py +0 -196
  42. {tunacode_cli-0.0.56.dist-info → tunacode_cli-0.0.57.dist-info}/WHEEL +0 -0
  43. {tunacode_cli-0.0.56.dist-info → tunacode_cli-0.0.57.dist-info}/entry_points.txt +0 -0
  44. {tunacode_cli-0.0.56.dist-info → tunacode_cli-0.0.57.dist-info}/licenses/LICENSE +0 -0
  45. {tunacode_cli-0.0.56.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,
@@ -21,9 +24,6 @@ from typing import (
21
24
  Union,
22
25
  )
23
26
 
24
- # Plan types will be defined below
25
- from enum import Enum
26
-
27
27
  # Try to import pydantic-ai types if available
28
28
  try:
29
29
  from pydantic_ai import Agent
@@ -202,6 +202,7 @@ class SimpleResult:
202
202
 
203
203
  class PlanPhase(Enum):
204
204
  """Plan Mode phases."""
205
+
205
206
  PLANNING_RESEARCH = "research"
206
207
  PLANNING_DRAFT = "draft"
207
208
  PLAN_READY = "ready"
@@ -211,14 +212,14 @@ class PlanPhase(Enum):
211
212
  @dataclass
212
213
  class PlanDoc:
213
214
  """Structured plan document with all required sections."""
214
-
215
+
215
216
  # Required sections
216
217
  title: str
217
218
  overview: str
218
219
  steps: List[str]
219
220
  files_to_modify: List[str]
220
221
  files_to_create: List[str]
221
-
222
+
222
223
  # Optional but recommended sections
223
224
  risks: List[str] = field(default_factory=list)
224
225
  tests: List[str] = field(default_factory=list)
@@ -226,16 +227,16 @@ class PlanDoc:
226
227
  open_questions: List[str] = field(default_factory=list)
227
228
  success_criteria: List[str] = field(default_factory=list)
228
229
  references: List[str] = field(default_factory=list)
229
-
230
+
230
231
  def validate(self) -> Tuple[bool, List[str]]:
231
232
  """
232
233
  Validate the plan document.
233
-
234
+
234
235
  Returns:
235
236
  tuple: (is_valid, list_of_missing_sections)
236
237
  """
237
238
  missing = []
238
-
239
+
239
240
  # Check required fields
240
241
  if not self.title or not self.title.strip():
241
242
  missing.append("title")
@@ -245,7 +246,7 @@ class PlanDoc:
245
246
  missing.append("steps")
246
247
  if not self.files_to_modify and not self.files_to_create:
247
248
  missing.append("files_to_modify or files_to_create")
248
-
249
+
249
250
  return len(missing) == 0, missing
250
251
 
251
252
 
tunacode/ui/input.py CHANGED
@@ -79,6 +79,7 @@ async def multiline_input(
79
79
 
80
80
  # Clear any residual terminal output
81
81
  import sys
82
+
82
83
  sys.stdout.flush()
83
84
 
84
85
  # Full placeholder with all keyboard shortcuts
@@ -47,6 +47,7 @@ def create_key_bindings(state_manager: StateManager = None) -> KeyBindings:
47
47
  # Trigger the same behavior as Ctrl+C by sending the signal
48
48
  import os
49
49
  import signal
50
+
50
51
  os.kill(os.getpid(), signal.SIGINT)
51
52
 
52
53
  @kb.add("s-tab") # shift+tab
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,18 +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 = ""
173
-
189
+
174
190
  # Filter out plan mode system prompts and tool definitions from streaming
175
- if any(phrase in str(content_chunk) for phrase in [
176
- "🔧 PLAN MODE",
177
- "TOOL EXECUTION ONLY",
178
- "planning assistant that ONLY communicates",
179
- "namespace functions {",
180
- "namespace multi_tool_use {",
181
- "You are trained on data up to"
182
- ]):
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
+ ):
183
202
  return
184
-
203
+
185
204
  # Ensure type safety for concatenation
186
205
  self.content = (self.content or "") + str(content_chunk)
187
206
 
@@ -195,16 +214,19 @@ class StreamingAgentPanel:
195
214
  async def set_content(self, content: str):
196
215
  """Set the complete content (overwrites previous)."""
197
216
  # Filter out plan mode system prompts and tool definitions
198
- if any(phrase in str(content) for phrase in [
199
- "🔧 PLAN MODE",
200
- "TOOL EXECUTION ONLY",
201
- "planning assistant that ONLY communicates",
202
- "namespace functions {",
203
- "namespace multi_tool_use {",
204
- "You are trained on data up to"
205
- ]):
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
+ ):
206
228
  return
207
-
229
+
208
230
  self.content = content
209
231
  if self.live:
210
232
  self.live.update(self._create_panel())
@@ -106,13 +106,19 @@ class PromptManager:
106
106
  base_prompt = prompt
107
107
 
108
108
  # Add Plan Mode indicator if active
109
- if (self.state_manager and
110
- self.state_manager.is_plan_mode() and
111
- "PLAN MODE ON" not in base_prompt):
112
- base_prompt = '<style fg="#40E0D0"><bold>⏸ PLAN MODE ON</bold></style>\n' + base_prompt
113
- elif (self.state_manager and
114
- not self.state_manager.is_plan_mode() and
115
- ("" in base_prompt or "PLAN MODE ON" in base_prompt)):
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
+ ):
116
122
  # Remove plan mode indicator if no longer in plan mode
117
123
  lines = base_prompt.split("\n")
118
124
  if len(lines) > 1 and ("⏸" in lines[0] or "PLAN MODE ON" in lines[0]):