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.
Files changed (143) hide show
  1. teddy_cli-0.1.0.dist-info/LICENSE +677 -0
  2. teddy_cli-0.1.0.dist-info/METADATA +33 -0
  3. teddy_cli-0.1.0.dist-info/RECORD +143 -0
  4. teddy_cli-0.1.0.dist-info/WHEEL +4 -0
  5. teddy_cli-0.1.0.dist-info/entry_points.txt +3 -0
  6. teddy_executor/__init__.py +1 -0
  7. teddy_executor/__main__.py +335 -0
  8. teddy_executor/adapters/__init__.py +0 -0
  9. teddy_executor/adapters/inbound/__init__.py +0 -0
  10. teddy_executor/adapters/inbound/cli_formatter.py +107 -0
  11. teddy_executor/adapters/inbound/cli_helpers.py +249 -0
  12. teddy_executor/adapters/inbound/console_plan_reviewer.py +69 -0
  13. teddy_executor/adapters/inbound/session_cli_handlers.py +366 -0
  14. teddy_executor/adapters/inbound/textual_plan_reviewer.py +78 -0
  15. teddy_executor/adapters/inbound/textual_plan_reviewer_app.py +367 -0
  16. teddy_executor/adapters/inbound/textual_plan_reviewer_editor.py +281 -0
  17. teddy_executor/adapters/inbound/textual_plan_reviewer_execution.py +213 -0
  18. teddy_executor/adapters/inbound/textual_plan_reviewer_helpers.py +308 -0
  19. teddy_executor/adapters/inbound/textual_plan_reviewer_logic.py +345 -0
  20. teddy_executor/adapters/inbound/textual_plan_reviewer_previews.py +227 -0
  21. teddy_executor/adapters/inbound/textual_plan_reviewer_widgets.py +246 -0
  22. teddy_executor/adapters/outbound/__init__.py +7 -0
  23. teddy_executor/adapters/outbound/console_interactor.py +212 -0
  24. teddy_executor/adapters/outbound/console_interactor_ask_loop.py +121 -0
  25. teddy_executor/adapters/outbound/console_interactor_helpers.py +95 -0
  26. teddy_executor/adapters/outbound/console_tooling.py +62 -0
  27. teddy_executor/adapters/outbound/filesystem_helpers.py +61 -0
  28. teddy_executor/adapters/outbound/litellm_adapter.py +462 -0
  29. teddy_executor/adapters/outbound/local_file_system_adapter.py +300 -0
  30. teddy_executor/adapters/outbound/local_repo_tree_generator.py +96 -0
  31. teddy_executor/adapters/outbound/openrouter_hydrator.py +89 -0
  32. teddy_executor/adapters/outbound/shell_adapter.py +344 -0
  33. teddy_executor/adapters/outbound/shell_command_builder.py +105 -0
  34. teddy_executor/adapters/outbound/system_environment_adapter.py +62 -0
  35. teddy_executor/adapters/outbound/system_environment_inspector.py +54 -0
  36. teddy_executor/adapters/outbound/system_time_adapter.py +22 -0
  37. teddy_executor/adapters/outbound/web_scraper_adapter.py +346 -0
  38. teddy_executor/adapters/outbound/web_searcher_adapter.py +122 -0
  39. teddy_executor/adapters/outbound/yaml_config_adapter.py +105 -0
  40. teddy_executor/container.py +333 -0
  41. teddy_executor/core/__init__.py +0 -0
  42. teddy_executor/core/domain/__init__.py +0 -0
  43. teddy_executor/core/domain/models/__init__.py +44 -0
  44. teddy_executor/core/domain/models/action_ports.py +28 -0
  45. teddy_executor/core/domain/models/change_set.py +10 -0
  46. teddy_executor/core/domain/models/exceptions.py +40 -0
  47. teddy_executor/core/domain/models/execution_report.py +65 -0
  48. teddy_executor/core/domain/models/orchestrator_ports.py +26 -0
  49. teddy_executor/core/domain/models/plan.py +85 -0
  50. teddy_executor/core/domain/models/planning_ports.py +43 -0
  51. teddy_executor/core/domain/models/project_context.py +56 -0
  52. teddy_executor/core/domain/models/report_assembly_data.py +18 -0
  53. teddy_executor/core/domain/models/session.py +17 -0
  54. teddy_executor/core/domain/models/shell_output.py +12 -0
  55. teddy_executor/core/domain/models/web_search_results.py +26 -0
  56. teddy_executor/core/ports/__init__.py +0 -0
  57. teddy_executor/core/ports/inbound/__init__.py +0 -0
  58. teddy_executor/core/ports/inbound/edit_simulator.py +33 -0
  59. teddy_executor/core/ports/inbound/get_context_use_case.py +32 -0
  60. teddy_executor/core/ports/inbound/init.py +15 -0
  61. teddy_executor/core/ports/inbound/plan_parser.py +52 -0
  62. teddy_executor/core/ports/inbound/plan_reviewer.py +44 -0
  63. teddy_executor/core/ports/inbound/plan_validator.py +26 -0
  64. teddy_executor/core/ports/inbound/planning_use_case.py +30 -0
  65. teddy_executor/core/ports/inbound/run_plan_use_case.py +60 -0
  66. teddy_executor/core/ports/outbound/__init__.py +34 -0
  67. teddy_executor/core/ports/outbound/config_service.py +29 -0
  68. teddy_executor/core/ports/outbound/environment_inspector.py +30 -0
  69. teddy_executor/core/ports/outbound/execution_report_assembler.py +19 -0
  70. teddy_executor/core/ports/outbound/file_system_manager.py +131 -0
  71. teddy_executor/core/ports/outbound/llm_client.py +90 -0
  72. teddy_executor/core/ports/outbound/markdown_report_formatter.py +26 -0
  73. teddy_executor/core/ports/outbound/prompt_manager.py +55 -0
  74. teddy_executor/core/ports/outbound/repo_tree_generator.py +17 -0
  75. teddy_executor/core/ports/outbound/session_loop_guard.py +16 -0
  76. teddy_executor/core/ports/outbound/session_manager.py +97 -0
  77. teddy_executor/core/ports/outbound/session_repository.py +65 -0
  78. teddy_executor/core/ports/outbound/shell_executor.py +24 -0
  79. teddy_executor/core/ports/outbound/system_environment.py +25 -0
  80. teddy_executor/core/ports/outbound/time_service.py +28 -0
  81. teddy_executor/core/ports/outbound/user_interactor.py +126 -0
  82. teddy_executor/core/ports/outbound/web_scraper.py +24 -0
  83. teddy_executor/core/ports/outbound/web_searcher.py +25 -0
  84. teddy_executor/core/services/__init__.py +0 -0
  85. teddy_executor/core/services/action_changeset_builder.py +90 -0
  86. teddy_executor/core/services/action_diff_manager.py +110 -0
  87. teddy_executor/core/services/action_dispatcher.py +142 -0
  88. teddy_executor/core/services/action_executor.py +209 -0
  89. teddy_executor/core/services/action_factory.py +197 -0
  90. teddy_executor/core/services/action_parser_complex.py +216 -0
  91. teddy_executor/core/services/action_parser_strategies.py +84 -0
  92. teddy_executor/core/services/context_service.py +437 -0
  93. teddy_executor/core/services/edit_simulator.py +128 -0
  94. teddy_executor/core/services/execution_orchestrator.py +295 -0
  95. teddy_executor/core/services/execution_report_assembler.py +62 -0
  96. teddy_executor/core/services/init_service.py +80 -0
  97. teddy_executor/core/services/markdown_plan_parser.py +309 -0
  98. teddy_executor/core/services/markdown_report_formatter.py +143 -0
  99. teddy_executor/core/services/parser_infrastructure.py +222 -0
  100. teddy_executor/core/services/parser_metadata.py +153 -0
  101. teddy_executor/core/services/parser_reporting.py +267 -0
  102. teddy_executor/core/services/plan_validator.py +82 -0
  103. teddy_executor/core/services/planning_service.py +242 -0
  104. teddy_executor/core/services/prompt_manager.py +146 -0
  105. teddy_executor/core/services/session_lifecycle_manager.py +228 -0
  106. teddy_executor/core/services/session_loop_guard.py +46 -0
  107. teddy_executor/core/services/session_orchestrator.py +538 -0
  108. teddy_executor/core/services/session_planner.py +43 -0
  109. teddy_executor/core/services/session_pruning_service.py +438 -0
  110. teddy_executor/core/services/session_replanner.py +105 -0
  111. teddy_executor/core/services/session_repository.py +194 -0
  112. teddy_executor/core/services/session_service.py +529 -0
  113. teddy_executor/core/services/templates/execution_report.md.j2 +290 -0
  114. teddy_executor/core/services/validation_rules/__init__.py +4 -0
  115. teddy_executor/core/services/validation_rules/edit.py +207 -0
  116. teddy_executor/core/services/validation_rules/edit_matcher.py +247 -0
  117. teddy_executor/core/services/validation_rules/edit_matcher_heuristics.py +84 -0
  118. teddy_executor/core/services/validation_rules/execute.py +37 -0
  119. teddy_executor/core/services/validation_rules/filesystem.py +73 -0
  120. teddy_executor/core/services/validation_rules/helpers.py +178 -0
  121. teddy_executor/core/services/validation_rules/message.py +29 -0
  122. teddy_executor/core/utils/__init__.py +1 -0
  123. teddy_executor/core/utils/diff.py +57 -0
  124. teddy_executor/core/utils/io.py +75 -0
  125. teddy_executor/core/utils/markdown.py +131 -0
  126. teddy_executor/core/utils/serialization.py +39 -0
  127. teddy_executor/core/utils/string.py +351 -0
  128. teddy_executor/prompts.py +45 -0
  129. teddy_executor/registries/__init__.py +1 -0
  130. teddy_executor/registries/infrastructure.py +147 -0
  131. teddy_executor/registries/reviewer.py +57 -0
  132. teddy_executor/registries/validators.py +47 -0
  133. teddy_executor/resources/__init__.py +1 -0
  134. teddy_executor/resources/config/.gitignore +2 -0
  135. teddy_executor/resources/config/__init__.py +1 -0
  136. teddy_executor/resources/config/config.yaml +49 -0
  137. teddy_executor/resources/config/init.context +5 -0
  138. teddy_executor/resources/config/prompts/architect.xml +462 -0
  139. teddy_executor/resources/config/prompts/assistant.xml +336 -0
  140. teddy_executor/resources/config/prompts/debugger.xml +456 -0
  141. teddy_executor/resources/config/prompts/developer.xml +481 -0
  142. teddy_executor/resources/config/prompts/pathfinder.xml +502 -0
  143. 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,7 @@
1
+ from .litellm_adapter import LiteLLMAdapter
2
+ from .yaml_config_adapter import YamlConfigAdapter
3
+
4
+ __all__ = [
5
+ "LiteLLMAdapter",
6
+ "YamlConfigAdapter",
7
+ ]
@@ -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
+ )