ferp 0.7.1__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 (87) hide show
  1. ferp/__init__.py +3 -0
  2. ferp/__main__.py +4 -0
  3. ferp/__version__.py +1 -0
  4. ferp/app.py +9 -0
  5. ferp/cli.py +160 -0
  6. ferp/core/__init__.py +0 -0
  7. ferp/core/app.py +1312 -0
  8. ferp/core/bundle_installer.py +245 -0
  9. ferp/core/command_provider.py +77 -0
  10. ferp/core/dependency_manager.py +59 -0
  11. ferp/core/fs_controller.py +70 -0
  12. ferp/core/fs_watcher.py +144 -0
  13. ferp/core/messages.py +49 -0
  14. ferp/core/path_actions.py +124 -0
  15. ferp/core/paths.py +3 -0
  16. ferp/core/protocols.py +8 -0
  17. ferp/core/script_controller.py +515 -0
  18. ferp/core/script_protocol.py +35 -0
  19. ferp/core/script_runner.py +421 -0
  20. ferp/core/settings.py +16 -0
  21. ferp/core/settings_store.py +69 -0
  22. ferp/core/state.py +156 -0
  23. ferp/core/task_store.py +164 -0
  24. ferp/core/transcript_logger.py +95 -0
  25. ferp/domain/__init__.py +0 -0
  26. ferp/domain/scripts.py +29 -0
  27. ferp/fscp/host/__init__.py +11 -0
  28. ferp/fscp/host/host.py +439 -0
  29. ferp/fscp/host/managed_process.py +113 -0
  30. ferp/fscp/host/process_registry.py +124 -0
  31. ferp/fscp/protocol/__init__.py +13 -0
  32. ferp/fscp/protocol/errors.py +2 -0
  33. ferp/fscp/protocol/messages.py +55 -0
  34. ferp/fscp/protocol/schemas/__init__.py +0 -0
  35. ferp/fscp/protocol/schemas/fscp/1.0/cancel.json +16 -0
  36. ferp/fscp/protocol/schemas/fscp/1.0/definitions.json +29 -0
  37. ferp/fscp/protocol/schemas/fscp/1.0/discriminator.json +14 -0
  38. ferp/fscp/protocol/schemas/fscp/1.0/envelope.json +13 -0
  39. ferp/fscp/protocol/schemas/fscp/1.0/exit.json +20 -0
  40. ferp/fscp/protocol/schemas/fscp/1.0/init.json +36 -0
  41. ferp/fscp/protocol/schemas/fscp/1.0/input_response.json +21 -0
  42. ferp/fscp/protocol/schemas/fscp/1.0/log.json +21 -0
  43. ferp/fscp/protocol/schemas/fscp/1.0/message.json +23 -0
  44. ferp/fscp/protocol/schemas/fscp/1.0/progress.json +23 -0
  45. ferp/fscp/protocol/schemas/fscp/1.0/request_input.json +47 -0
  46. ferp/fscp/protocol/schemas/fscp/1.0/result.json +16 -0
  47. ferp/fscp/protocol/schemas/fscp/__init__.py +0 -0
  48. ferp/fscp/protocol/state.py +16 -0
  49. ferp/fscp/protocol/validator.py +123 -0
  50. ferp/fscp/scripts/__init__.py +0 -0
  51. ferp/fscp/scripts/runtime/__init__.py +4 -0
  52. ferp/fscp/scripts/runtime/__main__.py +40 -0
  53. ferp/fscp/scripts/runtime/errors.py +14 -0
  54. ferp/fscp/scripts/runtime/io.py +64 -0
  55. ferp/fscp/scripts/runtime/script.py +149 -0
  56. ferp/fscp/scripts/runtime/state.py +17 -0
  57. ferp/fscp/scripts/runtime/worker.py +13 -0
  58. ferp/fscp/scripts/sdk.py +548 -0
  59. ferp/fscp/transcript/__init__.py +3 -0
  60. ferp/fscp/transcript/events.py +14 -0
  61. ferp/resources/__init__.py +0 -0
  62. ferp/services/__init__.py +3 -0
  63. ferp/services/file_listing.py +120 -0
  64. ferp/services/monday_sync.py +155 -0
  65. ferp/services/releases.py +214 -0
  66. ferp/services/scripts.py +90 -0
  67. ferp/services/update_check.py +130 -0
  68. ferp/styles/index.tcss +638 -0
  69. ferp/themes/themes.py +238 -0
  70. ferp/widgets/__init__.py +17 -0
  71. ferp/widgets/dialogs.py +167 -0
  72. ferp/widgets/file_tree.py +991 -0
  73. ferp/widgets/forms.py +146 -0
  74. ferp/widgets/output_panel.py +244 -0
  75. ferp/widgets/panels.py +13 -0
  76. ferp/widgets/process_list.py +158 -0
  77. ferp/widgets/readme_modal.py +59 -0
  78. ferp/widgets/scripts.py +192 -0
  79. ferp/widgets/task_capture.py +74 -0
  80. ferp/widgets/task_list.py +493 -0
  81. ferp/widgets/top_bar.py +110 -0
  82. ferp-0.7.1.dist-info/METADATA +128 -0
  83. ferp-0.7.1.dist-info/RECORD +87 -0
  84. ferp-0.7.1.dist-info/WHEEL +5 -0
  85. ferp-0.7.1.dist-info/entry_points.txt +2 -0
  86. ferp-0.7.1.dist-info/licenses/LICENSE +21 -0
  87. ferp-0.7.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Callable
