tunacode-cli 0.1.21__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 (174) hide show
  1. tunacode/__init__.py +0 -0
  2. tunacode/cli/textual_repl.tcss +283 -0
  3. tunacode/configuration/__init__.py +1 -0
  4. tunacode/configuration/defaults.py +45 -0
  5. tunacode/configuration/models.py +147 -0
  6. tunacode/configuration/models_registry.json +1 -0
  7. tunacode/configuration/pricing.py +74 -0
  8. tunacode/configuration/settings.py +35 -0
  9. tunacode/constants.py +227 -0
  10. tunacode/core/__init__.py +6 -0
  11. tunacode/core/agents/__init__.py +39 -0
  12. tunacode/core/agents/agent_components/__init__.py +48 -0
  13. tunacode/core/agents/agent_components/agent_config.py +441 -0
  14. tunacode/core/agents/agent_components/agent_helpers.py +290 -0
  15. tunacode/core/agents/agent_components/message_handler.py +99 -0
  16. tunacode/core/agents/agent_components/node_processor.py +477 -0
  17. tunacode/core/agents/agent_components/response_state.py +129 -0
  18. tunacode/core/agents/agent_components/result_wrapper.py +51 -0
  19. tunacode/core/agents/agent_components/state_transition.py +112 -0
  20. tunacode/core/agents/agent_components/streaming.py +271 -0
  21. tunacode/core/agents/agent_components/task_completion.py +40 -0
  22. tunacode/core/agents/agent_components/tool_buffer.py +44 -0
  23. tunacode/core/agents/agent_components/tool_executor.py +101 -0
  24. tunacode/core/agents/agent_components/truncation_checker.py +37 -0
  25. tunacode/core/agents/delegation_tools.py +109 -0
  26. tunacode/core/agents/main.py +545 -0
  27. tunacode/core/agents/prompts.py +66 -0
  28. tunacode/core/agents/research_agent.py +231 -0
  29. tunacode/core/compaction.py +218 -0
  30. tunacode/core/prompting/__init__.py +27 -0
  31. tunacode/core/prompting/loader.py +66 -0
  32. tunacode/core/prompting/prompting_engine.py +98 -0
  33. tunacode/core/prompting/sections.py +50 -0
  34. tunacode/core/prompting/templates.py +69 -0
  35. tunacode/core/state.py +409 -0
  36. tunacode/exceptions.py +313 -0
  37. tunacode/indexing/__init__.py +5 -0
  38. tunacode/indexing/code_index.py +432 -0
  39. tunacode/indexing/constants.py +86 -0
  40. tunacode/lsp/__init__.py +112 -0
  41. tunacode/lsp/client.py +351 -0
  42. tunacode/lsp/diagnostics.py +19 -0
  43. tunacode/lsp/servers.py +101 -0
  44. tunacode/prompts/default_prompt.md +952 -0
  45. tunacode/prompts/research/sections/agent_role.xml +5 -0
  46. tunacode/prompts/research/sections/constraints.xml +14 -0
  47. tunacode/prompts/research/sections/output_format.xml +57 -0
  48. tunacode/prompts/research/sections/tool_use.xml +23 -0
  49. tunacode/prompts/sections/advanced_patterns.xml +255 -0
  50. tunacode/prompts/sections/agent_role.xml +8 -0
  51. tunacode/prompts/sections/completion.xml +10 -0
  52. tunacode/prompts/sections/critical_rules.xml +37 -0
  53. tunacode/prompts/sections/examples.xml +220 -0
  54. tunacode/prompts/sections/output_style.xml +94 -0
  55. tunacode/prompts/sections/parallel_exec.xml +105 -0
  56. tunacode/prompts/sections/search_pattern.xml +100 -0
  57. tunacode/prompts/sections/system_info.xml +6 -0
  58. tunacode/prompts/sections/tool_use.xml +84 -0
  59. tunacode/prompts/sections/user_instructions.xml +3 -0
  60. tunacode/py.typed +0 -0
  61. tunacode/templates/__init__.py +5 -0
  62. tunacode/templates/loader.py +15 -0
  63. tunacode/tools/__init__.py +10 -0
  64. tunacode/tools/authorization/__init__.py +29 -0
  65. tunacode/tools/authorization/context.py +32 -0
  66. tunacode/tools/authorization/factory.py +20 -0
  67. tunacode/tools/authorization/handler.py +58 -0
  68. tunacode/tools/authorization/notifier.py +35 -0
  69. tunacode/tools/authorization/policy.py +19 -0
  70. tunacode/tools/authorization/requests.py +119 -0
  71. tunacode/tools/authorization/rules.py +72 -0
  72. tunacode/tools/bash.py +222 -0
  73. tunacode/tools/decorators.py +213 -0
  74. tunacode/tools/glob.py +353 -0
  75. tunacode/tools/grep.py +468 -0
  76. tunacode/tools/grep_components/__init__.py +9 -0
  77. tunacode/tools/grep_components/file_filter.py +93 -0
  78. tunacode/tools/grep_components/pattern_matcher.py +158 -0
  79. tunacode/tools/grep_components/result_formatter.py +87 -0
  80. tunacode/tools/grep_components/search_result.py +34 -0
  81. tunacode/tools/list_dir.py +205 -0
  82. tunacode/tools/prompts/bash_prompt.xml +10 -0
  83. tunacode/tools/prompts/glob_prompt.xml +7 -0
  84. tunacode/tools/prompts/grep_prompt.xml +10 -0
  85. tunacode/tools/prompts/list_dir_prompt.xml +7 -0
  86. tunacode/tools/prompts/read_file_prompt.xml +9 -0
  87. tunacode/tools/prompts/todoclear_prompt.xml +12 -0
  88. tunacode/tools/prompts/todoread_prompt.xml +16 -0
  89. tunacode/tools/prompts/todowrite_prompt.xml +28 -0
  90. tunacode/tools/prompts/update_file_prompt.xml +9 -0
  91. tunacode/tools/prompts/web_fetch_prompt.xml +11 -0
  92. tunacode/tools/prompts/write_file_prompt.xml +7 -0
  93. tunacode/tools/react.py +111 -0
  94. tunacode/tools/read_file.py +68 -0
  95. tunacode/tools/todo.py +222 -0
  96. tunacode/tools/update_file.py +62 -0
  97. tunacode/tools/utils/__init__.py +1 -0
  98. tunacode/tools/utils/ripgrep.py +311 -0
  99. tunacode/tools/utils/text_match.py +352 -0
  100. tunacode/tools/web_fetch.py +245 -0
  101. tunacode/tools/write_file.py +34 -0
  102. tunacode/tools/xml_helper.py +34 -0
  103. tunacode/types/__init__.py +166 -0
  104. tunacode/types/base.py +94 -0
  105. tunacode/types/callbacks.py +53 -0
  106. tunacode/types/dataclasses.py +121 -0
  107. tunacode/types/pydantic_ai.py +31 -0
  108. tunacode/types/state.py +122 -0
  109. tunacode/ui/__init__.py +6 -0
  110. tunacode/ui/app.py +542 -0
  111. tunacode/ui/commands/__init__.py +430 -0
  112. tunacode/ui/components/__init__.py +1 -0
  113. tunacode/ui/headless/__init__.py +5 -0
  114. tunacode/ui/headless/output.py +72 -0
  115. tunacode/ui/main.py +252 -0
  116. tunacode/ui/renderers/__init__.py +41 -0
  117. tunacode/ui/renderers/errors.py +197 -0
  118. tunacode/ui/renderers/panels.py +550 -0
  119. tunacode/ui/renderers/search.py +314 -0
  120. tunacode/ui/renderers/tools/__init__.py +21 -0
  121. tunacode/ui/renderers/tools/bash.py +247 -0
  122. tunacode/ui/renderers/tools/diagnostics.py +186 -0
  123. tunacode/ui/renderers/tools/glob.py +226 -0
  124. tunacode/ui/renderers/tools/grep.py +228 -0
  125. tunacode/ui/renderers/tools/list_dir.py +198 -0
  126. tunacode/ui/renderers/tools/read_file.py +226 -0
  127. tunacode/ui/renderers/tools/research.py +294 -0
  128. tunacode/ui/renderers/tools/update_file.py +237 -0
  129. tunacode/ui/renderers/tools/web_fetch.py +182 -0
  130. tunacode/ui/repl_support.py +226 -0
  131. tunacode/ui/screens/__init__.py +16 -0
  132. tunacode/ui/screens/model_picker.py +303 -0
  133. tunacode/ui/screens/session_picker.py +181 -0
  134. tunacode/ui/screens/setup.py +218 -0
  135. tunacode/ui/screens/theme_picker.py +90 -0
  136. tunacode/ui/screens/update_confirm.py +69 -0
  137. tunacode/ui/shell_runner.py +129 -0
  138. tunacode/ui/styles/layout.tcss +98 -0
  139. tunacode/ui/styles/modals.tcss +38 -0
  140. tunacode/ui/styles/panels.tcss +81 -0
  141. tunacode/ui/styles/theme-nextstep.tcss +303 -0
  142. tunacode/ui/styles/widgets.tcss +33 -0
  143. tunacode/ui/styles.py +18 -0
  144. tunacode/ui/widgets/__init__.py +23 -0
  145. tunacode/ui/widgets/command_autocomplete.py +62 -0
  146. tunacode/ui/widgets/editor.py +402 -0
  147. tunacode/ui/widgets/file_autocomplete.py +47 -0
  148. tunacode/ui/widgets/messages.py +46 -0
  149. tunacode/ui/widgets/resource_bar.py +182 -0
  150. tunacode/ui/widgets/status_bar.py +98 -0
  151. tunacode/utils/__init__.py +0 -0
  152. tunacode/utils/config/__init__.py +13 -0
  153. tunacode/utils/config/user_configuration.py +91 -0
  154. tunacode/utils/messaging/__init__.py +10 -0
  155. tunacode/utils/messaging/message_utils.py +34 -0
  156. tunacode/utils/messaging/token_counter.py +77 -0
  157. tunacode/utils/parsing/__init__.py +13 -0
  158. tunacode/utils/parsing/command_parser.py +55 -0
  159. tunacode/utils/parsing/json_utils.py +188 -0
  160. tunacode/utils/parsing/retry.py +146 -0
  161. tunacode/utils/parsing/tool_parser.py +267 -0
  162. tunacode/utils/security/__init__.py +15 -0
  163. tunacode/utils/security/command.py +106 -0
  164. tunacode/utils/system/__init__.py +25 -0
  165. tunacode/utils/system/gitignore.py +155 -0
  166. tunacode/utils/system/paths.py +190 -0
  167. tunacode/utils/ui/__init__.py +9 -0
  168. tunacode/utils/ui/file_filter.py +135 -0
  169. tunacode/utils/ui/helpers.py +24 -0
  170. tunacode_cli-0.1.21.dist-info/METADATA +170 -0
  171. tunacode_cli-0.1.21.dist-info/RECORD +174 -0
  172. tunacode_cli-0.1.21.dist-info/WHEEL +4 -0
  173. tunacode_cli-0.1.21.dist-info/entry_points.txt +2 -0
  174. tunacode_cli-0.1.21.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,111 @@
