pdd-cli 0.0.45__py3-none-any.whl → 0.0.90__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 (114) hide show
  1. pdd/__init__.py +4 -4
  2. pdd/agentic_common.py +863 -0
  3. pdd/agentic_crash.py +534 -0
  4. pdd/agentic_fix.py +1179 -0
  5. pdd/agentic_langtest.py +162 -0
  6. pdd/agentic_update.py +370 -0
  7. pdd/agentic_verify.py +183 -0
  8. pdd/auto_deps_main.py +15 -5
  9. pdd/auto_include.py +63 -5
  10. pdd/bug_main.py +3 -2
  11. pdd/bug_to_unit_test.py +2 -0
  12. pdd/change_main.py +11 -4
  13. pdd/cli.py +22 -1181
  14. pdd/cmd_test_main.py +73 -21
  15. pdd/code_generator.py +58 -18
  16. pdd/code_generator_main.py +672 -25
  17. pdd/commands/__init__.py +42 -0
  18. pdd/commands/analysis.py +248 -0
  19. pdd/commands/fix.py +140 -0
  20. pdd/commands/generate.py +257 -0
  21. pdd/commands/maintenance.py +174 -0
  22. pdd/commands/misc.py +79 -0
  23. pdd/commands/modify.py +230 -0
  24. pdd/commands/report.py +144 -0
  25. pdd/commands/templates.py +215 -0
  26. pdd/commands/utility.py +110 -0
  27. pdd/config_resolution.py +58 -0
  28. pdd/conflicts_main.py +8 -3
  29. pdd/construct_paths.py +258 -82
  30. pdd/context_generator.py +10 -2
  31. pdd/context_generator_main.py +113 -11
  32. pdd/continue_generation.py +47 -7
  33. pdd/core/__init__.py +0 -0
  34. pdd/core/cli.py +503 -0
  35. pdd/core/dump.py +554 -0
  36. pdd/core/errors.py +63 -0
  37. pdd/core/utils.py +90 -0
  38. pdd/crash_main.py +44 -11
  39. pdd/data/language_format.csv +71 -63
  40. pdd/data/llm_model.csv +20 -18
  41. pdd/detect_change_main.py +5 -4
  42. pdd/fix_code_loop.py +330 -76
  43. pdd/fix_error_loop.py +207 -61
  44. pdd/fix_errors_from_unit_tests.py +4 -3
  45. pdd/fix_main.py +75 -18
  46. pdd/fix_verification_errors.py +12 -100
  47. pdd/fix_verification_errors_loop.py +306 -272
  48. pdd/fix_verification_main.py +28 -9
  49. pdd/generate_output_paths.py +93 -10
  50. pdd/generate_test.py +16 -5
  51. pdd/get_jwt_token.py +9 -2
  52. pdd/get_run_command.py +73 -0
  53. pdd/get_test_command.py +68 -0
  54. pdd/git_update.py +70 -19
  55. pdd/incremental_code_generator.py +2 -2
  56. pdd/insert_includes.py +11 -3
  57. pdd/llm_invoke.py +1269 -103
  58. pdd/load_prompt_template.py +36 -10
  59. pdd/pdd_completion.fish +25 -2
  60. pdd/pdd_completion.sh +30 -4
  61. pdd/pdd_completion.zsh +79 -4
  62. pdd/postprocess.py +10 -3
  63. pdd/preprocess.py +228 -15
  64. pdd/preprocess_main.py +8 -5
  65. pdd/prompts/agentic_crash_explore_LLM.prompt +49 -0
  66. pdd/prompts/agentic_fix_explore_LLM.prompt +45 -0
  67. pdd/prompts/agentic_fix_harvest_only_LLM.prompt +48 -0
  68. pdd/prompts/agentic_fix_primary_LLM.prompt +85 -0
  69. pdd/prompts/agentic_update_LLM.prompt +1071 -0
  70. pdd/prompts/agentic_verify_explore_LLM.prompt +45 -0
  71. pdd/prompts/auto_include_LLM.prompt +100 -905
  72. pdd/prompts/detect_change_LLM.prompt +122 -20
  73. pdd/prompts/example_generator_LLM.prompt +22 -1
  74. pdd/prompts/extract_code_LLM.prompt +5 -1
  75. pdd/prompts/extract_program_code_fix_LLM.prompt +7 -1
  76. pdd/prompts/extract_prompt_update_LLM.prompt +7 -8
  77. pdd/prompts/extract_promptline_LLM.prompt +17 -11
  78. pdd/prompts/find_verification_errors_LLM.prompt +6 -0
  79. pdd/prompts/fix_code_module_errors_LLM.prompt +4 -2
  80. pdd/prompts/fix_errors_from_unit_tests_LLM.prompt +8 -0
  81. pdd/prompts/fix_verification_errors_LLM.prompt +22 -0
  82. pdd/prompts/generate_test_LLM.prompt +21 -6
  83. pdd/prompts/increase_tests_LLM.prompt +1 -5
  84. pdd/prompts/insert_includes_LLM.prompt +228 -108
  85. pdd/prompts/trace_LLM.prompt +25 -22
  86. pdd/prompts/unfinished_prompt_LLM.prompt +85 -1
  87. pdd/prompts/update_prompt_LLM.prompt +22 -1
  88. pdd/pytest_output.py +127 -12
  89. pdd/render_mermaid.py +236 -0
  90. pdd/setup_tool.py +648 -0
  91. pdd/simple_math.py +2 -0
  92. pdd/split_main.py +3 -2
  93. pdd/summarize_directory.py +49 -6
  94. pdd/sync_determine_operation.py +543 -98
  95. pdd/sync_main.py +81 -31
  96. pdd/sync_orchestration.py +1334 -751
  97. pdd/sync_tui.py +848 -0
  98. pdd/template_registry.py +264 -0
  99. pdd/templates/architecture/architecture_json.prompt +242 -0
  100. pdd/templates/generic/generate_prompt.prompt +174 -0
  101. pdd/trace.py +168 -12
  102. pdd/trace_main.py +4 -3
  103. pdd/track_cost.py +151 -61
  104. pdd/unfinished_prompt.py +49 -3
  105. pdd/update_main.py +549 -67
  106. pdd/update_model_costs.py +2 -2
  107. pdd/update_prompt.py +19 -4
  108. {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.90.dist-info}/METADATA +19 -6
  109. pdd_cli-0.0.90.dist-info/RECORD +153 -0
  110. {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.90.dist-info}/licenses/LICENSE +1 -1
  111. pdd_cli-0.0.45.dist-info/RECORD +0 -116
  112. {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.90.dist-info}/WHEEL +0 -0
  113. {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.90.dist-info}/entry_points.txt +0 -0
  114. {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.90.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()