5
+
6
+ from ferp.core.fs_controller import FileSystemController
7
+ from ferp.widgets.dialogs import ConfirmDialog, InputDialog
8
+
9
+
10
+ class PathActionController:
11
+ """Orchestrates file/directory creation and deletion prompts."""
12
+
13
+ def __init__(
14
+ self,
15
+ *,
16
+ present_input: Callable[[InputDialog, Callable[[str | None], None]], None],
17
+ present_confirm: Callable[[ConfirmDialog, Callable[[bool | None], None]], None],
18
+ show_error: Callable[[BaseException], None],
19
+ refresh_listing: Callable[[], None],
20
+ fs_controller: FileSystemController,
21
+ delete_handler: Callable[[Path], None],
22
+ ) -> None:
23
+ self._present_input = present_input
24
+ self._present_confirm = present_confirm
25
+ self._show_error = show_error
26
+ self._refresh_listing = refresh_listing
27
+ self._fs = fs_controller
28
+ self._delete_handler = delete_handler
29
+
30
+ def create_path(self, base: Path, *, is_directory: bool) -> None:
31
+ parent = base if base.is_dir() else base.parent
32
+ default_name = "New Folder" if is_directory else "New File.txt"
33
+
34
+ def after(name: str | None) -> None:
35
+ if not name:
36
+ return
37
+ target = parent / name
38
+
39
+ def perform(overwrite: bool) -> None:
40
+ try:
41
+ self._fs.create_path(
42
+ target,
43
+ is_directory=is_directory,
44
+ overwrite=overwrite,
45
+ )
46
+ except Exception as exc:
47
+ self._show_error(exc)
48
+ return
49
+ self._refresh_listing()
50
+
51
+ if target.exists():
52
+ self._present_confirm(
53
+ ConfirmDialog(f"'{target.name}' exists. Overwrite?"),
54
+ lambda confirmed: perform(True) if confirmed else None,
55
+ )
56
+ return
57
+
58
+ perform(False)
59
+
60
+ self._present_input(
61
+ InputDialog("Enter name", default=default_name),
62
+ after,
63
+ )
64
+
65
+ def delete_path(self, target: Path) -> None:
66
+ if not target.exists():
67
+ return
68
+
69
+ def after(confirmed: bool | None) -> None:
70
+ if not confirmed:
71
+ return
72
+ self._delete_handler(target)
73
+
74
+ self._present_confirm(
75
+ ConfirmDialog(f"Delete '{target.name}'?"),
76
+ after,
77
+ )
78
+
79
+ def rename_path(self, target: Path) -> None:
80
+ if not target.exists():
81
+ return
82
+
83
+ name = target.name
84
+ if name.endswith("."):
85
+ suffix = ""
86
+ else:
87
+ suffix = target.suffix
88
+ stem = name[: -len(suffix)] if suffix else name
89
+
90
+ def perform(overwrite: bool) -> None:
91
+ try:
92
+ self._fs.rename_path(target, destination, overwrite=overwrite)
93
+ except Exception as exc:
94
+ self._show_error(exc)
95
+ return
96
+ self._refresh_listing()
97
+
98
+ def after(name: str | None) -> None:
99
+ if not name:
100
+ return
101
+ nonlocal destination
102
+ new_name = name
103
+ if suffix:
104
+ if not new_name.endswith(suffix):
105
+ new_name = f"{new_name}{suffix}"
106
+ destination = target.with_name(new_name)
107
+ if destination == target:
108
+ return
109
+
110
+ if destination.exists():
111
+ self._present_confirm(
112
+ ConfirmDialog(f"'{destination.name}' exists. Overwrite?"),
113
+ lambda confirmed: perform(True) if confirmed else None,
114
+ )
115
+ return
116
+
117
+ perform(False)
118
+
119
+ destination = target
120
+ default_name = stem
121
+ self._present_input(
122
+ InputDialog("Enter new name", default=default_name),
123
+ after,
124
+ )
ferp/core/paths.py ADDED
@@ -0,0 +1,3 @@
1
+ APP_NAME = "ferp"
2
+ APP_AUTHOR = "zappbrandigan"
3
+ SCRIPTS_REPO_URL = "https://github.com/zappbrandigan/ferp-scripts"
ferp/core/protocols.py ADDED
@@ -0,0 +1,8 @@
1
+ from pathlib import Path
2
+ from typing import Protocol
3
+
4
+
5
+ class AppWithPath(Protocol):
6
+ current_path: Path
7
+
8
+ def resolve_startup_path(self) -> Path: ...
@@ -0,0 +1,515 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, Any, Callable
7
+
8
+ from textual.worker import Worker, WorkerState
9
+
10
+ from ferp.core.script_runner import (
11
+ ScriptInputRequest,
12
+ ScriptResult,
13
+ ScriptRunner,
14
+ ScriptStatus,
15
+ )
16
+ from ferp.domain.scripts import Script
17
+ from ferp.services.scripts import ScriptExecutionContext
18
+ from ferp.widgets.dialogs import ConfirmDialog
19
+ from ferp.widgets.file_tree import FileTree
20
+ from ferp.widgets.forms import BooleanField, PromptDialog, SelectionField
21
+ from ferp.widgets.scripts import ScriptManager
22
+
23
+ if TYPE_CHECKING:
24
+ from ferp.core.app import Ferp
25
+
26
+
27
+ class ScriptLifecycleController:
28
+ """Coordinates script execution, prompts, and progress UI."""
29
+
30
+ _POST_SCRIPT_REFRESH_DELAY_S = 0.25
31
+
32
+ def __init__(self, app: "Ferp") -> None:
33
+ self._app = app
34
+ self._runner = ScriptRunner(
35
+ app.app_root,
36
+ app._paths.cache_dir,
37
+ self._handle_script_progress,
38
+ )
39
+ self._progress_lines: list[str] = []
40
+ self._progress_started_at: datetime | None = None
41
+ self._script_running = False
42
+ self._active_script_name: str | None = None
43
+ self._active_target: Path | None = None
44
+ self._active_worker: Worker | None = None
45
+ self._abort_worker: Worker | None = None
46
+ self._input_screen: PromptDialog | ConfirmDialog | None = None
47
+
48
+ @property
49
+ def is_running(self) -> bool:
50
+ return self._script_running
51
+
52
+ @property
53
+ def active_target(self) -> Path | None:
54
+ return self._active_target
55
+
56
+ @property
57
+ def active_script_name(self) -> str | None:
58
+ return self._active_script_name
59
+
60
+ @property
61
+ def process_registry(self):
62
+ return self._runner.process_registry
63
+
64
+ @property
65
+ def active_process_handle(self) -> str | None:
66
+ return self._runner.active_process_handle
67
+
68
+ def run_script(self, script: Script, context: ScriptExecutionContext) -> None:
69
+ if self._script_running:
70
+ return
71
+ self._active_script_name = script.name
72
+ self._active_target = context.target_path
73
+ self._start_worker(lambda: self._runner.start(context))
74
+
75
+ def abort_active(self, reason: str = "Operation canceled by user.") -> bool:
76
+ if not self._script_running:
77
+ return False
78
+ self._dismiss_input_screen()
79
+ cancelled = self._runner.abort(reason)
80
+ if cancelled:
81
+ script_name = self._active_script_name or "Script"
82
+ self._app.render_script_output(script_name, cancelled)
83
+ self._schedule_post_script_refresh()
84
+ self._reset_after_script()
85
+ return cancelled is not None
86
+
87
+ def request_abort(self, reason: str = "Operation canceled by user.") -> bool:
88
+ if not self._script_running:
89
+ return False
90
+ if self._abort_worker is not None:
91
+ return True
92
+ self._dismiss_input_screen()
93
+
94
+ def abort() -> ScriptResult | None:
95
+ return self._runner.abort(reason)
96
+
97
+ try:
98
+ self._abort_worker = self._app.run_worker(
99
+ abort,
100
+ group="script_abort",
101
+ exclusive=True,
102
+ thread=True,
103
+ )
104
+ except Exception:
105
+ self._abort_worker = None
106
+ raise
107
+ return True
108
+
109
+ def handle_worker_state(self, event: Worker.StateChanged) -> bool:
110
+ worker = event.worker
111
+ if worker.group not in {"scripts", "script_abort"}:
112
+ return False
113
+ if worker.group == "scripts":
114
+ if self._active_worker is None:
115
+ if not self._script_running:
116
+ return True
117
+ elif worker is not self._active_worker:
118
+ return True
119
+
120
+ state = event.state
121
+ if state is WorkerState.RUNNING:
122
+ return True
123
+
124
+ if worker.group == "script_abort":
125
+ if state is WorkerState.SUCCESS:
126
+ result = worker.result
127
+ if isinstance(result, ScriptResult):
128
+ self._app.render_script_output(
129
+ self._active_script_name or "Script",
130
+ result,
131
+ )
132
+ self._schedule_post_script_refresh()
133
+ self._reset_after_script()
134
+ elif state is WorkerState.ERROR:
135
+ error = worker.error or RuntimeError("Script cancellation failed.")
136
+ self._set_script_error(error)
137
+ self._schedule_post_script_refresh()
138
+ self._reset_after_script()
139
+ if state in {WorkerState.SUCCESS, WorkerState.ERROR, WorkerState.CANCELLED}:
140
+ self._abort_worker = None
141
+ return True
142
+
143
+ if state is WorkerState.SUCCESS:
144
+ result = worker.result
145
+ if not isinstance(result, ScriptResult):
146
+ return True
147
+
148
+ if result.status is ScriptStatus.WAITING_INPUT:
149
+ if result.input_request:
150
+ self._handle_input_request(result.input_request)
151
+ else:
152
+ self._set_script_error(RuntimeError("Missing FSCP input details."))
153
+ self._runner.abort("Protocol error.")
154
+ self._reset_after_script()
155
+ return True
156
+
157
+ self._app.render_script_output(
158
+ self._active_script_name or "Script",
159
+ result,
160
+ )
161
+ self._schedule_post_script_refresh()
162
+ self._reset_after_script()
163
+ return True
164
+
165
+ if state is WorkerState.ERROR:
166
+ error = worker.error
167
+ if error is not None:
168
+ self._set_script_error(error)
169
+ else:
170
+ self._set_script_error(RuntimeError("Script worker failed."))
171
+ self._runner.abort("Worker failed.")
172
+ self._schedule_post_script_refresh()
173
+ self._reset_after_script()
174
+ return True
175
+
176
+ if state is WorkerState.CANCELLED:
177
+ self._reset_after_script()
178
+ return True
179
+
180
+ return True
181
+
182
+ def handle_launch_failure(self) -> None:
183
+ """Reset state if launching the worker raises."""
184
+ self._script_running = False
185
+ self._active_script_name = None
186
+ self._active_target = None
187
+ self._active_worker = None
188
+ self._abort_worker = None
189
+ self._progress_lines = []
190
+ self._progress_started_at = None
191
+ self._set_controls_disabled(False)
192
+ self._app.state_store.set_status("Ready")
193
+ self._set_script_error(RuntimeError("Script launch failed."))
194
+
195
+ # ------------------------------------------------------------------
196
+ # Internal helpers
197
+ # ------------------------------------------------------------------
198
+ def _start_worker(self, runner_fn: Callable[[], ScriptResult]) -> None:
199
+ self._script_running = True
200
+ app = self._app
201
+ app.state_store.set_status("Running")
202
+ self._progress_lines = []
203
+ self._progress_started_at = datetime.now()
204
+ app._stop_file_tree_watch()
205
+
206
+ script_name = self._active_script_name or "Script"
207
+ target = self._active_target or app.current_path
208
+ app.state_store.update_script_run(
209
+ phase="running",
210
+ script_name=script_name,
211
+ target_path=target,
212
+ input_prompt=None,
213
+ progress_message="",
214
+ progress_line="",
215
+ progress_current=None,
216
+ progress_total=None,
217
+ progress_unit="",
218
+ result=None,
219
+ transcript_path=None,
220
+ error=None,
221
+ )
222
+
223
+ self._set_controls_disabled(True)
224
+
225
+ try:
226
+ worker = app.run_worker(
227
+ runner_fn,
228
+ group="scripts",
229
+ exclusive=True,
230
+ thread=True,
231
+ )
232
+ self._active_worker = worker
233
+ except Exception:
234
+ self.handle_launch_failure()
235
+ raise
236
+
237
+ def _handle_input_request(self, request: ScriptInputRequest) -> None:
238
+ prompt = request.prompt or "Input required"
239
+ self._app.state_store.update_script_run(
240
+ phase="awaiting_input",
241
+ input_prompt=prompt,
242
+ )
243
+
244
+ normalized_default = (request.default or "").strip().lower()
245
+ is_confirm = request.mode == "confirm"
246
+ if not is_confirm and normalized_default in {
247
+ "true",
248
+ "1",
249
+ "yes",
250
+ "y",
251
+ "false",
252
+ "0",
253
+ "no",
254
+ "n",
255
+ }:
256
+ is_confirm = True
257
+
258
+ if is_confirm:
259
+
260
+ def handle_confirm(value: bool | None) -> None:
261
+ if value is None:
262
+ self._handle_user_cancelled()
263
+ return
264
+ if not self._accept_input():
265
+ return
266
+ self._input_screen = None
267
+ payload = "true" if value else "false"
268
+ self._start_worker(lambda: self._runner.provide_input(payload))
269
+
270
+ dialog = ConfirmDialog(prompt, id="confirm_dialog")
271
+ self._input_screen = dialog
272
+ self._app.push_screen(dialog, handle_confirm)
273
+ return
274
+
275
+ bool_fields = self._boolean_fields_for_request(request)
276
+ selection_fields = self._selection_fields_for_request(request)
277
+ dialog = PromptDialog(
278
+ prompt,
279
+ default=request.default,
280
+ suggestions=request.suggestions,
281
+ boolean_fields=bool_fields,
282
+ selection_fields=selection_fields,
283
+ show_text_input=request.show_text_input,
284
+ id="prompt_dialog",
285
+ )
286
+
287
+ def on_close(data: dict[str, str | bool | list[str]] | None) -> None:
288
+ if data is None:
289
+ self._handle_user_cancelled()
290
+ return
291
+ if not self._accept_input():
292
+ return
293
+ self._input_screen = None
294
+ value = data.get("value", "")
295
+ payload_value = str(value)
296
+ payload = (
297
+ json.dumps(data) if (bool_fields or selection_fields) else payload_value
298
+ )
299
+ self._start_worker(lambda: self._runner.provide_input(payload))
300
+
301
+ self._input_screen = dialog
302
+ self._app.push_screen(dialog, on_close)
303
+
304
+ def _handle_user_cancelled(self) -> None:
305
+ self.abort_active("Operation canceled by user.")
306
+
307
+ def _accept_input(self) -> bool:
308
+ if not self._script_running:
309
+ return False
310
+ return self._runner.active_process_handle is not None
311
+
312
+ def _dismiss_input_screen(self) -> None:
313
+ screen = self._input_screen
314
+ if screen is None:
315
+ return
316
+ if screen.is_active:
317
+ try:
318
+ screen.dismiss(None)
319
+ except Exception:
320
+ pass
321
+ else:
322
+ setattr(screen, "_dismiss_on_resume", True)
323
+ self._input_screen = None
324
+
325
+ def _set_script_error(self, error: BaseException) -> None:
326
+ script_name = self._active_script_name or "Script"
327
+ target = self._active_target or self._app.current_path
328
+ self._app.state_store.update_script_run(
329
+ phase="error",
330
+ script_name=script_name,
331
+ target_path=target,
332
+ input_prompt=None,
333
+ progress_message="",
334
+ progress_line="",
335
+ progress_current=None,
336
+ progress_total=None,
337
+ progress_unit="",
338
+ result=None,
339
+ transcript_path=None,
340
+ error=str(error),
341
+ )
342
+
343
+ def _boolean_fields_for_request(
344
+ self, request: ScriptInputRequest
345
+ ) -> list[BooleanField]:
346
+ fields: list[BooleanField] = []
347
+ for field in request.fields:
348
+ if field.get("type") != "bool":
349
+ continue
350
+ field_id = field.get("id")
351
+ label = field.get("label")
352
+ if not field_id or not label:
353
+ continue
354
+ fields.append(
355
+ BooleanField(
356
+ str(field_id),
357
+ str(label),
358
+ bool(field.get("default", False)),
359
+ )
360
+ )
361
+ return fields
362
+
363
+ def _selection_fields_for_request(
364
+ self, request: ScriptInputRequest
365
+ ) -> list[SelectionField]:
366
+ fields: list[SelectionField] = []
367
+ for field in request.fields:
368
+ if field.get("type") != "multi_select":
369
+ continue
370
+ field_id = field.get("id")
371
+ label = field.get("label")
372
+ options = field.get("options")
373
+ default = field.get("default", [])
374
+ if not field_id or not label:
375
+ continue
376
+ if not isinstance(options, list) or not options:
377
+ continue
378
+ options_clean = [str(item) for item in options if item]
379
+ if not options_clean:
380
+ continue
381
+ values = []
382
+ if isinstance(default, list):
383
+ values = [str(item) for item in default if item]
384
+ fields.append(
385
+ SelectionField(
386
+ str(field_id),
387
+ str(label),
388
+ options_clean,
389
+ values or None,
390
+ )
391
+ )
392
+ return fields
393
+
394
+ def _handle_script_progress(self, payload: dict[str, Any]) -> None:
395
+ def update() -> None:
396
+ current = self._coerce_float(payload.get("current"))
397
+ if current is None:
398
+ return
399
+
400
+ total = self._coerce_float(payload.get("total"))
401
+ unit = str(payload.get("unit")).strip() if payload.get("unit") else ""
402
+ message = payload.get("message")
403
+ message_text = str(message) if message is not None else ""
404
+
405
+ line = self._format_progress_line(current, total, unit)
406
+ if not line:
407
+ return
408
+
409
+ self._progress_lines.append(line)
410
+ self._progress_lines = self._progress_lines[-1:]
411
+ self._app.state_store.update_script_run(
412
+ phase="running",
413
+ progress_message=message_text,
414
+ progress_line="\n".join(self._progress_lines),
415
+ progress_current=current,
416
+ progress_total=total,
417
+ progress_unit=unit,
418
+ )
419
+
420
+ self._app.call_from_thread(update)
421
+
422
+ def _coerce_float(self, value: Any) -> float | None:
423
+ if value is None:
424
+ return None
425
+ try:
426
+ return float(value)
427
+ except (TypeError, ValueError):
428
+ return None
429
+
430
+ def _format_progress_line(
431
+ self,
432
+ current: float,
433
+ total: float | None,
434
+ unit: str,
435
+ ) -> str | None:
436
+ def fmt(value: float) -> str:
437
+ if abs(value - int(value)) < 1e-6:
438
+ return str(int(value))
439
+ return f"{value:.2f}".rstrip("0").rstrip(".")
440
+
441
+ if total is not None and total > 0:
442
+ percent = (current / total) * 100
443
+ progress = (
444
+ f"{fmt(current)}/{fmt(total)}"
445
+ + (f" {unit}" if unit else "")
446
+ + f" ({percent:.0f}%)"
447
+ )
448
+ else:
449
+ progress = f"{fmt(current)}" + (f" {unit}" if unit else "")
450
+
451
+ elapsed = self._format_elapsed()
452
+ return f"[dim]{elapsed}[/dim] {progress}"
453
+
454
+ def _format_elapsed(self) -> str:
455
+ started_at = self._progress_started_at
456
+ if started_at is None:
457
+ total_seconds = 0
458
+ else:
459
+ total_seconds = int((datetime.now() - started_at).total_seconds())
460
+ hours, remainder = divmod(total_seconds, 3600)
461
+ minutes, seconds = divmod(remainder, 60)
462
+ return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
463
+
464
+ def _reset_after_script(self) -> None:
465
+ self._script_running = False
466
+ self._active_script_name = None
467
+ self._active_target = None
468
+ self._progress_lines = []
469
+ self._progress_started_at = None
470
+ self._active_worker = None
471
+ self._abort_worker = None
472
+ self._input_screen = None
473
+ if self._app.is_shutting_down:
474
+ self._app._maybe_exit_after_script()
475
+ return
476
+ self._set_controls_disabled(False)
477
+ self._app.state_store.set_status("Ready")
478
+ self._app.state_store.update_script_run(
479
+ phase="idle",
480
+ script_name=None,
481
+ target_path=None,
482
+ input_prompt=None,
483
+ progress_message="",
484
+ progress_line="",
485
+ progress_current=None,
486
+ progress_total=None,
487
+ progress_unit="",
488
+ result=None,
489
+ transcript_path=None,
490
+ error=None,
491
+ )
492
+ self._app._start_file_tree_watch()
493
+ self._app._maybe_exit_after_script()
494
+
495
+ def _set_controls_disabled(self, disabled: bool) -> None:
496
+ script_manager = self._app.query_one(ScriptManager)
497
+ script_manager.disabled = disabled
498
+ if disabled:
499
+ script_manager.add_class("dimmed")
500
+ else:
501
+ script_manager.remove_class("dimmed")
502
+
503
+ file_tree = self._app.query_one(FileTree)
504
+ file_tree_container = self._app.query_one("#file_list_container")
505
+ file_tree.disabled = disabled
506
+ if disabled:
507
+ file_tree_container.add_class("dimmed")
508
+ else:
509
+ file_tree_container.remove_class("dimmed")
510
+
511
+ def _schedule_post_script_refresh(self) -> None:
512
+ self._app.schedule_refresh_listing(
513
+ delay=self._POST_SCRIPT_REFRESH_DELAY_S,
514
+ suppress_focus=True,
515
+ )
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+ from typing import Any, Dict
6
+
7
+
8
+ class ScriptExitCode(int, Enum):
9
+ SUCCESS = 0
10
+ ERROR = 1
11
+ CONFIRM = 10
12
+ INPUT = 11
13
+ SELECT = 12
14
+
15
+
16
+ class ScriptRequestType(str, Enum):
17
+ CONFIRM = "confirm"
18
+ INPUT = "input"
19
+ SELECT = "select"
20
+ PROGRESS = "progress"
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ScriptRequest:
25
+ type: ScriptRequestType
26
+ message: str
27
+ payload: Dict[str, Any]
28
+
29
+ @staticmethod
30
+ def from_json(data: Dict[str, Any]) -> "ScriptRequest":
31
+ return ScriptRequest(
32
+ type=ScriptRequestType(data["type"]),
33
+ message=str(data.get("message", "")),
34
+ payload=dict(data.get("payload", {})),
35
+ )