1
+ """Lightweight ReAct-style scratchpad tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any, Literal
7
+
8
+ from pydantic_ai.exceptions import ModelRetry
9
+
10
+ from tunacode.core.state import StateManager
11
+
12
+
13
+ def create_react_tool(state_manager: StateManager) -> Callable:
14
+ """Factory to create a react tool bound to a state manager.
15
+
16
+ Args:
17
+ state_manager: The state manager instance to use.
18
+
19
+ Returns:
20
+ An async function that implements the react tool.
21
+ """
22
+
23
+ async def react(
24
+ action: Literal["think", "observe", "get", "clear"],
25
+ thoughts: str | None = None,
26
+ next_action: str | None = None,
27
+ result: str | None = None,
28
+ ) -> str:
29
+ """ReAct scratchpad for tracking think/observe steps.
30
+
31
+ Args:
32
+ action: The action to perform (think/observe/get/clear).
33
+ thoughts: Thought content for think action.
34
+ next_action: Planned next action for think action.
35
+ result: Observation message for observe action.
36
+
37
+ Returns:
38
+ Status message or scratchpad contents.
39
+ """
40
+ scratchpad = state_manager.get_react_scratchpad()
41
+ scratchpad.setdefault("timeline", [])
42
+
43
+ if action == "think":
44
+ if not thoughts:
45
+ raise ModelRetry("Provide thoughts when using react think action")
46
+ if not next_action:
47
+ raise ModelRetry("Specify next_action when recording react thoughts")
48
+
49
+ entry = {"type": "think", "thoughts": thoughts, "next_action": next_action}
50
+ state_manager.append_react_entry(entry)
51
+ return "Recorded think step"
52
+
53
+ if action == "observe":
54
+ if not result:
55
+ raise ModelRetry("Provide result when using react observe action")
56
+
57
+ entry = {"type": "observe", "result": result}
58
+ state_manager.append_react_entry(entry)
59
+ return "Recorded observation"
60
+
61
+ if action == "get":
62
+ timeline = scratchpad.get("timeline", [])
63
+ if not timeline:
64
+ return "React scratchpad is empty"
65
+
66
+ formatted = [
67
+ f"{i + 1}. {item['type']}: {_format_entry(item)}" for i, item in enumerate(timeline)
68
+ ]
69
+ return "\n".join(formatted)
70
+
71
+ if action == "clear":
72
+ state_manager.clear_react_scratchpad()
73
+ return "React scratchpad cleared"
74
+
75
+ raise ModelRetry("Invalid react action. Use one of: think, observe, get, clear")
76
+
77
+ return react
78
+
79
+
80
+ def _format_entry(item: dict[str, Any]) -> str:
81
+ """Format a scratchpad entry for display."""
82
+ if item["type"] == "think":
83
+ return f"thoughts='{item['thoughts']}', next_action='{item['next_action']}'"
84
+ if item["type"] == "observe":
85
+ return f"result='{item['result']}'"
86
+ return str(item)
87
+
88
+
89
+ # Backwards compatibility: ReactTool class wrapper
90
+ class ReactTool:
91
+ """Wrapper class for backwards compatibility with existing code."""
92
+
93
+ def __init__(self, state_manager: StateManager) -> None:
94
+ self.state_manager = state_manager
95
+ self._react = create_react_tool(state_manager)
96
+
97
+ @property
98
+ def tool_name(self) -> str:
99
+ return "react"
100
+
101
+ async def execute(
102
+ self,
103
+ action: Literal["think", "observe", "get", "clear"],
104
+ thoughts: str | None = None,
105
+ next_action: str | None = None,
106
+ result: str | None = None,
107
+ ) -> str:
108
+ """Execute the react tool."""
109
+ return await self._react(
110
+ action=action, thoughts=thoughts, next_action=next_action, result=result
111
+ )
@@ -0,0 +1,68 @@
1
+ """File reading tool for agent operations."""
2
+
3
+ import asyncio
4
+ import os
5
+
6
+ from tunacode.constants import (
7
+ DEFAULT_READ_LIMIT,
8
+ ERROR_FILE_TOO_LARGE,
9
+ MAX_FILE_SIZE,
10
+ MAX_LINE_LENGTH,
11
+ MSG_FILE_SIZE_LIMIT,
12
+ )
13
+ from tunacode.exceptions import ToolExecutionError
14
+ from tunacode.tools.decorators import file_tool
15
+
16
+
17
+ @file_tool
18
+ async def read_file(
19
+ filepath: str,
20
+ offset: int = 0,
21
+ limit: int | None = None,
22
+ ) -> str:
23
+ """Read the contents of a file with line limiting and truncation.
24
+
25
+ Args:
26
+ filepath: The absolute path to the file to read.
27
+ offset: The line number to start reading from (0-based). Defaults to 0.
28
+ limit: The number of lines to read. Defaults to DEFAULT_READ_LIMIT (2000).
29
+
30
+ Returns:
31
+ The formatted file contents with line numbers.
32
+ """
33
+ if os.path.getsize(filepath) > MAX_FILE_SIZE:
34
+ raise ToolExecutionError(
35
+ tool_name="read_file",
36
+ message=ERROR_FILE_TOO_LARGE.format(filepath=filepath) + MSG_FILE_SIZE_LIMIT,
37
+ )
38
+
39
+ effective_limit = limit if limit is not None else DEFAULT_READ_LIMIT
40
+
41
+ def _read_sync(path: str) -> str:
42
+ with open(path, encoding="utf-8") as f:
43
+ lines = f.readlines()
44
+
45
+ total_lines = len(lines)
46
+ raw = lines[offset : offset + effective_limit]
47
+
48
+ content_lines = []
49
+ for i, line in enumerate(raw):
50
+ line = line.rstrip("\n")
51
+ if len(line) > MAX_LINE_LENGTH:
52
+ line = line[:MAX_LINE_LENGTH] + "..."
53
+ line_num = str(i + offset + 1).zfill(5)
54
+ content_lines.append(f"{line_num}| {line}")
55
+
56
+ output = "<file>\n"
57
+ output += "\n".join(content_lines)
58
+
59
+ last_line = offset + len(content_lines)
60
+ if total_lines > last_line:
61
+ output += f"\n\n(File has more lines. Use 'offset' to read beyond line {last_line})"
62
+ else:
63
+ output += f"\n\n(End of file - total {total_lines} lines)"
64
+ output += "\n</file>"
65
+
66
+ return output
67
+
68
+ return await asyncio.to_thread(_read_sync, filepath)
tunacode/tools/todo.py ADDED
@@ -0,0 +1,222 @@
1
+ """Todo list tools for tracking task progress during complex operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ from pydantic_ai.exceptions import ModelRetry
9
+
10
+ from tunacode.core.state import StateManager
11
+ from tunacode.tools.xml_helper import load_prompt_from_xml
12
+
13
+ # Heavily yoinked from https://github.com/sst/opencode/blob/dev/packages/opencode/src/tool/todo.ts
14
+ # and adapted for python.
15
+
16
+ TODO_FIELD_ACTIVE_FORM = "activeForm"
17
+ TODO_FIELD_CONTENT = "content"
18
+ TODO_FIELD_STATUS = "status"
19
+
20
+ TODO_STATUS_COMPLETED = "completed"
21
+ TODO_STATUS_IN_PROGRESS = "in_progress"
22
+ TODO_STATUS_PENDING = "pending"
23
+
24
+ MAX_IN_PROGRESS_TODOS = 1
25
+ NO_TODOS_MESSAGE = "No todos in list."
26
+ TODO_LIST_CLEARED_MESSAGE = "Todo list cleared."
27
+
28
+ # Valid status values
29
+ VALID_STATUSES = frozenset({TODO_STATUS_PENDING, TODO_STATUS_IN_PROGRESS, TODO_STATUS_COMPLETED})
30
+
31
+ # Status display symbols
32
+ STATUS_SYMBOLS = {
33
+ TODO_STATUS_PENDING: "[ ]",
34
+ TODO_STATUS_IN_PROGRESS: "[>]",
35
+ TODO_STATUS_COMPLETED: "[x]",
36
+ }
37
+
38
+
39
+ def _validate_todo(todo: Any, index: int) -> dict[str, Any]:
40
+ """Validate a single todo item has required fields.
41
+
42
+ Args:
43
+ todo: The todo dictionary to validate.
44
+ index: The index of the todo in the list (for error messages).
45
+
46
+ Raises:
47
+ ModelRetry: If validation fails.
48
+ """
49
+ if not isinstance(todo, dict):
50
+ raise ModelRetry(f"Todo at index {index} must be a dictionary, got {type(todo).__name__}")
51
+
52
+ required_fields = (TODO_FIELD_CONTENT, TODO_FIELD_STATUS, TODO_FIELD_ACTIVE_FORM)
53
+ missing = [f for f in required_fields if f not in todo]
54
+ if missing:
55
+ raise ModelRetry(f"Todo at index {index} missing required fields: {', '.join(missing)}")
56
+
57
+ status = todo[TODO_FIELD_STATUS]
58
+ if not isinstance(status, str) or not status:
59
+ raise ModelRetry(f"Todo at index {index} must have non-empty string '{TODO_FIELD_STATUS}'")
60
+ if status not in VALID_STATUSES:
61
+ raise ModelRetry(
62
+ f"Todo at index {index} has invalid status '{status}'. "
63
+ f"Must be one of: {', '.join(sorted(VALID_STATUSES))}"
64
+ )
65
+
66
+ content = todo[TODO_FIELD_CONTENT]
67
+ if not isinstance(content, str) or not content:
68
+ raise ModelRetry(f"Todo at index {index} must have non-empty string '{TODO_FIELD_CONTENT}'")
69
+
70
+ active_form = todo[TODO_FIELD_ACTIVE_FORM]
71
+ if not isinstance(active_form, str) or not active_form:
72
+ raise ModelRetry(
73
+ f"Todo at index {index} must have non-empty string '{TODO_FIELD_ACTIVE_FORM}'"
74
+ )
75
+
76
+ return todo
77
+
78
+
79
+ def _validate_todos(todos: Any) -> list[dict[str, Any]]:
80
+ if not isinstance(todos, list):
81
+ raise ModelRetry(f"todos must be a list, got {type(todos).__name__}")
82
+
83
+ validated = [_validate_todo(todo, index) for index, todo in enumerate(todos)]
84
+
85
+ in_progress_count = sum(
86
+ 1 for todo in validated if todo[TODO_FIELD_STATUS] == TODO_STATUS_IN_PROGRESS
87
+ )
88
+ if in_progress_count > MAX_IN_PROGRESS_TODOS:
89
+ raise ModelRetry(
90
+ f"Only {MAX_IN_PROGRESS_TODOS} todo may be '{TODO_STATUS_IN_PROGRESS}' at a time, "
91
+ f"got {in_progress_count}"
92
+ )
93
+
94
+ return validated
95
+
96
+
97
+ def _format_todos(todos: list[dict[str, Any]]) -> str:
98
+ """Format todos for display output.
99
+
100
+ Args:
101
+ todos: List of todo dictionaries.
102
+
103
+ Returns:
104
+ Formatted string representation of the todo list.
105
+ """
106
+ if not todos:
107
+ return NO_TODOS_MESSAGE
108
+
109
+ lines = []
110
+ for i, todo in enumerate(todos, 1):
111
+ status = todo[TODO_FIELD_STATUS]
112
+ symbol = STATUS_SYMBOLS[status]
113
+ content = todo[TODO_FIELD_CONTENT]
114
+ active_form = todo[TODO_FIELD_ACTIVE_FORM]
115
+
116
+ if status == TODO_STATUS_IN_PROGRESS:
117
+ lines.append(f"{i}. {symbol} {content} ({active_form})")
118
+ else:
119
+ lines.append(f"{i}. {symbol} {content}")
120
+
121
+ return "\n".join(lines)
122
+
123
+
124
+ def create_todowrite_tool(state_manager: StateManager) -> Callable:
125
+ # Heavily yoinked from https://github.com/sst/opencode/blob/dev/packages/opencode/src/tool/todo.ts
126
+ # and adapted for python.
127
+ """Factory to create a todowrite tool bound to a state manager.
128
+
129
+ Args:
130
+ state_manager: The state manager instance to use.
131
+
132
+ Returns:
133
+ An async function that implements the todowrite tool.
134
+ """
135
+
136
+ async def todowrite(todos: list[dict[str, Any]]) -> str:
137
+ """Create or update the todo list for tracking task progress.
138
+
139
+ Use this tool to manage and display tasks during complex multi-step operations.
140
+ The entire todo list is replaced with each call.
141
+
142
+ Args:
143
+ todos: List of todo items. Each item must have:
144
+ - content: Task description in imperative form (e.g., "Fix the bug")
145
+ - status: One of "pending", "in_progress", or "completed"
146
+ - activeForm: Present continuous form for display (e.g., "Fixing the bug")
147
+
148
+ Returns:
149
+ Formatted display of the updated todo list.
150
+ """
151
+ validated = _validate_todos(todos)
152
+
153
+ state_manager.set_todos(validated)
154
+ return _format_todos(validated)
155
+
156
+ # Load prompt from XML if available
157
+ prompt = load_prompt_from_xml("todowrite")
158
+ if prompt:
159
+ todowrite.__doc__ = prompt
160
+
161
+ return todowrite
162
+
163
+
164
+ def create_todoread_tool(state_manager: StateManager) -> Callable:
165
+ # Heavily yoinked from https://github.com/sst/opencode/blob/dev/packages/opencode/src/tool/todo.ts
166
+ # and adapted for python.
167
+ """Factory to create a todoread tool bound to a state manager.
168
+
169
+ Args:
170
+ state_manager: The state manager instance to use.
171
+
172
+ Returns:
173
+ An async function that implements the todoread tool.
174
+ """
175
+
176
+ async def todoread() -> str:
177
+ """Read the current todo list.
178
+
179
+ Use this tool to check the current state of all tasks.
180
+
181
+ Returns:
182
+ Formatted display of the current todo list, or a message if empty.
183
+ """
184
+ todos = state_manager.get_todos()
185
+ validated = _validate_todos(todos)
186
+ return _format_todos(validated)
187
+
188
+ # Load prompt from XML if available
189
+ prompt = load_prompt_from_xml("todoread")
190
+ if prompt:
191
+ todoread.__doc__ = prompt
192
+
193
+ return todoread
194
+
195
+
196
+ def create_todoclear_tool(state_manager: StateManager) -> Callable:
197
+ """Factory to create a todoclear tool bound to a state manager.
198
+
199
+ Args:
200
+ state_manager: The state manager instance to use.
201
+
202
+ Returns:
203
+ An async function that implements the todoclear tool.
204
+ """
205
+
206
+ async def todoclear() -> str:
207
+ """Clear the entire todo list.
208
+
209
+ Use this tool when starting fresh or when all tasks are complete.
210
+
211
+ Returns:
212
+ Confirmation message.
213
+ """
214
+ state_manager.clear_todos()
215
+ return TODO_LIST_CLEARED_MESSAGE
216
+
217
+ # Load prompt from XML if available
218
+ prompt = load_prompt_from_xml("todoclear")
219
+ if prompt:
220
+ todoclear.__doc__ = prompt
221
+
222
+ return todoclear
@@ -0,0 +1,62 @@
1
+ """File update tool for agent operations."""
2
+
3
+ import difflib
4
+ import os
5
+
6
+ from pydantic_ai.exceptions import ModelRetry
7
+
8
+ from tunacode.tools.decorators import file_tool
9
+ from tunacode.tools.utils.text_match import replace
10
+
11
+
12
+ @file_tool(writes=True)
13
+ async def update_file(filepath: str, old_text: str, new_text: str) -> str:
14
+ """Update an existing file by replacing old_text with new_text.
15
+
16
+ Args:
17
+ filepath: The path to the file to update.
18
+ old_text: The entire, exact block of text to be replaced.
19
+ new_text: The new block of text to insert.
20
+
21
+ Returns:
22
+ A message indicating success and the diff of changes.
23
+ """
24
+ if not os.path.exists(filepath):
25
+ raise ModelRetry(
26
+ f"File '{filepath}' not found. Cannot update. "
27
+ "Verify the filepath or use `write_file` if it's a new file."
28
+ )
29
+
30
+ with open(filepath, encoding="utf-8") as f:
31
+ original = f.read()
32
+
33
+ try:
34
+ new_content = replace(original, old_text, new_text, replace_all=False)
35
+ except ValueError as e:
36
+ lines = original.splitlines()
37
+ preview_lines = min(20, len(lines))
38
+ snippet = "\n".join(lines[:preview_lines])
39
+ raise ModelRetry(
40
+ f"{e}\n\nFile '{filepath}' preview ({preview_lines} lines):\n---\n{snippet}\n---"
41
+ ) from e
42
+
43
+ if original == new_content:
44
+ raise ModelRetry(
45
+ f"Update old_text found, but replacement resulted in no changes to '{filepath}'. "
46
+ "Was the `old_text` identical to the `new_text`? Please check the file content."
47
+ )
48
+
49
+ with open(filepath, "w", encoding="utf-8") as f:
50
+ f.write(new_content)
51
+
52
+ diff_lines = list(
53
+ difflib.unified_diff(
54
+ original.splitlines(keepends=True),
55
+ new_content.splitlines(keepends=True),
56
+ fromfile=f"a/{filepath}",
57
+ tofile=f"b/{filepath}",
58
+ )
59
+ )
60
+ diff_text = "".join(diff_lines)
61
+
62
+ return f"File '{filepath}' updated successfully.\n\n{diff_text}"
@@ -0,0 +1 @@
1
+ """Utility modules for tools."""