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
ferp/widgets/forms.py ADDED
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Iterable
5
+
6
+ from textual.app import ComposeResult
7
+ from textual.containers import Horizontal, Vertical
8
+ from textual.screen import ModalScreen
9
+ from textual.suggester import SuggestFromList
10
+ from textual.widget import Widget
11
+ from textual.widgets import Button, Checkbox, Input, Label, SelectionList
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class BooleanField:
16
+ id: str
17
+ label: str
18
+ value: bool = False
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class SelectionField:
23
+ id: str
24
+ label: str
25
+ options: list[str]
26
+ values: list[str] | None = None
27
+
28
+
29
+ def _selection_list_values(selection_list: SelectionList) -> list[str]:
30
+ selected = selection_list.selected
31
+ if isinstance(selected, list):
32
+ return [str(item) for item in selected]
33
+ return []
34
+
35
+
36
+ class PromptDialog(ModalScreen[dict[str, str | bool | list[str]] | None]):
37
+ def __init__(
38
+ self,
39
+ message: str,
40
+ id: str,
41
+ *,
42
+ default: str | None = None,
43
+ suggestions: Iterable[str] | None = None,
44
+ boolean_fields: Iterable[BooleanField] | None = None,
45
+ selection_fields: Iterable[SelectionField] | None = None,
46
+ show_text_input: bool = True,
47
+ ) -> None:
48
+ super().__init__(id=id)
49
+ self._message = message
50
+ self._default = default or ""
51
+ self._suggestions = list(suggestions or [])
52
+ self._bool_fields = list(boolean_fields or [])
53
+ self._selection_fields = list(selection_fields or [])
54
+ self._show_text_input = show_text_input
55
+
56
+ def compose(self) -> ComposeResult:
57
+ contents: list[Widget] = [Label(self._message, id="dialog_message")]
58
+ if self._show_text_input:
59
+ suggester = None
60
+ if self._suggestions:
61
+ suggester = SuggestFromList(
62
+ self._suggestions,
63
+ case_sensitive=False,
64
+ )
65
+ contents.append(
66
+ Input(value=self._default, id="prompt_input", suggester=suggester)
67
+ )
68
+ for field in self._selection_fields:
69
+ contents.append(
70
+ Label(
71
+ field.label,
72
+ id=f"{field.id}_label",
73
+ classes="selection_list_subtitle",
74
+ )
75
+ )
76
+ contents.append(
77
+ SelectionList(
78
+ *[(option, option) for option in field.options],
79
+ id=field.id,
80
+ classes="prompt_selection_list",
81
+ )
82
+ )
83
+ contents.append(
84
+ Horizontal(
85
+ *(
86
+ Checkbox(label=field.label, value=field.value, id=field.id)
87
+ for field in self._bool_fields
88
+ ),
89
+ id="prompt_flags",
90
+ classes="hidden" if not self._bool_fields else "",
91
+ )
92
+ )
93
+ contents.append(
94
+ Horizontal(
95
+ Button("OK", id="ok", variant="primary", flat=True),
96
+ Button("Cancel", id="cancel", flat=True),
97
+ classes="dialog_buttons",
98
+ )
99
+ )
100
+ yield Vertical(*contents, id="dialog_container")
101
+
102
+ def on_mount(self) -> None:
103
+ if self._show_text_input:
104
+ self.query_one(Input).focus()
105
+ for field in self._selection_fields:
106
+ selection_list = self.query_one(f"#{field.id}", SelectionList)
107
+ if field.values:
108
+ for value in field.values:
109
+ selector = getattr(selection_list, "select", None)
110
+ if callable(selector):
111
+ try:
112
+ selector(value)
113
+ except Exception:
114
+ pass
115
+ if not self._show_text_input:
116
+ if self._selection_fields:
117
+ self.query_one(SelectionList).focus()
118
+ return
119
+ if self._bool_fields:
120
+ self.query_one(Checkbox).focus()
121
+ return
122
+ self.query_one("#ok", Button).focus()
123
+
124
+ def on_screen_resume(self) -> None:
125
+ if getattr(self, "_dismiss_on_resume", False):
126
+ self.dismiss(None)
127
+
128
+ def _collect_state(self) -> dict[str, str | bool | list[str]]:
129
+ state: dict[str, str | bool | list[str]] = {}
130
+ if self._show_text_input:
131
+ state["value"] = self.query_one(Input).value.strip()
132
+ else:
133
+ state["value"] = ""
134
+ for field in self._bool_fields:
135
+ checkbox = self.query_one(f"#{field.id}", Checkbox)
136
+ state[field.id] = bool(checkbox.value)
137
+ for field in self._selection_fields:
138
+ selection_list = self.query_one(f"#{field.id}", SelectionList)
139
+ state[field.id] = _selection_list_values(selection_list)
140
+ return state
141
+
142
+ def on_button_pressed(self, event: Button.Pressed) -> None:
143
+ if event.button.id == "ok":
144
+ self.dismiss(self._collect_state())
145
+ else:
146
+ self.dismiss(None)
@@ -0,0 +1,244 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from rich.markup import escape
8
+ from textual.containers import Vertical
9
+ from textual.widgets import ProgressBar, Static
10
+
11
+ from ferp.core.script_runner import ScriptResult
12
+ from ferp.core.state import AppState, AppStateStore, ScriptRunState
13
+ from ferp.widgets.panels import ContentPanel
14
+
15
+
16
+ class ScriptOutputPanel(ContentPanel):
17
+ """Specialized panel responsible for rendering script status and errors."""
18
+
19
+ _MAX_VALUE_CHARS = 2500
20
+ _TRUNCATION_SUFFIX = "\n... (truncated)"
21
+
22
+ def __init__(
23
+ self,
24
+ *,
25
+ title: str = "Script Output",
26
+ panel_id: str = "output_panel",
27
+ initial_message: str = "No script output.",
28
+ state_store: AppStateStore,
29
+ ) -> None:
30
+ super().__init__(initial_message, id=panel_id, title=title)
31
+ self._state_store = state_store
32
+ self._state_subscription = self._handle_state_update
33
+ self._last_script_run: ScriptRunState | None = None
34
+ self._progress_header: Static | None = None
35
+ self._progress_message: Static | None = None
36
+ self._progress_bar: ProgressBar | None = None
37
+ self._progress_status: Static | None = None
38
+
39
+ def on_mount(self) -> None:
40
+ super().on_mount()
41
+ self._state_store.subscribe(self._state_subscription)
42
+
43
+ def on_unmount(self) -> None:
44
+ self._state_store.unsubscribe(self._state_subscription)
45
+
46
+ def show_error(self, error: BaseException) -> None:
47
+ self.remove_children()
48
+ self.update_content("[bold $error]Error:[/bold $error]\n" + escape(str(error)))
49
+
50
+ def show_result(
51
+ self,
52
+ script_name: str,
53
+ target: Path,
54
+ result: ScriptResult,
55
+ transcript_path: Path | None = None,
56
+ ) -> None:
57
+ status = result.status.value.replace("_", " ").title()
58
+ lines: list[str] = [
59
+ f"[bold $text-primary]Script:[/bold $text-primary] {escape(script_name)}",
60
+ f"[bold $text-primary]Target:[/bold $text-primary] {escape(str(target.name))}",
61
+ f"[bold $text-primary]Status:[/bold $text-primary] {status}",
62
+ ]
63
+
64
+ if result.exit_code is not None:
65
+ lines.append(
66
+ f"[bold $text-primary]Exit Code:[/bold $text-primary] {result.exit_code}"
67
+ )
68
+
69
+ if result.results:
70
+ total = len(result.results)
71
+ for index, payload in enumerate(result.results, start=1):
72
+ header_text, header_style = self._result_header(payload, index, total)
73
+ lines.append(
74
+ f"\n[bold {header_style}]{escape(header_text)}[/bold {header_style}]\n"
75
+ )
76
+ format_hint = payload.get("_format")
77
+ if (
78
+ isinstance(format_hint, str)
79
+ and format_hint.strip().lower() == "json"
80
+ ):
81
+ cleaned = {
82
+ key: value
83
+ for key, value in payload.items()
84
+ if not (isinstance(key, str) and key.startswith("_"))
85
+ }
86
+ lines.append(self._format_pair("json", cleaned))
87
+ continue
88
+ for key, value in payload.items():
89
+ if isinstance(key, str) and key.startswith("_"):
90
+ continue
91
+ lines.append(self._format_pair(key, value))
92
+
93
+ if result.error:
94
+ lines.append("\n[bold $error]Error:[/bold $error]\n" + escape(result.error))
95
+
96
+ if transcript_path:
97
+ lines.append(
98
+ f"\n[bold $secondary]Transcript:[/bold $secondary] [$text-secondary]{escape(str(transcript_path.name))}[/$text-secondary]"
99
+ )
100
+
101
+ self.remove_children()
102
+ self.update_content("\n".join(lines))
103
+
104
+ def _handle_state_update(self, state: AppState) -> None:
105
+ script_run = state.script_run
106
+ if self._last_script_run == script_run:
107
+ return
108
+ self._last_script_run = script_run
109
+ self._render_script_state(script_run)
110
+
111
+ def _render_script_state(self, script_run: ScriptRunState) -> None:
112
+ phase = script_run.phase
113
+ if phase == "running":
114
+ self._render_progress(script_run)
115
+ return
116
+ if phase == "awaiting_input":
117
+ self._clear_progress()
118
+ self.remove_children()
119
+ prompt = script_run.input_prompt or "Input required"
120
+ self.update_content(
121
+ "[bold $primary]Input requested:[/bold $primary] " + escape(prompt)
122
+ )
123
+ return
124
+ if phase == "result" and script_run.result is not None:
125
+ self._clear_progress()
126
+ script_name = script_run.script_name or "Script"
127
+ target = script_run.target_path or Path(".")
128
+ self.show_result(
129
+ script_name,
130
+ target,
131
+ script_run.result,
132
+ script_run.transcript_path,
133
+ )
134
+ return
135
+ if phase == "error" and script_run.error:
136
+ self._clear_progress()
137
+ self.remove_children()
138
+ self.update_content(
139
+ "[bold $error]Error:[/bold $error]\n" + escape(script_run.error)
140
+ )
141
+
142
+ def _render_progress(self, script_run: ScriptRunState) -> None:
143
+ script_name = script_run.script_name or "Script"
144
+ target = script_run.target_path
145
+ target_label = escape(str(target)) if target else "Unknown"
146
+ header_text = (
147
+ f"[bold $primary]Script:[/bold $primary] {escape(script_name)}\n"
148
+ f"[bold $primary]Target:[/bold $primary] {target_label}"
149
+ )
150
+
151
+ if self._progress_bar is None or self._progress_header is None:
152
+ self.remove_children()
153
+ self._progress_header = Static(header_text, id="progress_header")
154
+ self._progress_message = Static("", id="progress_message")
155
+ self._progress_bar = ProgressBar(
156
+ total=None,
157
+ show_eta=False,
158
+ id="script_progress_bar",
159
+ show_percentage=False,
160
+ )
161
+ self._progress_status = Static(
162
+ "[dim]Working, please wait...[/dim]",
163
+ id="progress_status",
164
+ )
165
+ self.mount(
166
+ self._progress_header,
167
+ Vertical(
168
+ self._progress_message,
169
+ self._progress_bar,
170
+ self._progress_status,
171
+ id="progress-container",
172
+ ),
173
+ )
174
+ else:
175
+ self._progress_header.update(header_text)
176
+
177
+ if self._progress_message is not None:
178
+ self._progress_message.update(escape(script_run.progress_message or ""))
179
+ if self._progress_bar is not None:
180
+ total = script_run.progress_total
181
+ current = script_run.progress_current
182
+ if total is not None and total >= 0 and current is not None:
183
+ self._progress_bar.update(
184
+ total=total, progress=max(0.0, min(current, total))
185
+ )
186
+ else:
187
+ self._progress_bar.update(total=None)
188
+ if self._progress_status is not None and script_run.progress_line:
189
+ self._progress_status.update(script_run.progress_line)
190
+
191
+ def _clear_progress(self) -> None:
192
+ self._progress_header = None
193
+ self._progress_message = None
194
+ self._progress_bar = None
195
+ self._progress_status = None
196
+
197
+ def _format_pair(self, key: Any, value: Any) -> str:
198
+ label = f"[bold $text-primary]{escape(str(key))}:[/bold $text-primary]"
199
+ body = escape(self._stringify_value(value))
200
+ return f"{label} {body}"
201
+
202
+ def _result_header(
203
+ self,
204
+ payload: dict[str, Any],
205
+ index: int,
206
+ total: int,
207
+ ) -> tuple[str, str]:
208
+ header_text = f"Result {index} of {total}"
209
+ status = "success"
210
+ custom_title = payload.get("_title")
211
+ if isinstance(custom_title, str) and custom_title.strip():
212
+ header_text = custom_title.strip()
213
+ custom_status = payload.get("_status")
214
+ if isinstance(custom_status, str):
215
+ status = custom_status.strip().lower()
216
+ status_to_style = {
217
+ "success": "$success",
218
+ "ok": "$success",
219
+ "warn": "$warning",
220
+ "warning": "$warning",
221
+ "error": "$error",
222
+ "fail": "$error",
223
+ "failed": "$error",
224
+ }
225
+ return header_text, status_to_style.get(status, "$success")
226
+
227
+ def _stringify_value(self, value: Any) -> str:
228
+ if value is None:
229
+ return "null"
230
+ if isinstance(value, (str, int, float, bool)):
231
+ return self._truncate_value(str(value))
232
+ if isinstance(value, (dict, list)):
233
+ try:
234
+ return self._truncate_value(
235
+ json.dumps(value, indent=2, ensure_ascii=True)
236
+ )
237
+ except (TypeError, ValueError):
238
+ return self._truncate_value(str(value))
239
+ return self._truncate_value(str(value))
240
+
241
+ def _truncate_value(self, value: str) -> str:
242
+ if len(value) <= self._MAX_VALUE_CHARS:
243
+ return value
244
+ return value[: self._MAX_VALUE_CHARS] + self._TRUNCATION_SUFFIX
ferp/widgets/panels.py ADDED
@@ -0,0 +1,13 @@
1
+ from textual.widgets import Static
2
+
3
+
4
+ class ContentPanel(Static):
5
+ def __init__(self, content: str, id: str, title: str) -> None:
6
+ super().__init__(content, id=id)
7
+ self.title = title
8
+
9
+ def on_mount(self) -> None:
10
+ self.border_title = self.title
11
+
12
+ def update_content(self, content: str) -> None:
13
+ self.update(content)
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Callable
5
+
6
+ from textual import on
7
+ from textual.binding import Binding
8
+ from textual.containers import Container, Vertical
9
+ from textual.screen import ModalScreen
10
+ from textual.widgets import Footer, ListItem, ListView, Static
11
+
12
+ from ferp.fscp.host.process_registry import ProcessRecord, ProcessRegistry
13
+
14
+
15
+ class ProcessListItem(ListItem):
16
+ """Row showing a single tracked process."""
17
+
18
+ def __init__(self, record: ProcessRecord) -> None:
19
+ self.record = record
20
+ body = Vertical(
21
+ Static(self._render_title(record), classes="process_row_title"),
22
+ Static(self._render_meta(record), classes="process_row_meta"),
23
+ )
24
+ super().__init__(body, classes="process_row")
25
+
26
+ @staticmethod
27
+ def _render_title(record: ProcessRecord) -> str:
28
+ pid = record.pid if record.pid is not None else "?"
29
+ state = record.state.name.replace("_", " ").title()
30
+ return f"{record.metadata.script_name} · pid {pid} · {state}"
31
+
32
+ @staticmethod
33
+ def _render_meta(record: ProcessRecord) -> str:
34
+ started = datetime.fromtimestamp(record.start_time).strftime(
35
+ "%Y-%m-%d %H:%M:%S"
36
+ )
37
+ target = record.metadata.target_path
38
+ exit_code = (
39
+ f" · exit {record.exit_code}" if record.exit_code is not None else ""
40
+ )
41
+ mode = f" · {record.termination_mode}" if record.termination_mode else ""
42
+ return f"{target} · started {started}{exit_code}{mode}"
43
+
44
+
45
+ class ProcessListScreen(ModalScreen[None]):
46
+ """Modal view over currently tracked processes."""
47
+
48
+ BINDINGS = [
49
+ Binding("escape", "close", "Close", show=True),
50
+ Binding("r", "refresh", "Refresh", show=True),
51
+ Binding("p", "prune_finished", "Prune finished", show=True),
52
+ Binding("k", "kill_selected", "Kill selected", show=True),
53
+ ]
54
+
55
+ def __init__(
56
+ self,
57
+ registry: ProcessRegistry,
58
+ request_abort: Callable[[ProcessRecord], bool],
59
+ ) -> None:
60
+ super().__init__()
61
+ self._registry = registry
62
+ self._request_abort = request_abort
63
+ self._status: Static | None = None
64
+ self._status_locked = False
65
+
66
+ def compose(self):
67
+ list_view = ListView(id="process_list_view")
68
+ list_view.border_title = "Processes"
69
+ status = Static("", id="process_list_status")
70
+ self._status = status
71
+ yield Container(
72
+ Vertical(
73
+ list_view,
74
+ status,
75
+ Footer(),
76
+ ),
77
+ id="process_list_modal",
78
+ )
79
+
80
+ def on_mount(self) -> None:
81
+ self._refresh()
82
+ list_view = self.query_one(ListView)
83
+ list_view.focus()
84
+
85
+ def on_screen_resume(self) -> None:
86
+ self._refresh()
87
+
88
+ @on(ListView.Highlighted, "#process_list_view")
89
+ def _clear_status_on_move(self, _: ListView.Highlighted) -> None:
90
+ if self._status_locked:
91
+ return
92
+ self._set_status("")
93
+
94
+ def action_close(self) -> None:
95
+ self.dismiss(None)
96
+
97
+ def action_refresh(self) -> None:
98
+ self._refresh()
99
+
100
+ def action_prune_finished(self) -> None:
101
+ removed = self._registry.prune_finished()
102
+ self._set_status(f"Pruned {len(removed)} finished process(es).")
103
+ self._refresh()
104
+
105
+ def action_kill_selected(self) -> None:
106
+ list_view = self.query_one(ListView)
107
+ if list_view.index is None:
108
+ self._set_status("Select a process first.")
109
+ return
110
+ try:
111
+ item = list_view.children[list_view.index]
112
+ except IndexError:
113
+ self._set_status("No process selected.")
114
+ return
115
+ if not isinstance(item, ProcessListItem):
116
+ self._set_status("Invalid selection.")
117
+ return
118
+ record = item.record
119
+ if record.is_terminal:
120
+ self._set_status("Process already finished.")
121
+ return
122
+ killed = self._request_abort(record)
123
+ if killed:
124
+ self._status_locked = True
125
+ self._refresh()
126
+ self._set_status("Termination requested.")
127
+ self.set_timer(0.2, self._refresh)
128
+ self.set_timer(0.8, self._refresh)
129
+ self.set_timer(1.2, self._unlock_status)
130
+ else:
131
+ self._set_status("Unable to terminate this process.")
132
+
133
+ def _refresh(self) -> None:
134
+ list_view = self.query_one(ListView)
135
+ list_view.clear()
136
+ records = sorted(
137
+ self._registry.list_all(), key=lambda rec: rec.start_time, reverse=True
138
+ )
139
+ if not records:
140
+ placeholder = ListItem(
141
+ Static("No tracked processes."),
142
+ classes="process_row process_row--empty",
143
+ )
144
+ placeholder.disabled = True
145
+ list_view.append(placeholder)
146
+ return
147
+ for record in records:
148
+ list_view.append(ProcessListItem(record))
149
+
150
+ def _set_status(self, message: str) -> None:
151
+ status = self._status
152
+ if status is None:
153
+ return
154
+ status.update(message)
155
+
156
+ def _unlock_status(self) -> None:
157
+ self._status_locked = False
158
+ self._set_status("")
@@ -0,0 +1,59 @@
1
+ from textual.binding import Binding
2
+ from textual.containers import Vertical, VerticalScroll
3
+ from textual.screen import ModalScreen
4
+ from textual.widgets import Label, MarkdownViewer
5
+
6
+
7
+ class ReadmeScreen(ModalScreen):
8
+ BINDINGS = [
9
+ Binding("j", "scroll_down", "Scroll down", show=False),
10
+ Binding("down", "scroll_down", "Scroll down", show=False),
11
+ Binding("k", "scroll_up", "Scroll up", show=False),
12
+ Binding("up", "scroll_up", "Scroll up", show=False),
13
+ Binding("escape", "close", "Close README", show=True),
14
+ Binding("q", "close", "Close README", show=False),
15
+ ]
16
+
17
+ def __init__(self, title: str, content: str, id: str) -> None:
18
+ super().__init__(id=id)
19
+ self.heading = title
20
+ self._content = content or "*No README available for this script.*"
21
+ self._markdown: MarkdownViewer | None = None
22
+
23
+ def compose(self):
24
+ markdown = MarkdownViewer(self._content, id="readme_content")
25
+ self._markdown = markdown
26
+ yield Vertical(
27
+ VerticalScroll(markdown, id="readme_scroll"),
28
+ id="readme_modal",
29
+ )
30
+
31
+ def on_mount(self) -> None:
32
+ self.query_one("#readme_scroll", VerticalScroll).focus()
33
+
34
+ def action_close(self) -> None:
35
+ self.app.pop_screen()
36
+
37
+ def action_scroll_down(self) -> None:
38
+ scroll = self.query_one("#readme_scroll", VerticalScroll)
39
+ scroll.scroll_down()
40
+
41
+ def action_scroll_up(self) -> None:
42
+ scroll = self.query_one("#readme_scroll", VerticalScroll)
43
+ scroll.scroll_up()
44
+
45
+ def update_content(self, title: str, content: str) -> None:
46
+ self.heading = title
47
+ self._content = content or "*No README available for this script.*"
48
+ if self._markdown is not None:
49
+ setter = getattr(self._markdown, "set_markdown", None)
50
+ if callable(setter):
51
+ setter(self._content)
52
+ else:
53
+ updater = getattr(self._markdown, "update", None)
54
+ if callable(updater):
55
+ updater(self._content)
56
+ try:
57
+ self.query_one("#readme_title", Label).update(self.heading)
58
+ except Exception:
59
+ pass