pdd-cli 0.0.45__py3-none-any.whl → 0.0.118__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.
- pdd/__init__.py +40 -8
- pdd/agentic_bug.py +323 -0
- pdd/agentic_bug_orchestrator.py +497 -0
- pdd/agentic_change.py +231 -0
- pdd/agentic_change_orchestrator.py +526 -0
- pdd/agentic_common.py +598 -0
- pdd/agentic_crash.py +534 -0
- pdd/agentic_e2e_fix.py +319 -0
- pdd/agentic_e2e_fix_orchestrator.py +426 -0
- pdd/agentic_fix.py +1294 -0
- pdd/agentic_langtest.py +162 -0
- pdd/agentic_update.py +387 -0
- pdd/agentic_verify.py +183 -0
- pdd/architecture_sync.py +565 -0
- pdd/auth_service.py +210 -0
- pdd/auto_deps_main.py +71 -51
- pdd/auto_include.py +245 -5
- pdd/auto_update.py +125 -47
- pdd/bug_main.py +196 -23
- pdd/bug_to_unit_test.py +2 -0
- pdd/change_main.py +11 -4
- pdd/cli.py +22 -1181
- pdd/cmd_test_main.py +350 -150
- pdd/code_generator.py +60 -18
- pdd/code_generator_main.py +790 -57
- pdd/commands/__init__.py +48 -0
- pdd/commands/analysis.py +306 -0
- pdd/commands/auth.py +309 -0
- pdd/commands/connect.py +290 -0
- pdd/commands/fix.py +163 -0
- pdd/commands/generate.py +257 -0
- pdd/commands/maintenance.py +175 -0
- pdd/commands/misc.py +87 -0
- pdd/commands/modify.py +256 -0
- pdd/commands/report.py +144 -0
- pdd/commands/sessions.py +284 -0
- pdd/commands/templates.py +215 -0
- pdd/commands/utility.py +110 -0
- pdd/config_resolution.py +58 -0
- pdd/conflicts_main.py +8 -3
- pdd/construct_paths.py +589 -111
- pdd/context_generator.py +10 -2
- pdd/context_generator_main.py +175 -76
- pdd/continue_generation.py +53 -10
- pdd/core/__init__.py +33 -0
- pdd/core/cli.py +527 -0
- pdd/core/cloud.py +237 -0
- pdd/core/dump.py +554 -0
- pdd/core/errors.py +67 -0
- pdd/core/remote_session.py +61 -0
- pdd/core/utils.py +90 -0
- pdd/crash_main.py +262 -33
- pdd/data/language_format.csv +71 -63
- pdd/data/llm_model.csv +20 -18
- pdd/detect_change_main.py +5 -4
- pdd/docs/prompting_guide.md +864 -0
- pdd/docs/whitepaper_with_benchmarks/data_and_functions/benchmark_analysis.py +495 -0
- pdd/docs/whitepaper_with_benchmarks/data_and_functions/creation_compare.py +528 -0
- pdd/fix_code_loop.py +523 -95
- pdd/fix_code_module_errors.py +6 -2
- pdd/fix_error_loop.py +491 -92
- pdd/fix_errors_from_unit_tests.py +4 -3
- pdd/fix_main.py +278 -21
- pdd/fix_verification_errors.py +12 -100
- pdd/fix_verification_errors_loop.py +529 -286
- pdd/fix_verification_main.py +294 -89
- pdd/frontend/dist/assets/index-B5DZHykP.css +1 -0
- pdd/frontend/dist/assets/index-DQ3wkeQ2.js +449 -0
- pdd/frontend/dist/index.html +376 -0
- pdd/frontend/dist/logo.svg +33 -0
- pdd/generate_output_paths.py +139 -15
- pdd/generate_test.py +218 -146
- pdd/get_comment.py +19 -44
- pdd/get_extension.py +8 -9
- pdd/get_jwt_token.py +318 -22
- pdd/get_language.py +8 -7
- pdd/get_run_command.py +75 -0
- pdd/get_test_command.py +68 -0
- pdd/git_update.py +70 -19
- pdd/incremental_code_generator.py +2 -2
- pdd/insert_includes.py +13 -4
- pdd/llm_invoke.py +1711 -181
- pdd/load_prompt_template.py +19 -12
- pdd/path_resolution.py +140 -0
- pdd/pdd_completion.fish +25 -2
- pdd/pdd_completion.sh +30 -4
- pdd/pdd_completion.zsh +79 -4
- pdd/postprocess.py +14 -4
- pdd/preprocess.py +293 -24
- pdd/preprocess_main.py +41 -6
- pdd/prompts/agentic_bug_step10_pr_LLM.prompt +182 -0
- pdd/prompts/agentic_bug_step1_duplicate_LLM.prompt +73 -0
- pdd/prompts/agentic_bug_step2_docs_LLM.prompt +129 -0
- pdd/prompts/agentic_bug_step3_triage_LLM.prompt +95 -0
- pdd/prompts/agentic_bug_step4_reproduce_LLM.prompt +97 -0
- pdd/prompts/agentic_bug_step5_root_cause_LLM.prompt +123 -0
- pdd/prompts/agentic_bug_step6_test_plan_LLM.prompt +107 -0
- pdd/prompts/agentic_bug_step7_generate_LLM.prompt +172 -0
- pdd/prompts/agentic_bug_step8_verify_LLM.prompt +119 -0
- pdd/prompts/agentic_bug_step9_e2e_test_LLM.prompt +289 -0
- pdd/prompts/agentic_change_step10_identify_issues_LLM.prompt +1006 -0
- pdd/prompts/agentic_change_step11_fix_issues_LLM.prompt +984 -0
- pdd/prompts/agentic_change_step12_create_pr_LLM.prompt +131 -0
- pdd/prompts/agentic_change_step1_duplicate_LLM.prompt +73 -0
- pdd/prompts/agentic_change_step2_docs_LLM.prompt +101 -0
- pdd/prompts/agentic_change_step3_research_LLM.prompt +126 -0
- pdd/prompts/agentic_change_step4_clarify_LLM.prompt +164 -0
- pdd/prompts/agentic_change_step5_docs_change_LLM.prompt +981 -0
- pdd/prompts/agentic_change_step6_devunits_LLM.prompt +1005 -0
- pdd/prompts/agentic_change_step7_architecture_LLM.prompt +1044 -0
- pdd/prompts/agentic_change_step8_analyze_LLM.prompt +1027 -0
- pdd/prompts/agentic_change_step9_implement_LLM.prompt +1077 -0
- pdd/prompts/agentic_crash_explore_LLM.prompt +49 -0
- pdd/prompts/agentic_e2e_fix_step1_unit_tests_LLM.prompt +90 -0
- pdd/prompts/agentic_e2e_fix_step2_e2e_tests_LLM.prompt +91 -0
- pdd/prompts/agentic_e2e_fix_step3_root_cause_LLM.prompt +89 -0
- pdd/prompts/agentic_e2e_fix_step4_fix_e2e_tests_LLM.prompt +96 -0
- pdd/prompts/agentic_e2e_fix_step5_identify_devunits_LLM.prompt +91 -0
- pdd/prompts/agentic_e2e_fix_step6_create_unit_tests_LLM.prompt +106 -0
- pdd/prompts/agentic_e2e_fix_step7_verify_tests_LLM.prompt +116 -0
- pdd/prompts/agentic_e2e_fix_step8_run_pdd_fix_LLM.prompt +120 -0
- pdd/prompts/agentic_e2e_fix_step9_verify_all_LLM.prompt +146 -0
- pdd/prompts/agentic_fix_explore_LLM.prompt +45 -0
- pdd/prompts/agentic_fix_harvest_only_LLM.prompt +48 -0
- pdd/prompts/agentic_fix_primary_LLM.prompt +85 -0
- pdd/prompts/agentic_update_LLM.prompt +925 -0
- pdd/prompts/agentic_verify_explore_LLM.prompt +45 -0
- pdd/prompts/auto_include_LLM.prompt +122 -905
- pdd/prompts/change_LLM.prompt +3093 -1
- pdd/prompts/detect_change_LLM.prompt +686 -27
- pdd/prompts/example_generator_LLM.prompt +22 -1
- pdd/prompts/extract_code_LLM.prompt +5 -1
- pdd/prompts/extract_program_code_fix_LLM.prompt +7 -1
- pdd/prompts/extract_prompt_update_LLM.prompt +7 -8
- pdd/prompts/extract_promptline_LLM.prompt +17 -11
- pdd/prompts/find_verification_errors_LLM.prompt +6 -0
- pdd/prompts/fix_code_module_errors_LLM.prompt +12 -2
- pdd/prompts/fix_errors_from_unit_tests_LLM.prompt +9 -0
- pdd/prompts/fix_verification_errors_LLM.prompt +22 -0
- pdd/prompts/generate_test_LLM.prompt +41 -7
- pdd/prompts/generate_test_from_example_LLM.prompt +115 -0
- pdd/prompts/increase_tests_LLM.prompt +1 -5
- pdd/prompts/insert_includes_LLM.prompt +316 -186
- pdd/prompts/prompt_code_diff_LLM.prompt +119 -0
- pdd/prompts/prompt_diff_LLM.prompt +82 -0
- pdd/prompts/trace_LLM.prompt +25 -22
- pdd/prompts/unfinished_prompt_LLM.prompt +85 -1
- pdd/prompts/update_prompt_LLM.prompt +22 -1
- pdd/pytest_output.py +127 -12
- pdd/remote_session.py +876 -0
- pdd/render_mermaid.py +236 -0
- pdd/server/__init__.py +52 -0
- pdd/server/app.py +335 -0
- pdd/server/click_executor.py +587 -0
- pdd/server/executor.py +338 -0
- pdd/server/jobs.py +661 -0
- pdd/server/models.py +241 -0
- pdd/server/routes/__init__.py +31 -0
- pdd/server/routes/architecture.py +451 -0
- pdd/server/routes/auth.py +364 -0
- pdd/server/routes/commands.py +929 -0
- pdd/server/routes/config.py +42 -0
- pdd/server/routes/files.py +603 -0
- pdd/server/routes/prompts.py +1322 -0
- pdd/server/routes/websocket.py +473 -0
- pdd/server/security.py +243 -0
- pdd/server/terminal_spawner.py +209 -0
- pdd/server/token_counter.py +222 -0
- pdd/setup_tool.py +648 -0
- pdd/simple_math.py +2 -0
- pdd/split_main.py +3 -2
- pdd/summarize_directory.py +237 -195
- pdd/sync_animation.py +8 -4
- pdd/sync_determine_operation.py +839 -112
- pdd/sync_main.py +351 -57
- pdd/sync_orchestration.py +1400 -756
- pdd/sync_tui.py +848 -0
- pdd/template_expander.py +161 -0
- pdd/template_registry.py +264 -0
- pdd/templates/architecture/architecture_json.prompt +237 -0
- pdd/templates/generic/generate_prompt.prompt +174 -0
- pdd/trace.py +168 -12
- pdd/trace_main.py +4 -3
- pdd/track_cost.py +140 -63
- pdd/unfinished_prompt.py +51 -4
- pdd/update_main.py +567 -67
- pdd/update_model_costs.py +2 -2
- pdd/update_prompt.py +19 -4
- {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/METADATA +29 -11
- pdd_cli-0.0.118.dist-info/RECORD +227 -0
- {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/licenses/LICENSE +1 -1
- pdd_cli-0.0.45.dist-info/RECORD +0 -116
- {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/WHEEL +0 -0
- {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/entry_points.txt +0 -0
- {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/top_level.txt +0 -0
pdd/sync_tui.py
ADDED
|
@@ -0,0 +1,848 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import sys
|
|
3
|
+
import os
|
|
4
|
+
from typing import List, Optional, Callable, Any
|
|
5
|
+
import io
|
|
6
|
+
import asyncio
|
|
7
|
+
|
|
8
|
+
from textual.app import App, ComposeResult
|
|
9
|
+
from textual.screen import ModalScreen
|
|
10
|
+
from textual.widgets import Static, RichLog, Button, Label, Input, ProgressBar
|
|
11
|
+
from textual.containers import Vertical, Container, Horizontal
|
|
12
|
+
from textual.binding import Binding
|
|
13
|
+
from textual.worker import Worker
|
|
14
|
+
from textual import work
|
|
15
|
+
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.panel import Panel
|
|
18
|
+
from rich.align import Align
|
|
19
|
+
from rich.text import Text
|
|
20
|
+
import time
|
|
21
|
+
import re
|
|
22
|
+
|
|
23
|
+
# Reuse existing animation logic
|
|
24
|
+
from .sync_animation import AnimationState, _render_animation_frame, DEEP_NAVY, ELECTRIC_CYAN
|
|
25
|
+
from . import logo_animation
|
|
26
|
+
from rich.style import Style
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ConfirmScreen(ModalScreen[bool]):
|
|
30
|
+
"""A modal confirmation dialog for user prompts within the TUI."""
|
|
31
|
+
|
|
32
|
+
CSS = """
|
|
33
|
+
ConfirmScreen {
|
|
34
|
+
align: center middle;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
#confirm-dialog {
|
|
38
|
+
width: 70;
|
|
39
|
+
height: auto;
|
|
40
|
+
border: thick $primary;
|
|
41
|
+
background: #0A0A23;
|
|
42
|
+
padding: 1 2;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#confirm-title {
|
|
46
|
+
width: 100%;
|
|
47
|
+
text-align: center;
|
|
48
|
+
text-style: bold;
|
|
49
|
+
color: #00D8FF;
|
|
50
|
+
margin-bottom: 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#confirm-message {
|
|
54
|
+
width: 100%;
|
|
55
|
+
text-align: center;
|
|
56
|
+
color: #FFFFFF;
|
|
57
|
+
margin-bottom: 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#confirm-buttons {
|
|
61
|
+
width: 100%;
|
|
62
|
+
align: center middle;
|
|
63
|
+
margin-top: 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
#confirm-buttons Button {
|
|
67
|
+
margin: 0 2;
|
|
68
|
+
min-width: 12;
|
|
69
|
+
}
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
BINDINGS = [
|
|
73
|
+
Binding("y", "confirm_yes", "Yes"),
|
|
74
|
+
Binding("n", "confirm_no", "No"),
|
|
75
|
+
Binding("enter", "confirm_yes", "Confirm"),
|
|
76
|
+
Binding("escape", "confirm_no", "Cancel"),
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
def __init__(self, message: str, title: str = "Confirmation Required"):
|
|
80
|
+
super().__init__()
|
|
81
|
+
self.message = message
|
|
82
|
+
self.title_text = title
|
|
83
|
+
|
|
84
|
+
def compose(self) -> ComposeResult:
|
|
85
|
+
with Container(id="confirm-dialog"):
|
|
86
|
+
yield Label(self.title_text, id="confirm-title")
|
|
87
|
+
yield Label(self.message, id="confirm-message")
|
|
88
|
+
with Horizontal(id="confirm-buttons"):
|
|
89
|
+
yield Button("Yes (Y)", id="yes", variant="success")
|
|
90
|
+
yield Button("No (N)", id="no", variant="error")
|
|
91
|
+
|
|
92
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
93
|
+
self.dismiss(event.button.id == "yes")
|
|
94
|
+
|
|
95
|
+
def action_confirm_yes(self) -> None:
|
|
96
|
+
self.dismiss(True)
|
|
97
|
+
|
|
98
|
+
def action_confirm_no(self) -> None:
|
|
99
|
+
self.dismiss(False)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class InputScreen(ModalScreen[str]):
|
|
103
|
+
"""A modal input dialog for text entry within the TUI."""
|
|
104
|
+
|
|
105
|
+
CSS = """
|
|
106
|
+
InputScreen {
|
|
107
|
+
align: center middle;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
#input-dialog {
|
|
111
|
+
width: 70;
|
|
112
|
+
height: auto;
|
|
113
|
+
border: thick $primary;
|
|
114
|
+
background: #0A0A23;
|
|
115
|
+
padding: 1 2;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
#input-title {
|
|
119
|
+
width: 100%;
|
|
120
|
+
text-align: center;
|
|
121
|
+
text-style: bold;
|
|
122
|
+
color: #00D8FF;
|
|
123
|
+
margin-bottom: 1;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
#input-message {
|
|
127
|
+
width: 100%;
|
|
128
|
+
text-align: center;
|
|
129
|
+
color: #FFFFFF;
|
|
130
|
+
margin-bottom: 1;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
#input-field {
|
|
134
|
+
width: 100%;
|
|
135
|
+
margin-bottom: 1;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#input-buttons {
|
|
139
|
+
width: 100%;
|
|
140
|
+
align: center middle;
|
|
141
|
+
margin-top: 1;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#input-buttons Button {
|
|
145
|
+
margin: 0 2;
|
|
146
|
+
min-width: 12;
|
|
147
|
+
}
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
BINDINGS = [
|
|
151
|
+
Binding("escape", "cancel", "Cancel"),
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
def __init__(self, message: str, title: str = "Input Required", default: str = "", password: bool = False):
|
|
155
|
+
super().__init__()
|
|
156
|
+
self.message = message
|
|
157
|
+
self.title_text = title
|
|
158
|
+
self.default = default
|
|
159
|
+
self.password = password
|
|
160
|
+
|
|
161
|
+
def compose(self) -> ComposeResult:
|
|
162
|
+
with Container(id="input-dialog"):
|
|
163
|
+
yield Label(self.title_text, id="input-title")
|
|
164
|
+
yield Label(self.message, id="input-message")
|
|
165
|
+
yield Input(value=self.default, password=self.password, id="input-field")
|
|
166
|
+
with Horizontal(id="input-buttons"):
|
|
167
|
+
yield Button("OK (Enter)", id="ok", variant="success")
|
|
168
|
+
yield Button("Cancel (Esc)", id="cancel", variant="error")
|
|
169
|
+
|
|
170
|
+
def on_mount(self) -> None:
|
|
171
|
+
self.query_one("#input-field", Input).focus()
|
|
172
|
+
|
|
173
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
174
|
+
if event.button.id == "ok":
|
|
175
|
+
input_widget = self.query_one("#input-field", Input)
|
|
176
|
+
self.dismiss(input_widget.value)
|
|
177
|
+
else:
|
|
178
|
+
self.dismiss(None)
|
|
179
|
+
|
|
180
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
181
|
+
self.dismiss(event.value)
|
|
182
|
+
|
|
183
|
+
def action_cancel(self) -> None:
|
|
184
|
+
self.dismiss(None)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class TUIStdinRedirector(io.TextIOBase):
|
|
188
|
+
"""
|
|
189
|
+
Redirects stdin reads to the TUI's input mechanism.
|
|
190
|
+
|
|
191
|
+
When code calls input() or sys.stdin.readline(), this redirector
|
|
192
|
+
will request input via the TUI's modal dialog system.
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
def __init__(self, app_ref: List[Optional['SyncApp']]):
|
|
196
|
+
super().__init__()
|
|
197
|
+
self.app_ref = app_ref
|
|
198
|
+
self._last_prompt = ""
|
|
199
|
+
|
|
200
|
+
def readable(self) -> bool:
|
|
201
|
+
return True
|
|
202
|
+
|
|
203
|
+
def writable(self) -> bool:
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
def readline(self, limit: int = -1) -> str:
|
|
207
|
+
"""Called by input() to read a line."""
|
|
208
|
+
app = self.app_ref[0] if self.app_ref else None
|
|
209
|
+
|
|
210
|
+
if app is None:
|
|
211
|
+
raise EOFError("TUI not available for input")
|
|
212
|
+
|
|
213
|
+
# Try to get input via TUI
|
|
214
|
+
try:
|
|
215
|
+
# Determine if this looks like an API key prompt
|
|
216
|
+
is_password = "api" in self._last_prompt.lower() or "key" in self._last_prompt.lower()
|
|
217
|
+
|
|
218
|
+
result = app.request_input(
|
|
219
|
+
self._last_prompt if self._last_prompt else "Input required:",
|
|
220
|
+
"Input Required",
|
|
221
|
+
default="",
|
|
222
|
+
password=is_password
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# Reset the prompt for next time
|
|
226
|
+
self._last_prompt = ""
|
|
227
|
+
|
|
228
|
+
if result is None:
|
|
229
|
+
raise EOFError("Input cancelled by user")
|
|
230
|
+
return result + '\n'
|
|
231
|
+
except Exception as e:
|
|
232
|
+
self._last_prompt = ""
|
|
233
|
+
if isinstance(e, EOFError):
|
|
234
|
+
raise
|
|
235
|
+
raise EOFError(f"TUI input failed: {e}")
|
|
236
|
+
|
|
237
|
+
def read(self, size: int = -1) -> str:
|
|
238
|
+
if size == 0:
|
|
239
|
+
return ""
|
|
240
|
+
return self.readline()
|
|
241
|
+
|
|
242
|
+
def set_prompt(self, prompt: str) -> None:
|
|
243
|
+
"""Store the prompt for the next readline call."""
|
|
244
|
+
self._last_prompt = prompt.strip()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class TUIStdoutWrapper(io.TextIOBase):
|
|
248
|
+
"""
|
|
249
|
+
Wrapper for stdout that captures prompts written before input() calls.
|
|
250
|
+
|
|
251
|
+
This allows us to detect when input() is about to be called and
|
|
252
|
+
capture the prompt text to display in the TUI input modal.
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
def __init__(self, real_redirector: 'ThreadSafeRedirector', stdin_redirector: 'TUIStdinRedirector'):
|
|
256
|
+
super().__init__()
|
|
257
|
+
self.real_redirector = real_redirector
|
|
258
|
+
self.stdin_redirector = stdin_redirector
|
|
259
|
+
|
|
260
|
+
def write(self, s: str) -> int:
|
|
261
|
+
# Capture potential prompts (text not ending in newline)
|
|
262
|
+
if s and not s.endswith('\n'):
|
|
263
|
+
self.stdin_redirector.set_prompt(s)
|
|
264
|
+
return self.real_redirector.write(s)
|
|
265
|
+
|
|
266
|
+
def flush(self) -> None:
|
|
267
|
+
self.real_redirector.flush()
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def captured_logs(self) -> List[str]:
|
|
271
|
+
return self.real_redirector.captured_logs
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class ThreadSafeRedirector(io.TextIOBase):
|
|
275
|
+
"""
|
|
276
|
+
Redirects writes to a Textual RichLog, handling ANSI codes and line buffering.
|
|
277
|
+
"""
|
|
278
|
+
def __init__(self, app: App, log: RichLog):
|
|
279
|
+
self.app = app
|
|
280
|
+
self.log_widget = log
|
|
281
|
+
self.buffer = ""
|
|
282
|
+
# Heuristic pattern for standard logging timestamp (e.g., 2025-12-02 01:20:28,193)
|
|
283
|
+
self.log_pattern = re.compile(r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}')
|
|
284
|
+
self.captured_logs = [] # Store logs for debug
|
|
285
|
+
|
|
286
|
+
def write(self, s: str) -> int:
|
|
287
|
+
if not s:
|
|
288
|
+
return 0
|
|
289
|
+
|
|
290
|
+
self.buffer += s
|
|
291
|
+
|
|
292
|
+
# Handle carriage return for in-place updates (progress bars)
|
|
293
|
+
# When buffer has \r but no \n, it's an intermediate progress update
|
|
294
|
+
# Keep only content after the last \r (ready for next update or final \n)
|
|
295
|
+
if '\r' in self.buffer and '\n' not in self.buffer:
|
|
296
|
+
self.buffer = self.buffer.rsplit('\r', 1)[-1]
|
|
297
|
+
return len(s)
|
|
298
|
+
|
|
299
|
+
# Process complete lines
|
|
300
|
+
while '\n' in self.buffer:
|
|
301
|
+
line, self.buffer = self.buffer.split('\n', 1)
|
|
302
|
+
# Handle \r within line: keep only content after last \r
|
|
303
|
+
if '\r' in line:
|
|
304
|
+
line = line.rsplit('\r', 1)[-1]
|
|
305
|
+
self.captured_logs.append(line) # Capture processed line
|
|
306
|
+
|
|
307
|
+
# Convert ANSI codes to Rich Text
|
|
308
|
+
text = Text.from_ansi(line)
|
|
309
|
+
|
|
310
|
+
# Check if the line looks like a log message and dim it
|
|
311
|
+
# We strip ANSI codes for pattern matching to ensure the regex works
|
|
312
|
+
plain_text = text.plain
|
|
313
|
+
if self.log_pattern.match(plain_text):
|
|
314
|
+
# Apply dim style to the whole text object
|
|
315
|
+
# This layers 'dim' over existing styles (like colors)
|
|
316
|
+
text.style = Style(dim=True)
|
|
317
|
+
|
|
318
|
+
self.app.call_from_thread(self.log_widget.write, text)
|
|
319
|
+
|
|
320
|
+
return len(s)
|
|
321
|
+
|
|
322
|
+
def flush(self):
|
|
323
|
+
# Write any remaining content in buffer
|
|
324
|
+
if self.buffer:
|
|
325
|
+
text = Text.from_ansi(self.buffer)
|
|
326
|
+
if self.log_pattern.match(text.plain):
|
|
327
|
+
text.style = Style(dim=True)
|
|
328
|
+
self.app.call_from_thread(self.log_widget.write, text)
|
|
329
|
+
self.buffer = ""
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class SyncApp(App):
|
|
333
|
+
"""Textual App for PDD Sync."""
|
|
334
|
+
|
|
335
|
+
CSS = """
|
|
336
|
+
Screen {
|
|
337
|
+
background: #0A0A23; /* DEEP_NAVY */
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
#animation-container {
|
|
341
|
+
height: auto;
|
|
342
|
+
dock: top;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
#progress-container {
|
|
346
|
+
height: auto;
|
|
347
|
+
padding: 0 1;
|
|
348
|
+
display: none;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
#progress-container.visible {
|
|
352
|
+
display: block;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
#progress-bar {
|
|
356
|
+
width: 100%;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
#log-container {
|
|
360
|
+
height: 1fr;
|
|
361
|
+
border: solid $primary;
|
|
362
|
+
background: #0A0A23;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
RichLog {
|
|
366
|
+
background: #0A0A23;
|
|
367
|
+
color: #00D8FF; /* ELECTRIC_CYAN */
|
|
368
|
+
padding: 0 1;
|
|
369
|
+
}
|
|
370
|
+
"""
|
|
371
|
+
|
|
372
|
+
BINDINGS = [
|
|
373
|
+
Binding("ctrl+c", "quit", "Quit"),
|
|
374
|
+
]
|
|
375
|
+
|
|
376
|
+
def __init__(
|
|
377
|
+
self,
|
|
378
|
+
basename: str,
|
|
379
|
+
budget: Optional[float],
|
|
380
|
+
worker_func: Callable[[], Any],
|
|
381
|
+
function_name_ref: List[str],
|
|
382
|
+
cost_ref: List[float],
|
|
383
|
+
prompt_path_ref: List[str],
|
|
384
|
+
code_path_ref: List[str],
|
|
385
|
+
example_path_ref: List[str],
|
|
386
|
+
tests_path_ref: List[str],
|
|
387
|
+
prompt_color_ref: List[str],
|
|
388
|
+
code_color_ref: List[str],
|
|
389
|
+
example_color_ref: List[str],
|
|
390
|
+
tests_color_ref: List[str],
|
|
391
|
+
stop_event: threading.Event,
|
|
392
|
+
progress_callback_ref: Optional[List[Optional[Callable[[int, int], None]]]] = None,
|
|
393
|
+
):
|
|
394
|
+
super().__init__()
|
|
395
|
+
self.basename = basename
|
|
396
|
+
self.budget = budget
|
|
397
|
+
self.worker_func = worker_func
|
|
398
|
+
|
|
399
|
+
# Shared state refs
|
|
400
|
+
self.function_name_ref = function_name_ref
|
|
401
|
+
self.cost_ref = cost_ref
|
|
402
|
+
self.prompt_path_ref = prompt_path_ref
|
|
403
|
+
self.code_path_ref = code_path_ref
|
|
404
|
+
self.example_path_ref = example_path_ref
|
|
405
|
+
self.tests_path_ref = tests_path_ref
|
|
406
|
+
self.prompt_color_ref = prompt_color_ref
|
|
407
|
+
self.code_color_ref = code_color_ref
|
|
408
|
+
self.example_color_ref = example_color_ref
|
|
409
|
+
self.tests_color_ref = tests_color_ref
|
|
410
|
+
self.progress_callback_ref = progress_callback_ref
|
|
411
|
+
|
|
412
|
+
self.stop_event = stop_event
|
|
413
|
+
|
|
414
|
+
# Internal animation state
|
|
415
|
+
self.animation_state = AnimationState(basename, budget)
|
|
416
|
+
|
|
417
|
+
# Result storage
|
|
418
|
+
self.worker_result = None
|
|
419
|
+
self.worker_exception = None
|
|
420
|
+
|
|
421
|
+
# Confirmation mechanism for worker thread to request user input
|
|
422
|
+
self._confirm_event = threading.Event()
|
|
423
|
+
self._confirm_result = False
|
|
424
|
+
self._confirm_message = ""
|
|
425
|
+
self._confirm_title = ""
|
|
426
|
+
|
|
427
|
+
# Input mechanism for worker thread to request text input
|
|
428
|
+
self._input_event = threading.Event()
|
|
429
|
+
self._input_result: Optional[str] = None
|
|
430
|
+
self._input_message = ""
|
|
431
|
+
self._input_title = ""
|
|
432
|
+
self._input_default = ""
|
|
433
|
+
self._input_password = False
|
|
434
|
+
|
|
435
|
+
# Logo Animation State
|
|
436
|
+
self.logo_phase = True
|
|
437
|
+
self.logo_start_time = 0.0
|
|
438
|
+
self.logo_expanded_init = False
|
|
439
|
+
self.particles = []
|
|
440
|
+
|
|
441
|
+
self.redirector = None # Will hold the redirector instance
|
|
442
|
+
self._stdin_redirector = None # Will hold stdin redirector
|
|
443
|
+
|
|
444
|
+
# Track log widget width for proper text wrapping
|
|
445
|
+
# Accounts for: log-container border (2), RichLog padding (2), scrollbar (2)
|
|
446
|
+
self._log_width = 74 # Default fallback (80 - 6)
|
|
447
|
+
|
|
448
|
+
# Reference to self for stdin redirector (using list for mutability)
|
|
449
|
+
self._app_ref: List[Optional['SyncApp']] = [None]
|
|
450
|
+
|
|
451
|
+
@property
|
|
452
|
+
def captured_logs(self) -> List[str]:
|
|
453
|
+
if self.redirector:
|
|
454
|
+
if hasattr(self.redirector, 'captured_logs'):
|
|
455
|
+
return self.redirector.captured_logs
|
|
456
|
+
elif hasattr(self.redirector, 'real_redirector'):
|
|
457
|
+
return self.redirector.real_redirector.captured_logs
|
|
458
|
+
return []
|
|
459
|
+
|
|
460
|
+
def _update_progress(self, current: int, total: int) -> None:
|
|
461
|
+
"""Update the progress bar from the worker thread.
|
|
462
|
+
|
|
463
|
+
Called by summarize_directory during auto-deps to show file processing progress.
|
|
464
|
+
Uses call_from_thread to safely update the UI from the worker thread.
|
|
465
|
+
"""
|
|
466
|
+
def _do_update():
|
|
467
|
+
# Show progress container if hidden
|
|
468
|
+
if "visible" not in self.progress_container.classes:
|
|
469
|
+
self.progress_container.add_class("visible")
|
|
470
|
+
|
|
471
|
+
# Update progress bar
|
|
472
|
+
self.progress_bar.update(total=total, progress=current)
|
|
473
|
+
|
|
474
|
+
# Hide when complete
|
|
475
|
+
if current >= total:
|
|
476
|
+
self.progress_container.remove_class("visible")
|
|
477
|
+
|
|
478
|
+
self.call_from_thread(_do_update)
|
|
479
|
+
|
|
480
|
+
def compose(self) -> ComposeResult:
|
|
481
|
+
yield Container(Static(id="animation-view"), id="animation-container")
|
|
482
|
+
yield Container(ProgressBar(id="progress-bar", show_eta=False), id="progress-container")
|
|
483
|
+
yield Container(RichLog(highlight=True, markup=True, wrap=True, id="log"), id="log-container")
|
|
484
|
+
|
|
485
|
+
def on_mount(self) -> None:
|
|
486
|
+
self.log_widget = self.query_one("#log", RichLog)
|
|
487
|
+
self.progress_bar = self.query_one("#progress-bar", ProgressBar)
|
|
488
|
+
self.progress_container = self.query_one("#progress-container", Container)
|
|
489
|
+
|
|
490
|
+
# Set up progress callback if ref is available
|
|
491
|
+
if self.progress_callback_ref is not None:
|
|
492
|
+
self.progress_callback_ref[0] = self._update_progress
|
|
493
|
+
self.animation_view = self.query_one("#animation-view", Static)
|
|
494
|
+
|
|
495
|
+
# Initialize Logo Particles
|
|
496
|
+
local_ascii_logo_art = logo_animation.ASCII_LOGO_ART
|
|
497
|
+
if isinstance(local_ascii_logo_art, str):
|
|
498
|
+
local_ascii_logo_art = local_ascii_logo_art.strip().splitlines()
|
|
499
|
+
|
|
500
|
+
self.particles = logo_animation._parse_logo_art(local_ascii_logo_art)
|
|
501
|
+
|
|
502
|
+
# Set initial styles and formation targets
|
|
503
|
+
width = self.size.width if self.size.width > 0 else 80
|
|
504
|
+
height = 18 # Fixed animation height
|
|
505
|
+
|
|
506
|
+
for p in self.particles:
|
|
507
|
+
p.style = Style(color=logo_animation.ELECTRIC_CYAN)
|
|
508
|
+
|
|
509
|
+
logo_target_positions = logo_animation._get_centered_logo_positions(
|
|
510
|
+
self.particles, local_ascii_logo_art, width, height
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
for i, p in enumerate(self.particles):
|
|
514
|
+
p.start_x = 0.0
|
|
515
|
+
p.start_y = float(height - 1)
|
|
516
|
+
p.current_x, p.current_y = p.start_x, p.start_y
|
|
517
|
+
p.target_x, p.target_y = float(logo_target_positions[i][0]), float(logo_target_positions[i][1])
|
|
518
|
+
|
|
519
|
+
self.logo_start_time = time.monotonic()
|
|
520
|
+
|
|
521
|
+
# Start animation timer (20 FPS for smoother logo)
|
|
522
|
+
self.set_interval(0.05, self.update_animation)
|
|
523
|
+
|
|
524
|
+
# Calculate initial log width based on current size
|
|
525
|
+
if self.size.width > 0:
|
|
526
|
+
self._log_width = max(20, self.size.width - 6)
|
|
527
|
+
|
|
528
|
+
# Start worker
|
|
529
|
+
self.run_worker_task()
|
|
530
|
+
|
|
531
|
+
@work(thread=True)
|
|
532
|
+
def run_worker_task(self) -> None:
|
|
533
|
+
"""Runs the sync logic in a separate thread, capturing stdout/stderr/stdin."""
|
|
534
|
+
|
|
535
|
+
# Set app reference for stdin redirector
|
|
536
|
+
self._app_ref[0] = self
|
|
537
|
+
|
|
538
|
+
# Save original environment values to restore later
|
|
539
|
+
# This prevents subprocess from inheriting TUI-specific env vars
|
|
540
|
+
original_force_color = os.environ.get("FORCE_COLOR")
|
|
541
|
+
original_term = os.environ.get("TERM")
|
|
542
|
+
original_columns = os.environ.get("COLUMNS")
|
|
543
|
+
|
|
544
|
+
# Force Rich and other tools to output ANSI colors
|
|
545
|
+
os.environ["FORCE_COLOR"] = "1"
|
|
546
|
+
# Some tools check TERM
|
|
547
|
+
os.environ["TERM"] = "xterm-256color"
|
|
548
|
+
# Set COLUMNS so Rich Console/Panels render at log widget width, not terminal width
|
|
549
|
+
# This must be set before any code imports/creates Rich Console objects
|
|
550
|
+
os.environ["COLUMNS"] = str(self._log_width)
|
|
551
|
+
|
|
552
|
+
# Capture stdout/stderr/stdin
|
|
553
|
+
original_stdout = sys.stdout
|
|
554
|
+
original_stderr = sys.stderr
|
|
555
|
+
original_stdin = sys.stdin
|
|
556
|
+
|
|
557
|
+
# Create redirectors
|
|
558
|
+
base_redirector = ThreadSafeRedirector(self, self.log_widget)
|
|
559
|
+
self._stdin_redirector = TUIStdinRedirector(self._app_ref)
|
|
560
|
+
|
|
561
|
+
# Wrap stdout to capture prompts for input() calls
|
|
562
|
+
self.redirector = TUIStdoutWrapper(base_redirector, self._stdin_redirector)
|
|
563
|
+
|
|
564
|
+
sys.stdout = self.redirector
|
|
565
|
+
sys.stderr = base_redirector # stderr doesn't need prompt capture
|
|
566
|
+
sys.stdin = self._stdin_redirector
|
|
567
|
+
|
|
568
|
+
try:
|
|
569
|
+
self.worker_result = self.worker_func()
|
|
570
|
+
except EOFError as e:
|
|
571
|
+
# Handle EOF from stdin redirector - input was needed but cancelled/failed
|
|
572
|
+
self.worker_exception = e
|
|
573
|
+
self.call_from_thread(
|
|
574
|
+
self.log_widget.write,
|
|
575
|
+
f"[bold yellow]Input required but not provided: {e}[/bold yellow]\n"
|
|
576
|
+
"[dim]Hint: Ensure API keys are configured in environment or .env file[/dim]"
|
|
577
|
+
)
|
|
578
|
+
self.worker_result = {
|
|
579
|
+
"success": False,
|
|
580
|
+
"total_cost": 0.0,
|
|
581
|
+
"model_name": "",
|
|
582
|
+
"error": f"Input required: {e}",
|
|
583
|
+
"operations_completed": [],
|
|
584
|
+
"errors": [f"EOFError: {e}"]
|
|
585
|
+
}
|
|
586
|
+
except BaseException as e:
|
|
587
|
+
self.worker_exception = e
|
|
588
|
+
# Print to widget
|
|
589
|
+
self.call_from_thread(self.log_widget.write, f"[bold red]Error in sync worker: {e}[/bold red]")
|
|
590
|
+
# Print to original stderr so it's visible after TUI closes
|
|
591
|
+
print(f"\nError in sync worker thread: {type(e).__name__}: {e}", file=original_stderr)
|
|
592
|
+
import traceback
|
|
593
|
+
traceback.print_exc(file=original_stderr)
|
|
594
|
+
|
|
595
|
+
# Create a failure result so the app returns something meaningful
|
|
596
|
+
self.worker_result = {
|
|
597
|
+
"success": False,
|
|
598
|
+
"total_cost": 0.0,
|
|
599
|
+
"model_name": "",
|
|
600
|
+
"error": str(e),
|
|
601
|
+
"operations_completed": [],
|
|
602
|
+
"errors": [f"{type(e).__name__}: {e}"]
|
|
603
|
+
}
|
|
604
|
+
finally:
|
|
605
|
+
sys.stdout = original_stdout
|
|
606
|
+
sys.stderr = original_stderr
|
|
607
|
+
sys.stdin = original_stdin
|
|
608
|
+
self._app_ref[0] = None
|
|
609
|
+
|
|
610
|
+
# Restore original environment values
|
|
611
|
+
# This is critical to prevent subprocess contamination
|
|
612
|
+
if original_force_color is not None:
|
|
613
|
+
os.environ["FORCE_COLOR"] = original_force_color
|
|
614
|
+
elif "FORCE_COLOR" in os.environ:
|
|
615
|
+
del os.environ["FORCE_COLOR"]
|
|
616
|
+
|
|
617
|
+
if original_term is not None:
|
|
618
|
+
os.environ["TERM"] = original_term
|
|
619
|
+
elif "TERM" in os.environ:
|
|
620
|
+
del os.environ["TERM"]
|
|
621
|
+
|
|
622
|
+
if original_columns is not None:
|
|
623
|
+
os.environ["COLUMNS"] = original_columns
|
|
624
|
+
elif "COLUMNS" in os.environ:
|
|
625
|
+
del os.environ["COLUMNS"]
|
|
626
|
+
|
|
627
|
+
# Force flush any remaining buffer
|
|
628
|
+
try:
|
|
629
|
+
if hasattr(self.redirector, 'flush'):
|
|
630
|
+
self.redirector.flush()
|
|
631
|
+
except Exception:
|
|
632
|
+
pass
|
|
633
|
+
self.call_from_thread(self.exit, result=self.worker_result)
|
|
634
|
+
|
|
635
|
+
def update_animation(self) -> None:
|
|
636
|
+
"""Updates the animation frame based on current shared state."""
|
|
637
|
+
if self.stop_event.is_set():
|
|
638
|
+
return
|
|
639
|
+
|
|
640
|
+
# We need the width of the app/screen.
|
|
641
|
+
width = self.size.width
|
|
642
|
+
if width == 0: # Not ready yet
|
|
643
|
+
width = 80
|
|
644
|
+
|
|
645
|
+
# Update log width and COLUMNS env var for resize handling
|
|
646
|
+
# This ensures Rich Panels created after resize use the new width
|
|
647
|
+
# Offset of 6 accounts for: border (2), padding (2), scrollbar (2)
|
|
648
|
+
new_log_width = max(20, width - 6)
|
|
649
|
+
if new_log_width != self._log_width:
|
|
650
|
+
self._log_width = new_log_width
|
|
651
|
+
os.environ["COLUMNS"] = str(self._log_width)
|
|
652
|
+
|
|
653
|
+
# --- LOGO ANIMATION PHASE ---
|
|
654
|
+
if self.logo_phase:
|
|
655
|
+
current_time = time.monotonic()
|
|
656
|
+
elapsed = current_time - self.logo_start_time
|
|
657
|
+
|
|
658
|
+
formation_dur = logo_animation.LOGO_FORMATION_DURATION or 0.1
|
|
659
|
+
hold_dur = logo_animation.LOGO_HOLD_DURATION or 0.1
|
|
660
|
+
expand_dur = logo_animation.LOGO_TO_BOX_TRANSITION_DURATION or 0.1
|
|
661
|
+
|
|
662
|
+
if elapsed < formation_dur:
|
|
663
|
+
# Formation
|
|
664
|
+
progress = elapsed / formation_dur
|
|
665
|
+
for p in self.particles: p.update_progress(progress)
|
|
666
|
+
elif elapsed < formation_dur + hold_dur:
|
|
667
|
+
# Hold
|
|
668
|
+
for p in self.particles: p.update_progress(1.0)
|
|
669
|
+
elif elapsed < formation_dur + hold_dur + expand_dur:
|
|
670
|
+
# Expansion
|
|
671
|
+
if not self.logo_expanded_init:
|
|
672
|
+
box_targets = logo_animation._get_box_perimeter_positions(self.particles, width, 18)
|
|
673
|
+
for i, p in enumerate(self.particles):
|
|
674
|
+
p.set_new_transition(float(box_targets[i][0]), float(box_targets[i][1]))
|
|
675
|
+
self.logo_expanded_init = True
|
|
676
|
+
|
|
677
|
+
expand_elapsed = elapsed - (formation_dur + hold_dur)
|
|
678
|
+
progress = expand_elapsed / expand_dur
|
|
679
|
+
for p in self.particles: p.update_progress(progress)
|
|
680
|
+
else:
|
|
681
|
+
# Logo animation done, switch to main UI
|
|
682
|
+
self.logo_phase = False
|
|
683
|
+
# Fall through to render main UI immediately
|
|
684
|
+
|
|
685
|
+
if self.logo_phase:
|
|
686
|
+
frame = logo_animation._render_particles_to_text(self.particles, width, 18)
|
|
687
|
+
self.animation_view.update(frame)
|
|
688
|
+
return
|
|
689
|
+
|
|
690
|
+
# --- MAIN SYNC ANIMATION PHASE ---
|
|
691
|
+
|
|
692
|
+
# Update state from refs
|
|
693
|
+
current_func_name = self.function_name_ref[0] if self.function_name_ref else "checking"
|
|
694
|
+
current_cost = self.cost_ref[0] if self.cost_ref else 0.0
|
|
695
|
+
|
|
696
|
+
current_prompt_path = self.prompt_path_ref[0] if self.prompt_path_ref else ""
|
|
697
|
+
current_code_path = self.code_path_ref[0] if self.code_path_ref else ""
|
|
698
|
+
current_example_path = self.example_path_ref[0] if self.example_path_ref else ""
|
|
699
|
+
current_tests_path = self.tests_path_ref[0] if self.tests_path_ref else ""
|
|
700
|
+
|
|
701
|
+
self.animation_state.set_box_colors(
|
|
702
|
+
self.prompt_color_ref[0] if self.prompt_color_ref else "",
|
|
703
|
+
self.code_color_ref[0] if self.code_color_ref else "",
|
|
704
|
+
self.example_color_ref[0] if self.example_color_ref else "",
|
|
705
|
+
self.tests_color_ref[0] if self.tests_color_ref else ""
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
self.animation_state.update_dynamic_state(
|
|
709
|
+
current_func_name, current_cost,
|
|
710
|
+
current_prompt_path, current_code_path,
|
|
711
|
+
current_example_path, current_tests_path
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
frame = _render_animation_frame(self.animation_state, width)
|
|
715
|
+
self.animation_view.update(frame)
|
|
716
|
+
|
|
717
|
+
def request_confirmation(self, message: str, title: str = "Confirmation Required") -> bool:
|
|
718
|
+
"""
|
|
719
|
+
Request user confirmation from the worker thread.
|
|
720
|
+
|
|
721
|
+
This method is thread-safe and can be called from the worker thread.
|
|
722
|
+
It will block until the user responds to the modal dialog.
|
|
723
|
+
|
|
724
|
+
Args:
|
|
725
|
+
message: The confirmation message to display
|
|
726
|
+
title: The title of the confirmation dialog
|
|
727
|
+
|
|
728
|
+
Returns:
|
|
729
|
+
True if user confirmed, False otherwise
|
|
730
|
+
"""
|
|
731
|
+
self._confirm_event.clear()
|
|
732
|
+
self._confirm_result = False
|
|
733
|
+
self._confirm_message = message
|
|
734
|
+
self._confirm_title = title
|
|
735
|
+
|
|
736
|
+
def schedule_modal():
|
|
737
|
+
"""Called on main thread via call_from_thread."""
|
|
738
|
+
# Create task to show modal - we're on the main thread with running event loop
|
|
739
|
+
asyncio.create_task(self._show_confirm_modal_async())
|
|
740
|
+
|
|
741
|
+
# Schedule on main thread using Textual's thread-safe mechanism
|
|
742
|
+
self.call_from_thread(schedule_modal)
|
|
743
|
+
|
|
744
|
+
# Block worker thread until user responds (with timeout to prevent infinite hang)
|
|
745
|
+
if not self._confirm_event.wait(timeout=300): # 5 minute timeout
|
|
746
|
+
# Timeout - default to False (don't proceed)
|
|
747
|
+
return False
|
|
748
|
+
|
|
749
|
+
return self._confirm_result
|
|
750
|
+
|
|
751
|
+
async def _show_confirm_modal_async(self) -> None:
|
|
752
|
+
"""Async method to show the confirmation modal."""
|
|
753
|
+
try:
|
|
754
|
+
result = await self.push_screen_wait(
|
|
755
|
+
ConfirmScreen(self._confirm_message, self._confirm_title)
|
|
756
|
+
)
|
|
757
|
+
self._confirm_result = result
|
|
758
|
+
except Exception as e:
|
|
759
|
+
# If modal fails, default to True to not block workflow
|
|
760
|
+
print(f"Confirmation modal error: {e}", file=sys.__stderr__)
|
|
761
|
+
self._confirm_result = True
|
|
762
|
+
finally:
|
|
763
|
+
self._confirm_event.set()
|
|
764
|
+
|
|
765
|
+
def request_input(self, message: str, title: str = "Input Required",
|
|
766
|
+
default: str = "", password: bool = False) -> Optional[str]:
|
|
767
|
+
"""
|
|
768
|
+
Request text input from the worker thread.
|
|
769
|
+
|
|
770
|
+
This method is thread-safe and can be called from the worker thread.
|
|
771
|
+
It will block until the user provides input or cancels.
|
|
772
|
+
|
|
773
|
+
Args:
|
|
774
|
+
message: The input prompt message
|
|
775
|
+
title: The title of the input dialog
|
|
776
|
+
default: Default value for the input field
|
|
777
|
+
password: If True, mask the input (for passwords/API keys)
|
|
778
|
+
|
|
779
|
+
Returns:
|
|
780
|
+
The user's input string, or None if cancelled
|
|
781
|
+
"""
|
|
782
|
+
self._input_event.clear()
|
|
783
|
+
self._input_result = None
|
|
784
|
+
self._input_message = message
|
|
785
|
+
self._input_title = title
|
|
786
|
+
self._input_default = default
|
|
787
|
+
self._input_password = password
|
|
788
|
+
|
|
789
|
+
def schedule_modal():
|
|
790
|
+
"""Called on main thread via call_from_thread."""
|
|
791
|
+
asyncio.create_task(self._show_input_modal_async())
|
|
792
|
+
|
|
793
|
+
# Schedule on main thread
|
|
794
|
+
self.call_from_thread(schedule_modal)
|
|
795
|
+
|
|
796
|
+
# Block worker thread until user responds (with timeout)
|
|
797
|
+
if not self._input_event.wait(timeout=300): # 5 minute timeout
|
|
798
|
+
return None
|
|
799
|
+
|
|
800
|
+
return self._input_result
|
|
801
|
+
|
|
802
|
+
async def _show_input_modal_async(self) -> None:
|
|
803
|
+
"""Async method to show the input modal."""
|
|
804
|
+
try:
|
|
805
|
+
result = await self.push_screen_wait(
|
|
806
|
+
InputScreen(
|
|
807
|
+
self._input_message,
|
|
808
|
+
self._input_title,
|
|
809
|
+
self._input_default,
|
|
810
|
+
self._input_password
|
|
811
|
+
)
|
|
812
|
+
)
|
|
813
|
+
self._input_result = result
|
|
814
|
+
except Exception as e:
|
|
815
|
+
print(f"Input modal error: {e}", file=sys.__stderr__)
|
|
816
|
+
self._input_result = None
|
|
817
|
+
finally:
|
|
818
|
+
self._input_event.set()
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def show_exit_animation():
|
|
822
|
+
"""Shows the exit logo animation."""
|
|
823
|
+
from .logo_animation import ASCII_LOGO_ART, ELECTRIC_CYAN, DEEP_NAVY
|
|
824
|
+
|
|
825
|
+
logo_lines = ASCII_LOGO_ART
|
|
826
|
+
if isinstance(logo_lines, str):
|
|
827
|
+
logo_lines = logo_lines.strip().splitlines()
|
|
828
|
+
|
|
829
|
+
# Calculate dimensions from raw lines to ensure panel fits
|
|
830
|
+
max_width = max(len(line) for line in logo_lines) if logo_lines else 0
|
|
831
|
+
|
|
832
|
+
console = Console()
|
|
833
|
+
console.clear()
|
|
834
|
+
|
|
835
|
+
# Join lines as-is to preserve ASCII shape
|
|
836
|
+
logo_content = "\n".join(logo_lines)
|
|
837
|
+
|
|
838
|
+
logo_panel = Panel(
|
|
839
|
+
Text(logo_content, justify="left"), # Ensure left alignment within the block
|
|
840
|
+
style=f"bold {ELECTRIC_CYAN} on {DEEP_NAVY}",
|
|
841
|
+
border_style=ELECTRIC_CYAN,
|
|
842
|
+
padding=(1, 4), # Add padding (top/bottom, right/left) inside the border
|
|
843
|
+
expand=False # Shrink panel to fit content
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
console.print(Align.center(logo_panel))
|
|
847
|
+
time.sleep(1.0)
|
|
848
|
+
console.clear()
|