teddy-cli 0.1.0__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.
- teddy_cli-0.1.0.dist-info/LICENSE +677 -0
- teddy_cli-0.1.0.dist-info/METADATA +33 -0
- teddy_cli-0.1.0.dist-info/RECORD +143 -0
- teddy_cli-0.1.0.dist-info/WHEEL +4 -0
- teddy_cli-0.1.0.dist-info/entry_points.txt +3 -0
- teddy_executor/__init__.py +1 -0
- teddy_executor/__main__.py +335 -0
- teddy_executor/adapters/__init__.py +0 -0
- teddy_executor/adapters/inbound/__init__.py +0 -0
- teddy_executor/adapters/inbound/cli_formatter.py +107 -0
- teddy_executor/adapters/inbound/cli_helpers.py +249 -0
- teddy_executor/adapters/inbound/console_plan_reviewer.py +69 -0
- teddy_executor/adapters/inbound/session_cli_handlers.py +366 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer.py +78 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_app.py +367 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_editor.py +281 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_execution.py +213 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_helpers.py +308 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_logic.py +345 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_previews.py +227 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_widgets.py +246 -0
- teddy_executor/adapters/outbound/__init__.py +7 -0
- teddy_executor/adapters/outbound/console_interactor.py +212 -0
- teddy_executor/adapters/outbound/console_interactor_ask_loop.py +121 -0
- teddy_executor/adapters/outbound/console_interactor_helpers.py +95 -0
- teddy_executor/adapters/outbound/console_tooling.py +62 -0
- teddy_executor/adapters/outbound/filesystem_helpers.py +61 -0
- teddy_executor/adapters/outbound/litellm_adapter.py +462 -0
- teddy_executor/adapters/outbound/local_file_system_adapter.py +300 -0
- teddy_executor/adapters/outbound/local_repo_tree_generator.py +96 -0
- teddy_executor/adapters/outbound/openrouter_hydrator.py +89 -0
- teddy_executor/adapters/outbound/shell_adapter.py +344 -0
- teddy_executor/adapters/outbound/shell_command_builder.py +105 -0
- teddy_executor/adapters/outbound/system_environment_adapter.py +62 -0
- teddy_executor/adapters/outbound/system_environment_inspector.py +54 -0
- teddy_executor/adapters/outbound/system_time_adapter.py +22 -0
- teddy_executor/adapters/outbound/web_scraper_adapter.py +346 -0
- teddy_executor/adapters/outbound/web_searcher_adapter.py +122 -0
- teddy_executor/adapters/outbound/yaml_config_adapter.py +105 -0
- teddy_executor/container.py +333 -0
- teddy_executor/core/__init__.py +0 -0
- teddy_executor/core/domain/__init__.py +0 -0
- teddy_executor/core/domain/models/__init__.py +44 -0
- teddy_executor/core/domain/models/action_ports.py +28 -0
- teddy_executor/core/domain/models/change_set.py +10 -0
- teddy_executor/core/domain/models/exceptions.py +40 -0
- teddy_executor/core/domain/models/execution_report.py +65 -0
- teddy_executor/core/domain/models/orchestrator_ports.py +26 -0
- teddy_executor/core/domain/models/plan.py +85 -0
- teddy_executor/core/domain/models/planning_ports.py +43 -0
- teddy_executor/core/domain/models/project_context.py +56 -0
- teddy_executor/core/domain/models/report_assembly_data.py +18 -0
- teddy_executor/core/domain/models/session.py +17 -0
- teddy_executor/core/domain/models/shell_output.py +12 -0
- teddy_executor/core/domain/models/web_search_results.py +26 -0
- teddy_executor/core/ports/__init__.py +0 -0
- teddy_executor/core/ports/inbound/__init__.py +0 -0
- teddy_executor/core/ports/inbound/edit_simulator.py +33 -0
- teddy_executor/core/ports/inbound/get_context_use_case.py +32 -0
- teddy_executor/core/ports/inbound/init.py +15 -0
- teddy_executor/core/ports/inbound/plan_parser.py +52 -0
- teddy_executor/core/ports/inbound/plan_reviewer.py +44 -0
- teddy_executor/core/ports/inbound/plan_validator.py +26 -0
- teddy_executor/core/ports/inbound/planning_use_case.py +30 -0
- teddy_executor/core/ports/inbound/run_plan_use_case.py +60 -0
- teddy_executor/core/ports/outbound/__init__.py +34 -0
- teddy_executor/core/ports/outbound/config_service.py +29 -0
- teddy_executor/core/ports/outbound/environment_inspector.py +30 -0
- teddy_executor/core/ports/outbound/execution_report_assembler.py +19 -0
- teddy_executor/core/ports/outbound/file_system_manager.py +131 -0
- teddy_executor/core/ports/outbound/llm_client.py +90 -0
- teddy_executor/core/ports/outbound/markdown_report_formatter.py +26 -0
- teddy_executor/core/ports/outbound/prompt_manager.py +55 -0
- teddy_executor/core/ports/outbound/repo_tree_generator.py +17 -0
- teddy_executor/core/ports/outbound/session_loop_guard.py +16 -0
- teddy_executor/core/ports/outbound/session_manager.py +97 -0
- teddy_executor/core/ports/outbound/session_repository.py +65 -0
- teddy_executor/core/ports/outbound/shell_executor.py +24 -0
- teddy_executor/core/ports/outbound/system_environment.py +25 -0
- teddy_executor/core/ports/outbound/time_service.py +28 -0
- teddy_executor/core/ports/outbound/user_interactor.py +126 -0
- teddy_executor/core/ports/outbound/web_scraper.py +24 -0
- teddy_executor/core/ports/outbound/web_searcher.py +25 -0
- teddy_executor/core/services/__init__.py +0 -0
- teddy_executor/core/services/action_changeset_builder.py +90 -0
- teddy_executor/core/services/action_diff_manager.py +110 -0
- teddy_executor/core/services/action_dispatcher.py +142 -0
- teddy_executor/core/services/action_executor.py +209 -0
- teddy_executor/core/services/action_factory.py +197 -0
- teddy_executor/core/services/action_parser_complex.py +216 -0
- teddy_executor/core/services/action_parser_strategies.py +84 -0
- teddy_executor/core/services/context_service.py +437 -0
- teddy_executor/core/services/edit_simulator.py +128 -0
- teddy_executor/core/services/execution_orchestrator.py +295 -0
- teddy_executor/core/services/execution_report_assembler.py +62 -0
- teddy_executor/core/services/init_service.py +80 -0
- teddy_executor/core/services/markdown_plan_parser.py +309 -0
- teddy_executor/core/services/markdown_report_formatter.py +143 -0
- teddy_executor/core/services/parser_infrastructure.py +222 -0
- teddy_executor/core/services/parser_metadata.py +153 -0
- teddy_executor/core/services/parser_reporting.py +267 -0
- teddy_executor/core/services/plan_validator.py +82 -0
- teddy_executor/core/services/planning_service.py +242 -0
- teddy_executor/core/services/prompt_manager.py +146 -0
- teddy_executor/core/services/session_lifecycle_manager.py +228 -0
- teddy_executor/core/services/session_loop_guard.py +46 -0
- teddy_executor/core/services/session_orchestrator.py +538 -0
- teddy_executor/core/services/session_planner.py +43 -0
- teddy_executor/core/services/session_pruning_service.py +438 -0
- teddy_executor/core/services/session_replanner.py +105 -0
- teddy_executor/core/services/session_repository.py +194 -0
- teddy_executor/core/services/session_service.py +529 -0
- teddy_executor/core/services/templates/execution_report.md.j2 +290 -0
- teddy_executor/core/services/validation_rules/__init__.py +4 -0
- teddy_executor/core/services/validation_rules/edit.py +207 -0
- teddy_executor/core/services/validation_rules/edit_matcher.py +247 -0
- teddy_executor/core/services/validation_rules/edit_matcher_heuristics.py +84 -0
- teddy_executor/core/services/validation_rules/execute.py +37 -0
- teddy_executor/core/services/validation_rules/filesystem.py +73 -0
- teddy_executor/core/services/validation_rules/helpers.py +178 -0
- teddy_executor/core/services/validation_rules/message.py +29 -0
- teddy_executor/core/utils/__init__.py +1 -0
- teddy_executor/core/utils/diff.py +57 -0
- teddy_executor/core/utils/io.py +75 -0
- teddy_executor/core/utils/markdown.py +131 -0
- teddy_executor/core/utils/serialization.py +39 -0
- teddy_executor/core/utils/string.py +351 -0
- teddy_executor/prompts.py +45 -0
- teddy_executor/registries/__init__.py +1 -0
- teddy_executor/registries/infrastructure.py +147 -0
- teddy_executor/registries/reviewer.py +57 -0
- teddy_executor/registries/validators.py +47 -0
- teddy_executor/resources/__init__.py +1 -0
- teddy_executor/resources/config/.gitignore +2 -0
- teddy_executor/resources/config/__init__.py +1 -0
- teddy_executor/resources/config/config.yaml +49 -0
- teddy_executor/resources/config/init.context +5 -0
- teddy_executor/resources/config/prompts/architect.xml +462 -0
- teddy_executor/resources/config/prompts/assistant.xml +336 -0
- teddy_executor/resources/config/prompts/debugger.xml +456 -0
- teddy_executor/resources/config/prompts/developer.xml +481 -0
- teddy_executor/resources/config/prompts/pathfinder.xml +502 -0
- teddy_executor/resources/config/prompts/prototyper.xml +425 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import pathlib
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Optional, cast
|
|
7
|
+
|
|
8
|
+
import anyio
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from teddy_executor.adapters.inbound.textual_plan_reviewer_app import ReviewerApp
|
|
12
|
+
from teddy_executor.core.domain.models.plan import ActionData
|
|
13
|
+
|
|
14
|
+
from teddy_executor.adapters.inbound.textual_plan_reviewer_editor import (
|
|
15
|
+
launch_editor,
|
|
16
|
+
preview_edit_diff_viewer,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def do_preview_logic(app: ReviewerApp, node: Any, action: ActionData) -> None:
|
|
23
|
+
"""Internal logic for previewing/modifying complex actions."""
|
|
24
|
+
if action.type == "CREATE":
|
|
25
|
+
await preview_create(app, action, node)
|
|
26
|
+
elif action.type == "EDIT":
|
|
27
|
+
await preview_edit(app, action, node)
|
|
28
|
+
elif action.type in ("EXECUTE", "RESEARCH"):
|
|
29
|
+
await preview_text_action(app, action, node)
|
|
30
|
+
elif action.type == "READ":
|
|
31
|
+
await preview_readonly(app, action)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Diff viewer orchestration moved to textual_plan_reviewer_editor.py
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def preview_edit(app: ReviewerApp, action: ActionData, node: Any) -> None:
|
|
38
|
+
"""Handle non-blocking preview for EDIT."""
|
|
39
|
+
if not app._file_system:
|
|
40
|
+
return
|
|
41
|
+
path_str = cast(str, action.params.get("path", ""))
|
|
42
|
+
suffix = pathlib.Path(path_str).suffix or ".txt"
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
original = str(app._file_system.read_file(path_str))
|
|
46
|
+
except Exception:
|
|
47
|
+
original = ""
|
|
48
|
+
proposed, _ = app._edit_simulator.simulate_edits(
|
|
49
|
+
original, action.params.get("edits", [])
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
diff_viewer = app._console_tooling.get_diff_viewer_command()
|
|
53
|
+
|
|
54
|
+
is_mock_path = (
|
|
55
|
+
not isinstance(action.pending_temp_file, (str, os.PathLike))
|
|
56
|
+
and action.pending_temp_file is not None
|
|
57
|
+
)
|
|
58
|
+
if not action.pending_temp_file or (
|
|
59
|
+
not is_mock_path and not os.path.exists(action.pending_temp_file)
|
|
60
|
+
):
|
|
61
|
+
action.pending_temp_file = app._system_env.create_temp_file(suffix=suffix)
|
|
62
|
+
|
|
63
|
+
if diff_viewer and not is_mock_path:
|
|
64
|
+
needs_refresh = await preview_edit_diff_viewer(
|
|
65
|
+
app, action, diff_viewer, original, str(proposed)
|
|
66
|
+
)
|
|
67
|
+
if needs_refresh:
|
|
68
|
+
app._refresh_node(node)
|
|
69
|
+
else:
|
|
70
|
+
final = await launch_editor(
|
|
71
|
+
app,
|
|
72
|
+
str(proposed),
|
|
73
|
+
suffix=suffix,
|
|
74
|
+
persistent_path=action.pending_temp_file,
|
|
75
|
+
)
|
|
76
|
+
if final is not None:
|
|
77
|
+
action.modified = True
|
|
78
|
+
if "edits" not in action.modified_fields:
|
|
79
|
+
action.modified_fields.append("edits")
|
|
80
|
+
if str(final) != str(proposed):
|
|
81
|
+
action.params["edits"] = [{"find": original, "replace": str(final)}]
|
|
82
|
+
action.params.pop("content", None)
|
|
83
|
+
app._refresh_node(node)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def preview_create(app: ReviewerApp, action: ActionData, node: Any) -> None:
|
|
87
|
+
"""Handle non-blocking preview for CREATE."""
|
|
88
|
+
path_str = cast(str, action.params.get("path", ""))
|
|
89
|
+
content = cast(str, action.params.get("content", ""))
|
|
90
|
+
suffix = pathlib.Path(path_str).suffix or ".txt"
|
|
91
|
+
|
|
92
|
+
# Only trigger content editor for CREATE to avoid path-input deadlock.
|
|
93
|
+
# Users edit the path via the parameter list in the right pane.
|
|
94
|
+
if not action.pending_temp_file:
|
|
95
|
+
action.pending_temp_file = app._system_env.create_temp_file(suffix=suffix)
|
|
96
|
+
|
|
97
|
+
new_content = await launch_editor(
|
|
98
|
+
app, str(content), suffix=suffix, persistent_path=action.pending_temp_file
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
if new_content is not None and str(new_content) != str(content):
|
|
102
|
+
action.modified = True
|
|
103
|
+
if "content" not in action.modified_fields:
|
|
104
|
+
action.modified_fields.append("content")
|
|
105
|
+
# Content will be harvested from pending_temp_file on submit
|
|
106
|
+
app._refresh_node(node)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def preview_text_action(app: ReviewerApp, action: ActionData, node: Any) -> None:
|
|
110
|
+
"""Handle non-blocking preview for EXECUTE/RESEARCH."""
|
|
111
|
+
key = "command" if action.type == "EXECUTE" else "queries"
|
|
112
|
+
content = action.params.get(key, "")
|
|
113
|
+
suffix = ".sh" if action.type == "EXECUTE" else ".txt"
|
|
114
|
+
|
|
115
|
+
# Ensure a persistent path exists for the harvest
|
|
116
|
+
if not action.pending_temp_file:
|
|
117
|
+
action.pending_temp_file = app._system_env.create_temp_file(suffix=suffix)
|
|
118
|
+
|
|
119
|
+
final = await launch_editor(
|
|
120
|
+
app, str(content), suffix=suffix, persistent_path=action.pending_temp_file
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if final is not None:
|
|
124
|
+
action.modified = True
|
|
125
|
+
if key not in action.modified_fields:
|
|
126
|
+
action.modified_fields.append(key)
|
|
127
|
+
if str(final) != str(content):
|
|
128
|
+
action.params[key] = str(final)
|
|
129
|
+
app._refresh_node(node)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def preview_readonly(app: ReviewerApp, action: ActionData) -> None:
|
|
133
|
+
"""Handle non-blocking preview for READ (read-only)."""
|
|
134
|
+
if not app._file_system:
|
|
135
|
+
return
|
|
136
|
+
resource = action.params.get("resource") or action.params.get("path", "")
|
|
137
|
+
try:
|
|
138
|
+
content = app._file_system.read_file(resource)
|
|
139
|
+
except Exception as e:
|
|
140
|
+
logger.debug("Failed to read resource for preview: %s", e)
|
|
141
|
+
content = f"--- Content for {resource} could not be retrieved ---"
|
|
142
|
+
|
|
143
|
+
temp_file = app._system_env.create_temp_file(
|
|
144
|
+
suffix=pathlib.Path(resource).suffix or ".txt"
|
|
145
|
+
)
|
|
146
|
+
try:
|
|
147
|
+
with open(temp_file, "w", encoding="utf-8") as f:
|
|
148
|
+
f.write(content)
|
|
149
|
+
# Lock file as read-only
|
|
150
|
+
os.chmod(temp_file, 0o444)
|
|
151
|
+
editor_cmd = app._console_tooling.find_editor()
|
|
152
|
+
if editor_cmd:
|
|
153
|
+
# We don't use the deferred harvest pattern for READ as they are truly read-only
|
|
154
|
+
with app.suspend():
|
|
155
|
+
await anyio.to_thread.run_sync(
|
|
156
|
+
app._system_env.run_command, editor_cmd + [temp_file]
|
|
157
|
+
)
|
|
158
|
+
finally:
|
|
159
|
+
app._system_env.delete_file(temp_file)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def view_details_handler(app: "ReviewerApp") -> None:
|
|
163
|
+
"""Implementation for viewing action logs."""
|
|
164
|
+
from textual.widgets import Tree
|
|
165
|
+
from teddy_executor.core.domain.models.plan import ActionData
|
|
166
|
+
from teddy_executor.adapters.inbound.textual_plan_reviewer_execution import (
|
|
167
|
+
format_action_log,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
tree = app.query_one(Tree)
|
|
171
|
+
node = tree.cursor_node
|
|
172
|
+
if not node or not node.data:
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
action = node.data
|
|
176
|
+
if not isinstance(action, ActionData) or not action.executed:
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
if action.action_log:
|
|
180
|
+
log_content = format_action_log(action.action_log)
|
|
181
|
+
temp_file = app._system_env.create_temp_file(suffix=".md")
|
|
182
|
+
app._log_preview_files.append(temp_file)
|
|
183
|
+
await launch_editor(
|
|
184
|
+
app,
|
|
185
|
+
log_content,
|
|
186
|
+
suffix=".md",
|
|
187
|
+
persistent_path=temp_file,
|
|
188
|
+
skip_confirm=True,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
async def view_plan_handler(app: "ReviewerApp") -> None:
|
|
193
|
+
"""Implementation for viewing the full plan."""
|
|
194
|
+
content: Optional[str] = None
|
|
195
|
+
plan_path = app.plan.plan_path
|
|
196
|
+
if plan_path and app._file_system:
|
|
197
|
+
try:
|
|
198
|
+
content = app._file_system.read_file(plan_path)
|
|
199
|
+
except Exception as e:
|
|
200
|
+
logger.debug("Failed to read plan file for viewing: %s", e)
|
|
201
|
+
if not content:
|
|
202
|
+
content = app.plan.raw_content
|
|
203
|
+
if not content:
|
|
204
|
+
content = f"# Plan: {app.plan.title}\n\n{app.plan.rationale}\n\n"
|
|
205
|
+
|
|
206
|
+
if content:
|
|
207
|
+
# If we have a persistent path, we use it. We skip confirmation because
|
|
208
|
+
# 'view' is intended to be a read-only or informational action.
|
|
209
|
+
await launch_editor(
|
|
210
|
+
app,
|
|
211
|
+
content,
|
|
212
|
+
suffix=".md",
|
|
213
|
+
persistent_path=plan_path,
|
|
214
|
+
skip_confirm=True,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
async def add_message_handler(app: "ReviewerApp") -> None:
|
|
219
|
+
"""Implementation for adding user instruction message."""
|
|
220
|
+
current_message = app._user_message_cache
|
|
221
|
+
if current_message is None:
|
|
222
|
+
current_message = app.plan.metadata.get("user_request") or ""
|
|
223
|
+
if app.INSTRUCTION_MARKER not in current_message:
|
|
224
|
+
current_message += app.INSTRUCTION_MARKER
|
|
225
|
+
new_message = await launch_editor(app, current_message, suffix=".md")
|
|
226
|
+
if new_message is not None and new_message != current_message:
|
|
227
|
+
app._user_message_cache = new_message
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from textual.binding import Binding
|
|
7
|
+
from textual.containers import VerticalScroll
|
|
8
|
+
from textual.screen import ModalScreen
|
|
9
|
+
from textual.widgets import Input, Label, ListItem, ListView, Tree, Static
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from textual.app import ComposeResult
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PathInputScreen(ModalScreen[str]):
|
|
16
|
+
"""Modal screen for editing a file path."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, initial_path: str):
|
|
19
|
+
super().__init__()
|
|
20
|
+
self.initial_path = initial_path
|
|
21
|
+
|
|
22
|
+
def compose(self) -> ComposeResult:
|
|
23
|
+
yield Label("File path:")
|
|
24
|
+
yield Input(value=self.initial_path, id="path_input")
|
|
25
|
+
|
|
26
|
+
def on_mount(self) -> None:
|
|
27
|
+
self.query_one(Input).focus()
|
|
28
|
+
|
|
29
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
30
|
+
self.dismiss(event.value)
|
|
31
|
+
|
|
32
|
+
def on_key(self, event) -> None:
|
|
33
|
+
if event.key == "escape":
|
|
34
|
+
self.dismiss(None)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ConfirmScreen(ModalScreen[bool]):
|
|
38
|
+
"""Modal screen for final confirmation."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, message: str = "Do you want to apply the changes? (y/n)"):
|
|
41
|
+
super().__init__()
|
|
42
|
+
self.message = message
|
|
43
|
+
|
|
44
|
+
def compose(self) -> ComposeResult:
|
|
45
|
+
yield Label(self.message)
|
|
46
|
+
|
|
47
|
+
def on_key(self, event) -> None:
|
|
48
|
+
if event.key == "y":
|
|
49
|
+
self.dismiss(True)
|
|
50
|
+
elif event.key == "n":
|
|
51
|
+
self.dismiss(False)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ParameterEditModal(ModalScreen[str]):
|
|
55
|
+
"""Modal screen for editing a simple parameter string."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, label: str, initial_value: str):
|
|
58
|
+
super().__init__()
|
|
59
|
+
self.label = label
|
|
60
|
+
self.initial_value = initial_value
|
|
61
|
+
|
|
62
|
+
def compose(self) -> ComposeResult:
|
|
63
|
+
yield Label(self.label)
|
|
64
|
+
yield Input(value=self.initial_value, id="param_input")
|
|
65
|
+
|
|
66
|
+
def on_mount(self) -> None:
|
|
67
|
+
self.query_one(Input).focus()
|
|
68
|
+
|
|
69
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
70
|
+
self.dismiss(event.value)
|
|
71
|
+
|
|
72
|
+
def on_key(self, event) -> None:
|
|
73
|
+
if event.key == "escape":
|
|
74
|
+
self.dismiss(None)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ActionTree(Tree):
|
|
78
|
+
"""A tree that allows both space and enter to toggle selection."""
|
|
79
|
+
|
|
80
|
+
CONTEXT_ROOT = "CONTEXT_ROOT"
|
|
81
|
+
RATIONALE_ROOT = "RATIONALE_ROOT"
|
|
82
|
+
ACTION_PLAN_ROOT = "ACTION_PLAN_ROOT"
|
|
83
|
+
|
|
84
|
+
BINDINGS = [
|
|
85
|
+
Binding("enter", "select_cursor", "Toggle", show=False),
|
|
86
|
+
Binding("space", "select_cursor", "Toggle", show=False),
|
|
87
|
+
Binding(
|
|
88
|
+
"ctrl+down",
|
|
89
|
+
"app.jump_next",
|
|
90
|
+
"Next Section",
|
|
91
|
+
show=False,
|
|
92
|
+
priority=True,
|
|
93
|
+
),
|
|
94
|
+
Binding("alt+down", "app.jump_next", "Next Section", show=False, priority=True),
|
|
95
|
+
Binding(
|
|
96
|
+
"shift+down",
|
|
97
|
+
"app.jump_next",
|
|
98
|
+
"Next Section",
|
|
99
|
+
show=False,
|
|
100
|
+
priority=True,
|
|
101
|
+
),
|
|
102
|
+
Binding("ctrl+up", "app.jump_prev", "Prev Section", show=False, priority=True),
|
|
103
|
+
Binding("alt+up", "app.jump_prev", "Prev Section", show=False, priority=True),
|
|
104
|
+
Binding(
|
|
105
|
+
"shift+up",
|
|
106
|
+
"app.jump_prev",
|
|
107
|
+
"Prev Section",
|
|
108
|
+
show=False,
|
|
109
|
+
priority=True,
|
|
110
|
+
),
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
def jump_to_section(self, section_id: str) -> None:
|
|
114
|
+
"""
|
|
115
|
+
Jump focus to a major section node.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
section_id: The identifier for the section (e.g., RATIONALE_ROOT).
|
|
119
|
+
"""
|
|
120
|
+
for child in self.root.children:
|
|
121
|
+
if child.data == section_id:
|
|
122
|
+
# Ensure the section is expanded and visible before moving cursor
|
|
123
|
+
child.expand()
|
|
124
|
+
self.move_cursor(child)
|
|
125
|
+
# Ensure parent is also expanded (root is usually invisible but its children should be shown)
|
|
126
|
+
self.root.expand()
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class RationaleDetail(VerticalScroll):
|
|
131
|
+
"""A focusable scroll view for rationale."""
|
|
132
|
+
|
|
133
|
+
BINDINGS = [
|
|
134
|
+
Binding("shift+up", "scroll_to_top", "Top", show=False, priority=True),
|
|
135
|
+
Binding("shift+down", "scroll_to_bottom", "Bottom", show=False, priority=True),
|
|
136
|
+
Binding("alt+up", "scroll_to_top", "Top", show=False, priority=True),
|
|
137
|
+
Binding("alt+down", "scroll_to_bottom", "Bottom", show=False, priority=True),
|
|
138
|
+
Binding("ctrl+up", "scroll_to_top", "Top", show=False, priority=True),
|
|
139
|
+
Binding("ctrl+down", "scroll_to_bottom", "Bottom", show=False, priority=True),
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
def action_scroll_to_top(self) -> None:
|
|
143
|
+
"""Scroll to the top of the content."""
|
|
144
|
+
self.scroll_home(animate=False)
|
|
145
|
+
|
|
146
|
+
def action_scroll_to_bottom(self) -> None:
|
|
147
|
+
"""Scroll to the bottom of the content."""
|
|
148
|
+
self.scroll_end(animate=False)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class ParameterDetail(ListView):
|
|
152
|
+
"""A focusable list that wraps parameters."""
|
|
153
|
+
|
|
154
|
+
BINDINGS = [
|
|
155
|
+
Binding("shift+up", "scroll_to_top", "Top", show=False, priority=True),
|
|
156
|
+
Binding("shift+down", "scroll_to_bottom", "Bottom", show=False, priority=True),
|
|
157
|
+
Binding("alt+up", "scroll_to_top", "Top", show=False, priority=True),
|
|
158
|
+
Binding("alt+down", "scroll_to_bottom", "Bottom", show=False, priority=True),
|
|
159
|
+
Binding("ctrl+up", "scroll_to_top", "Top", show=False, priority=True),
|
|
160
|
+
Binding("ctrl+down", "scroll_to_bottom", "Bottom", show=False, priority=True),
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
def action_scroll_to_top(self) -> None:
|
|
164
|
+
"""Jump focus to the first item."""
|
|
165
|
+
if self.children:
|
|
166
|
+
self.index = 0
|
|
167
|
+
|
|
168
|
+
def action_scroll_to_bottom(self) -> None:
|
|
169
|
+
"""Jump focus to the last item."""
|
|
170
|
+
if self.children:
|
|
171
|
+
self.index = len(self.children) - 1
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class DetailItem(ListItem):
|
|
175
|
+
"""A focusable item in the parameter list."""
|
|
176
|
+
|
|
177
|
+
MAX_PREVIEW_LENGTH = 20000
|
|
178
|
+
TRUNCATE_HALFWAY = 10000
|
|
179
|
+
|
|
180
|
+
def __init__(self, key: str, val: Any):
|
|
181
|
+
super().__init__()
|
|
182
|
+
# Truncate extremely large values to prevent TUI freeze during layout
|
|
183
|
+
display_val = str(val)
|
|
184
|
+
if len(display_val) > self.MAX_PREVIEW_LENGTH:
|
|
185
|
+
display_val = (
|
|
186
|
+
display_val[: self.TRUNCATE_HALFWAY]
|
|
187
|
+
+ "\n\n... [TRUNCATED FOR PREVIEW] ...\n\n"
|
|
188
|
+
+ display_val[-self.TRUNCATE_HALFWAY :]
|
|
189
|
+
)
|
|
190
|
+
self.data = {"key": key, "val": val, "display_val": display_val}
|
|
191
|
+
|
|
192
|
+
def compose(self) -> ComposeResult:
|
|
193
|
+
"""Compose the list item with a wrapping static widget."""
|
|
194
|
+
# Use Static instead of Label for performance on large content
|
|
195
|
+
content = self.data["display_val"]
|
|
196
|
+
if self.data["key"]:
|
|
197
|
+
content = f"[bold]{self.data['key']}:[/] {content}"
|
|
198
|
+
yield Static(content, expand=True)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
TUI_CSS = """
|
|
202
|
+
#main-container {
|
|
203
|
+
layout: horizontal;
|
|
204
|
+
height: 1fr;
|
|
205
|
+
}
|
|
206
|
+
#left-pane {
|
|
207
|
+
width: 65%;
|
|
208
|
+
}
|
|
209
|
+
#right-pane {
|
|
210
|
+
width: 35%;
|
|
211
|
+
border-left: vkey $foreground 15%;
|
|
212
|
+
padding: 0;
|
|
213
|
+
background: $surface;
|
|
214
|
+
}
|
|
215
|
+
#right-pane:focus-within {
|
|
216
|
+
background: $surface-lighten-1;
|
|
217
|
+
}
|
|
218
|
+
Tree {
|
|
219
|
+
height: 1fr;
|
|
220
|
+
}
|
|
221
|
+
ListView {
|
|
222
|
+
background: transparent;
|
|
223
|
+
height: 1fr;
|
|
224
|
+
border: none;
|
|
225
|
+
}
|
|
226
|
+
VerticalScroll {
|
|
227
|
+
background: transparent;
|
|
228
|
+
}
|
|
229
|
+
#rationale-content {
|
|
230
|
+
padding: 1 2;
|
|
231
|
+
background: transparent;
|
|
232
|
+
}
|
|
233
|
+
ListItem {
|
|
234
|
+
height: auto;
|
|
235
|
+
padding: 0 1;
|
|
236
|
+
}
|
|
237
|
+
ListItem.--highlight {
|
|
238
|
+
background: $accent 30%;
|
|
239
|
+
color: $text;
|
|
240
|
+
text-style: bold;
|
|
241
|
+
}
|
|
242
|
+
Static {
|
|
243
|
+
width: 100%;
|
|
244
|
+
height: auto;
|
|
245
|
+
}
|
|
246
|
+
"""
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import shlex
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
|
|
7
|
+
from teddy_executor.adapters.inbound.cli_formatter import (
|
|
8
|
+
echo_diff_preview,
|
|
9
|
+
echo_plan_summary,
|
|
10
|
+
)
|
|
11
|
+
from teddy_executor.core.domain.models.change_set import ChangeSet
|
|
12
|
+
from teddy_executor.core.domain.models.plan import ActionData, Plan
|
|
13
|
+
from teddy_executor.core.ports.outbound.system_environment import ISystemEnvironment
|
|
14
|
+
from teddy_executor.core.ports.outbound.config_service import IConfigService
|
|
15
|
+
from teddy_executor.core.ports.outbound.user_interactor import IUserInteractor
|
|
16
|
+
from teddy_executor.adapters.outbound.console_tooling import ConsoleToolingHelper
|
|
17
|
+
from teddy_executor.adapters.outbound.console_interactor_ask_loop import (
|
|
18
|
+
ConsoleAskLoop,
|
|
19
|
+
)
|
|
20
|
+
from teddy_executor.adapters.outbound.console_interactor_helpers import (
|
|
21
|
+
display_handoff_and_confirm,
|
|
22
|
+
prepare_external_preview_files,
|
|
23
|
+
print_skipped_action,
|
|
24
|
+
restore_terminal_mode,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ConsoleInteractorAdapter(IUserInteractor):
|
|
29
|
+
def __init__(self, system_env: ISystemEnvironment, config_service: IConfigService):
|
|
30
|
+
self._system_env = system_env
|
|
31
|
+
self._config_service = config_service
|
|
32
|
+
self._tooling = ConsoleToolingHelper(system_env, config_service)
|
|
33
|
+
self._console = Console(stderr=True)
|
|
34
|
+
self._ask_loop = ConsoleAskLoop(self._system_env, self._tooling)
|
|
35
|
+
|
|
36
|
+
def _restore_terminal(self):
|
|
37
|
+
"""Restores stdin to canonical/echo mode (Unix only)."""
|
|
38
|
+
restore_terminal_mode()
|
|
39
|
+
|
|
40
|
+
def prompt(self, text: str, default: str = "") -> str:
|
|
41
|
+
"""Prompts the user using typer.prompt."""
|
|
42
|
+
return typer.prompt(text, default=default, show_default=False, err=True)
|
|
43
|
+
|
|
44
|
+
def prompt_for_message(
|
|
45
|
+
self, initial_message: Optional[str] = None
|
|
46
|
+
) -> Optional[str]:
|
|
47
|
+
"""Opens the external editor to capture a user message."""
|
|
48
|
+
import os
|
|
49
|
+
|
|
50
|
+
mock_output = os.environ.get("TEDDY_TEST_MOCK_EDITOR_OUTPUT")
|
|
51
|
+
if mock_output:
|
|
52
|
+
return mock_output
|
|
53
|
+
|
|
54
|
+
return self._launch_editor_synchronous(initial_message or "")
|
|
55
|
+
|
|
56
|
+
def display_message(self, message: str) -> None:
|
|
57
|
+
"""Displays a message using Rich console to ensure consistent coloring."""
|
|
58
|
+
self._console.print(message)
|
|
59
|
+
|
|
60
|
+
def ask_question(
|
|
61
|
+
self,
|
|
62
|
+
prompt: str,
|
|
63
|
+
resources: list[str] | None = None,
|
|
64
|
+
agent_name: Optional[str] = None,
|
|
65
|
+
) -> str:
|
|
66
|
+
"""
|
|
67
|
+
Presents a prompt to the user on the console and captures their input.
|
|
68
|
+
Allows falling back to an external editor for multi-line text.
|
|
69
|
+
"""
|
|
70
|
+
self._display_ask_header(prompt, resources, agent_name)
|
|
71
|
+
self._ask_loop.cleanup()
|
|
72
|
+
return self._ask_loop.run(prompt)
|
|
73
|
+
|
|
74
|
+
def _display_ask_header(
|
|
75
|
+
self, prompt: str, resources: list[str] | None, agent_name: Optional[str]
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Displays the formatted message header and reference files."""
|
|
78
|
+
display_name = agent_name if agent_name else "TeDDy"
|
|
79
|
+
header = f"\n--- MESSAGE from {display_name} ---"
|
|
80
|
+
typer.secho(header, fg=typer.colors.CYAN, err=True)
|
|
81
|
+
typer.echo(prompt, err=True)
|
|
82
|
+
|
|
83
|
+
if resources:
|
|
84
|
+
typer.echo("\n▶ Reference Files:", err=True)
|
|
85
|
+
typer.echo("\n".join(resources), err=True)
|
|
86
|
+
typer.echo("", err=True) # Spacer
|
|
87
|
+
|
|
88
|
+
def _launch_editor_synchronous(self, initial_content: str) -> str:
|
|
89
|
+
"""Opens a temporary file in an external editor and waits for it to close."""
|
|
90
|
+
import os
|
|
91
|
+
|
|
92
|
+
mock_output = os.environ.get("TEDDY_TEST_MOCK_EDITOR_OUTPUT")
|
|
93
|
+
if mock_output:
|
|
94
|
+
return mock_output
|
|
95
|
+
|
|
96
|
+
temp_path = self._system_env.create_temp_file(suffix=".md")
|
|
97
|
+
try:
|
|
98
|
+
with open(temp_path, "w", encoding="utf-8") as f:
|
|
99
|
+
f.write(initial_content)
|
|
100
|
+
|
|
101
|
+
editor_cmd = self._tooling.find_editor()
|
|
102
|
+
if not editor_cmd:
|
|
103
|
+
typer.echo("Error: No suitable editor found.", err=True)
|
|
104
|
+
return ""
|
|
105
|
+
|
|
106
|
+
cmd = editor_cmd + [temp_path]
|
|
107
|
+
self._system_env.run_command(cmd)
|
|
108
|
+
self._restore_terminal()
|
|
109
|
+
|
|
110
|
+
with open(temp_path, "r", encoding="utf-8") as f:
|
|
111
|
+
return f.read().strip()
|
|
112
|
+
except Exception as e:
|
|
113
|
+
typer.echo(f"Error: Editor launch failed: {e}", err=True)
|
|
114
|
+
return ""
|
|
115
|
+
finally:
|
|
116
|
+
self._system_env.delete_file(temp_path)
|
|
117
|
+
|
|
118
|
+
def confirm_plan_review(self, plan: Plan) -> bool:
|
|
119
|
+
"""Displays a summary of the plan and asks for bulk confirmation."""
|
|
120
|
+
echo_plan_summary(plan)
|
|
121
|
+
try:
|
|
122
|
+
prompt = "\nExecute this plan? (y/n): "
|
|
123
|
+
response = typer.prompt(prompt, default="n", show_default=False, err=True)
|
|
124
|
+
return response.lower().strip().startswith("y")
|
|
125
|
+
except (EOFError, typer.Abort):
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
def _handle_external_preview(
|
|
129
|
+
self, change_set: ChangeSet, diff_command: List[str], temp_files: List[str]
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Sets up temp files and launches external diff/editor (Non-blocking)."""
|
|
132
|
+
paths = prepare_external_preview_files(self._system_env, change_set, temp_files)
|
|
133
|
+
|
|
134
|
+
if change_set.action_type == "CREATE":
|
|
135
|
+
cmd = [c for c in diff_command if c.lower() not in ("--diff", "-d")]
|
|
136
|
+
self._system_env.run_command(cmd + paths, background=True)
|
|
137
|
+
else:
|
|
138
|
+
self._system_env.run_command(diff_command + paths, background=True)
|
|
139
|
+
|
|
140
|
+
def confirm_action(
|
|
141
|
+
self,
|
|
142
|
+
action: ActionData,
|
|
143
|
+
action_prompt: str,
|
|
144
|
+
change_set: Optional[ChangeSet] = None,
|
|
145
|
+
) -> tuple[bool, str]:
|
|
146
|
+
# Always restore TTY before starting an interaction block
|
|
147
|
+
self._restore_terminal()
|
|
148
|
+
temp_files: List[str] = []
|
|
149
|
+
try:
|
|
150
|
+
if change_set:
|
|
151
|
+
diff_command = self._tooling.get_diff_viewer_command()
|
|
152
|
+
if not diff_command:
|
|
153
|
+
# Check if failure was due to a missing custom tool
|
|
154
|
+
custom_tool = self._system_env.get_env("TEDDY_DIFF_TOOL")
|
|
155
|
+
if custom_tool:
|
|
156
|
+
tool_name = shlex.split(custom_tool)[0]
|
|
157
|
+
typer.echo(
|
|
158
|
+
f"Warning: Custom diff tool '{tool_name}' not found. "
|
|
159
|
+
"Falling back to in-terminal diff.",
|
|
160
|
+
err=True,
|
|
161
|
+
)
|
|
162
|
+
echo_diff_preview(change_set)
|
|
163
|
+
else:
|
|
164
|
+
self._handle_external_preview(change_set, diff_command, temp_files)
|
|
165
|
+
self._restore_terminal()
|
|
166
|
+
|
|
167
|
+
message = ""
|
|
168
|
+
while True:
|
|
169
|
+
prompt = f"{action_prompt}\nApprove? (y/n/m): "
|
|
170
|
+
# Use typer.prompt which handles echoing to stderr correctly
|
|
171
|
+
response = (
|
|
172
|
+
typer.prompt(prompt, default="n", show_default=False, err=True)
|
|
173
|
+
.lower()
|
|
174
|
+
.strip()
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
if response.startswith("y"):
|
|
178
|
+
return True, message
|
|
179
|
+
|
|
180
|
+
if response.startswith("m"):
|
|
181
|
+
message = self._launch_editor_synchronous("")
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
return False, ""
|
|
185
|
+
except (EOFError, typer.Abort):
|
|
186
|
+
# If input stream is closed (e.g., in non-interactive script),
|
|
187
|
+
# default to denying the action.
|
|
188
|
+
typer.echo("\nAborted.", err=True)
|
|
189
|
+
return False, "Skipped due to non-interactive session."
|
|
190
|
+
finally:
|
|
191
|
+
for file_path in temp_files:
|
|
192
|
+
self._system_env.delete_file(file_path)
|
|
193
|
+
|
|
194
|
+
def notify_skipped_action(self, action: ActionData, reason: str) -> None:
|
|
195
|
+
"""Prints a colorized warning that an action was skipped."""
|
|
196
|
+
print_skipped_action(action, reason)
|
|
197
|
+
|
|
198
|
+
def notify_warning(self, message: str) -> None:
|
|
199
|
+
"""Prints a colorized warning message to stderr."""
|
|
200
|
+
self._console.print(f"[bold yellow]WARNING:[/] {message}")
|
|
201
|
+
|
|
202
|
+
def confirm_manual_handoff(
|
|
203
|
+
self,
|
|
204
|
+
action_type: str,
|
|
205
|
+
target_agent: str | None,
|
|
206
|
+
resources: list[str],
|
|
207
|
+
message: str,
|
|
208
|
+
) -> tuple[bool, str]:
|
|
209
|
+
"""Displays a handoff request and asks for confirmation."""
|
|
210
|
+
return display_handoff_and_confirm(
|
|
211
|
+
action_type, target_agent, resources, message
|
|
212
|
+
)
|