patchfeld 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. patchfeld/__init__.py +1 -0
  2. patchfeld/__main__.py +32 -0
  3. patchfeld/actions.py +34 -0
  4. patchfeld/activity/__init__.py +0 -0
  5. patchfeld/activity/log.py +237 -0
  6. patchfeld/agents/__init__.py +0 -0
  7. patchfeld/agents/child_tools.py +66 -0
  8. patchfeld/agents/fake_sdk_adapter.py +45 -0
  9. patchfeld/agents/manager.py +365 -0
  10. patchfeld/agents/permission_grants.py +98 -0
  11. patchfeld/agents/permission_inbox.py +91 -0
  12. patchfeld/agents/request_inbox.py +65 -0
  13. patchfeld/agents/sdk_adapter.py +49 -0
  14. patchfeld/agents/session.py +250 -0
  15. patchfeld/agents/sort.py +66 -0
  16. patchfeld/agents/state.py +81 -0
  17. patchfeld/app.py +1433 -0
  18. patchfeld/config.py +128 -0
  19. patchfeld/events.py +260 -0
  20. patchfeld/layout/__init__.py +0 -0
  21. patchfeld/layout/custom_widgets.py +82 -0
  22. patchfeld/layout/defaults.py +33 -0
  23. patchfeld/layout/engine.py +241 -0
  24. patchfeld/layout/local_widgets.py +188 -0
  25. patchfeld/layout/registry.py +69 -0
  26. patchfeld/layout/spec.py +104 -0
  27. patchfeld/layout/splitter.py +170 -0
  28. patchfeld/layout/titles.py +70 -0
  29. patchfeld/orchestrator/__init__.py +0 -0
  30. patchfeld/orchestrator/formatting.py +15 -0
  31. patchfeld/orchestrator/session.py +785 -0
  32. patchfeld/orchestrator/tabs_tools.py +149 -0
  33. patchfeld/orchestrator/tools.py +976 -0
  34. patchfeld/persistence/__init__.py +0 -0
  35. patchfeld/persistence/agents_index.py +68 -0
  36. patchfeld/persistence/atomic.py +47 -0
  37. patchfeld/persistence/layout_store.py +25 -0
  38. patchfeld/persistence/layouts_store.py +61 -0
  39. patchfeld/persistence/orchestrator_sessions.py +127 -0
  40. patchfeld/persistence/paths.py +48 -0
  41. patchfeld/persistence/themes_store.py +44 -0
  42. patchfeld/persistence/transcript_store.py +64 -0
  43. patchfeld/persistence/workspace_store.py +25 -0
  44. patchfeld/theme/__init__.py +0 -0
  45. patchfeld/theme/engine.py +75 -0
  46. patchfeld/theme/spec.py +31 -0
  47. patchfeld/widgets/__init__.py +0 -0
  48. patchfeld/widgets/_file_lang.py +36 -0
  49. patchfeld/widgets/_terminal_keys.py +89 -0
  50. patchfeld/widgets/_terminal_render.py +147 -0
  51. patchfeld/widgets/activity_feed.py +365 -0
  52. patchfeld/widgets/agent_table.py +236 -0
  53. patchfeld/widgets/agent_transcript.py +85 -0
  54. patchfeld/widgets/change_cwd_screen.py +39 -0
  55. patchfeld/widgets/chrome.py +210 -0
  56. patchfeld/widgets/diff_viewer.py +52 -0
  57. patchfeld/widgets/file_editor.py +258 -0
  58. patchfeld/widgets/file_tree.py +33 -0
  59. patchfeld/widgets/file_viewer.py +77 -0
  60. patchfeld/widgets/history_screen.py +58 -0
  61. patchfeld/widgets/layout_switcher.py +126 -0
  62. patchfeld/widgets/log_tail.py +113 -0
  63. patchfeld/widgets/markdown.py +65 -0
  64. patchfeld/widgets/new_tab_screen.py +31 -0
  65. patchfeld/widgets/notebook.py +45 -0
  66. patchfeld/widgets/orchestrator_chat.py +73 -0
  67. patchfeld/widgets/permission_modal.py +185 -0
  68. patchfeld/widgets/permission_request_bar.py +90 -0
  69. patchfeld/widgets/resume_screen.py +179 -0
  70. patchfeld/widgets/rich_transcript.py +606 -0
  71. patchfeld/widgets/system_usage.py +244 -0
  72. patchfeld/widgets/terminal.py +251 -0
  73. patchfeld/widgets/theme_switcher.py +63 -0
  74. patchfeld/widgets/transcript_screen.py +39 -0
  75. patchfeld/workspace/__init__.py +3 -0
  76. patchfeld/workspace/spec.py +72 -0
  77. patchfeld-0.2.0.dist-info/METADATA +584 -0
  78. patchfeld-0.2.0.dist-info/RECORD +81 -0
  79. patchfeld-0.2.0.dist-info/WHEEL +4 -0
  80. patchfeld-0.2.0.dist-info/entry_points.txt +3 -0
  81. patchfeld-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,258 @@
1
+ import logging
2
+ from pathlib import Path
3
+
4
+ from textual.app import ComposeResult
5
+ from textual.binding import Binding
6
+ from textual.containers import Vertical
7
+ from textual.screen import ModalScreen
8
+ from textual.widgets import Button, Static, TextArea
9
+
10
+ from patchfeld.events import FileSelected
11
+ from patchfeld.widgets._file_lang import load_text as _load_text
12
+
13
+ log = logging.getLogger(__name__)
14
+
15
+
16
+ def _stat_or_none(path: Path) -> tuple[float, int] | None:
17
+ try:
18
+ st = path.stat()
19
+ except OSError:
20
+ return None
21
+ return st.st_mtime, st.st_size
22
+
23
+
24
+ class FileEditor(TextArea):
25
+ """Editable, syntax-highlighted file editor.
26
+
27
+ Mirrors `FileViewer` for loading and language detection, plus:
28
+
29
+ - `Ctrl+S` saves the buffer to disk; the border title shows ` *` when
30
+ the buffer differs from the loaded baseline.
31
+ - With `follow_selection=True`, subscribes to `FileSelected` events on
32
+ the EventBus and reloads to show the selected file. If unsaved edits
33
+ exist when the path changes, prompts via `ConfirmDirtySwitchScreen`.
34
+ - At save time, if the file's mtime/size on disk diverges from what was
35
+ cached at load, prompts via `ConfirmOverwriteScreen` before writing.
36
+ """
37
+
38
+ DEFAULT_CSS = """
39
+ FileEditor {
40
+ border: round $surface-lighten-2;
41
+ }
42
+ """
43
+
44
+ BINDINGS = [
45
+ Binding("ctrl+s", "save", "save", show=False),
46
+ ]
47
+
48
+ def __init__(
49
+ self,
50
+ *,
51
+ file_path: str | None = None,
52
+ follow_selection: bool = False,
53
+ ) -> None:
54
+ if file_path is not None:
55
+ text, language = _load_text(Path(file_path))
56
+ else:
57
+ text, language = "", None
58
+ kwargs: dict = {}
59
+ if language is not None:
60
+ kwargs["language"] = language
61
+ super().__init__(text, **kwargs)
62
+ self._follow_selection = follow_selection
63
+ self._current_path: Path | None = Path(file_path) if file_path else None
64
+ self._loaded_text: str = text
65
+ self._dirty: bool = False
66
+ if self._current_path is not None:
67
+ stat = _stat_or_none(self._current_path)
68
+ else:
69
+ stat = None
70
+ self._loaded_mtime: float | None = stat[0] if stat else None
71
+ self._loaded_size: int | None = stat[1] if stat else None
72
+ self._unsub = lambda: None
73
+
74
+ def load_file(self, file_path: str) -> None:
75
+ path = Path(file_path)
76
+ text, language = _load_text(path)
77
+ self.text = text
78
+ if language is not None:
79
+ try:
80
+ self.language = language
81
+ except Exception:
82
+ pass
83
+ self._current_path = path
84
+ self._loaded_text = text
85
+ stat = _stat_or_none(path)
86
+ self._loaded_mtime = stat[0] if stat else None
87
+ self._loaded_size = stat[1] if stat else None
88
+ self._dirty = False
89
+ self._refresh_border_title()
90
+
91
+ def _on_file_selected(self, event: FileSelected) -> None:
92
+ new_path = Path(event.path)
93
+ if not self._dirty or new_path == self._current_path:
94
+ self.load_file(event.path)
95
+ return
96
+ self.run_worker(
97
+ self._handle_dirty_switch(event.path),
98
+ exclusive=True,
99
+ )
100
+
101
+ async def _handle_dirty_switch(self, new_path: str) -> None:
102
+ verb = await self.app.push_screen_wait(
103
+ ConfirmDirtySwitchScreen(
104
+ current_name=self._current_path.name if self._current_path else "",
105
+ new_name=Path(new_path).name,
106
+ )
107
+ )
108
+ if verb == "save":
109
+ saved = await self.action_save()
110
+ if saved:
111
+ self.load_file(new_path)
112
+ elif verb == "discard":
113
+ self.load_file(new_path)
114
+ # cancel: no-op
115
+
116
+ @property
117
+ def is_dirty(self) -> bool:
118
+ return self._dirty
119
+
120
+ def on_mount(self) -> None:
121
+ self._refresh_border_title()
122
+ if not self._follow_selection:
123
+ return
124
+ bus = getattr(self.app, "event_bus", None)
125
+ if bus is not None:
126
+ self._unsub = bus.subscribe(FileSelected, self._on_file_selected)
127
+
128
+ def on_unmount(self) -> None:
129
+ self._unsub()
130
+
131
+ def on_text_area_changed(self, _event) -> None:
132
+ new_dirty = self.text != self._loaded_text
133
+ if new_dirty != self._dirty:
134
+ self._dirty = new_dirty
135
+ self._refresh_border_title()
136
+
137
+ def _refresh_border_title(self) -> None:
138
+ if self._current_path is None:
139
+ self.border_title = "Edit"
140
+ return
141
+ name = self._current_path.name
142
+ self.border_title = f"Edit: {name} *" if self._dirty else f"Edit: {name}"
143
+
144
+ async def action_save(self) -> bool:
145
+ """Save the current buffer to disk. Returns True iff the file was written."""
146
+ if self._current_path is None:
147
+ return False
148
+ # No-baseline + no-edit short circuit: avoid writing the
149
+ # error-placeholder text after a failed load.
150
+ if self._loaded_mtime is None and self.text == self._loaded_text:
151
+ return False
152
+ if self._loaded_mtime is not None:
153
+ current = _stat_or_none(self._current_path)
154
+ if current is not None and (
155
+ current[0] != self._loaded_mtime
156
+ or current[1] != self._loaded_size
157
+ ):
158
+ verb = await self.app.push_screen_wait(
159
+ ConfirmOverwriteScreen(name=self._current_path.name)
160
+ )
161
+ if verb != "overwrite":
162
+ return False
163
+ try:
164
+ self._current_path.parent.mkdir(parents=True, exist_ok=True)
165
+ self._current_path.write_text(self.text, encoding="utf-8")
166
+ except OSError:
167
+ self.border_title = f"Edit: {self._current_path.name} (save failed)"
168
+ log.warning("FileEditor save failed: %s", self._current_path)
169
+ return False
170
+ stat = _stat_or_none(self._current_path)
171
+ if stat is not None:
172
+ self._loaded_mtime, self._loaded_size = stat
173
+ self._loaded_text = self.text
174
+ self._dirty = False
175
+ self._refresh_border_title()
176
+ return True
177
+
178
+ @classmethod
179
+ def default_border_title(cls, props: dict) -> str:
180
+ fp = props.get("file_path")
181
+ return f"Edit: {Path(fp).name}" if fp else "Edit"
182
+
183
+
184
+ class ConfirmOverwriteScreen(ModalScreen[str]):
185
+ """Modal shown when Ctrl+S detects the file changed on disk since load.
186
+
187
+ Dismisses with one of: 'overwrite', 'cancel'.
188
+ """
189
+
190
+ DEFAULT_CSS = """
191
+ ConfirmOverwriteScreen { align: center middle; }
192
+ ConfirmOverwriteScreen > Vertical {
193
+ width: 60; height: auto; padding: 1 2;
194
+ background: $surface; border: round $primary;
195
+ }
196
+ ConfirmOverwriteScreen .row { height: auto; }
197
+ ConfirmOverwriteScreen Button { margin-right: 1; }
198
+ """
199
+
200
+ BINDINGS = [("escape", "cancel", "cancel")]
201
+
202
+ def __init__(self, *, name: str) -> None:
203
+ super().__init__()
204
+ self._filename = name
205
+
206
+ def compose(self) -> ComposeResult:
207
+ with Vertical():
208
+ yield Static(
209
+ f"{self._filename} was changed on disk since you opened it. "
210
+ f"Overwrite anyway?"
211
+ )
212
+ yield Button("Overwrite", id="overwrite", variant="warning")
213
+ yield Button("Cancel", id="cancel")
214
+
215
+ def on_button_pressed(self, event: Button.Pressed) -> None:
216
+ self.dismiss(event.button.id or "cancel")
217
+
218
+ def action_cancel(self) -> None:
219
+ self.dismiss("cancel")
220
+
221
+
222
+ class ConfirmDirtySwitchScreen(ModalScreen[str]):
223
+ """Modal shown when a FileSelected event would discard unsaved edits.
224
+
225
+ Dismisses with one of: 'save', 'discard', 'cancel'.
226
+ """
227
+
228
+ DEFAULT_CSS = """
229
+ ConfirmDirtySwitchScreen { align: center middle; }
230
+ ConfirmDirtySwitchScreen > Vertical {
231
+ width: 70; height: auto; padding: 1 2;
232
+ background: $surface; border: round $primary;
233
+ }
234
+ ConfirmDirtySwitchScreen Button { margin-right: 1; }
235
+ """
236
+
237
+ BINDINGS = [("escape", "cancel", "cancel")]
238
+
239
+ def __init__(self, *, current_name: str, new_name: str) -> None:
240
+ super().__init__()
241
+ self._current_name = current_name
242
+ self._new_name = new_name
243
+
244
+ def compose(self) -> ComposeResult:
245
+ with Vertical():
246
+ yield Static(
247
+ f"Unsaved changes in {self._current_name or '(unsaved buffer)'}. "
248
+ f"Save & switch to {self._new_name}, discard, or cancel?"
249
+ )
250
+ yield Button("Save & Switch", id="save", variant="primary")
251
+ yield Button("Discard & Switch", id="discard", variant="warning")
252
+ yield Button("Cancel", id="cancel")
253
+
254
+ def on_button_pressed(self, event: Button.Pressed) -> None:
255
+ self.dismiss(event.button.id or "cancel")
256
+
257
+ def action_cancel(self) -> None:
258
+ self.dismiss("cancel")
@@ -0,0 +1,33 @@
1
+ from pathlib import Path
2
+
3
+ from textual.widgets import DirectoryTree
4
+
5
+ from patchfeld.events import FileSelected
6
+
7
+
8
+ class FileTree(DirectoryTree):
9
+ """Wraps Textual's DirectoryTree with a kw-only `path` prop. Publishes a
10
+ `FileSelected` event on the EventBus when the user selects a file —
11
+ other widgets (e.g. `FileViewer(follow_selection=True)`) can react."""
12
+
13
+ DEFAULT_CSS = """
14
+ FileTree {
15
+ border: round $surface-lighten-2;
16
+ padding: 0 1;
17
+ }
18
+ """
19
+
20
+ def __init__(self, *, path: str) -> None:
21
+ super().__init__(Path(path))
22
+
23
+ def on_directory_tree_file_selected(self, event) -> None:
24
+ bus = getattr(self.app, "event_bus", None)
25
+ if bus is not None:
26
+ bus.publish(FileSelected(path=str(event.path)))
27
+
28
+ @classmethod
29
+ def default_border_title(cls, props: dict) -> str:
30
+ path = props.get("path")
31
+ if path:
32
+ return f"Files: {path}"
33
+ return "Files"
@@ -0,0 +1,77 @@
1
+ from pathlib import Path
2
+
3
+ from textual.widgets import TextArea
4
+
5
+ from patchfeld.events import FileSelected
6
+ from patchfeld.widgets._file_lang import load_text as _load_text
7
+
8
+
9
+ class FileViewer(TextArea):
10
+ """Read-only file display with extension-based syntax highlighting.
11
+
12
+ Loads the entire file into memory at mount time — fine for typical
13
+ source files, but for log-sized content (>~1MB) prefer the LogTail
14
+ widget which streams from the end and polls for additions.
15
+
16
+ If `follow_selection=True`, subscribes to `FileSelected` events on the
17
+ EventBus and reloads to show the selected file. Pair with a `FileTree`
18
+ panel to get a click-a-file → see-its-content workflow:
19
+
20
+ {"id": "tree", "widget": "FileTree", "props": {"path": "."}}
21
+ {"id": "viewer", "widget": "FileViewer", "props": {"follow_selection": true}}
22
+ """
23
+
24
+ DEFAULT_CSS = """
25
+ FileViewer {
26
+ border: round $surface-lighten-2;
27
+ }
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ *,
33
+ file_path: str | None = None,
34
+ follow_selection: bool = False,
35
+ ) -> None:
36
+ if file_path is not None:
37
+ text, language = _load_text(Path(file_path))
38
+ else:
39
+ text, language = "", None
40
+ kwargs: dict = {"read_only": True}
41
+ if language is not None:
42
+ kwargs["language"] = language
43
+ super().__init__(text, **kwargs)
44
+ self._follow_selection = follow_selection
45
+ self._unsub = lambda: None
46
+
47
+ def on_mount(self) -> None:
48
+ if not self._follow_selection:
49
+ return
50
+ bus = getattr(self.app, "event_bus", None)
51
+ if bus is None:
52
+ return
53
+ self._unsub = bus.subscribe(FileSelected, self._on_file_selected)
54
+
55
+ def on_unmount(self) -> None:
56
+ self._unsub()
57
+
58
+ def _on_file_selected(self, event: FileSelected) -> None:
59
+ self.load_file(event.path)
60
+
61
+ def load_file(self, file_path: str) -> None:
62
+ path = Path(file_path)
63
+ text, language = _load_text(path)
64
+ self.text = text
65
+ if language is not None:
66
+ try:
67
+ self.language = language
68
+ except Exception:
69
+ pass
70
+
71
+ @classmethod
72
+ def default_border_title(cls, props: dict) -> str:
73
+ from pathlib import Path as _P
74
+ file_path = props.get("file_path")
75
+ if file_path:
76
+ return f"File: {_P(file_path).name}"
77
+ return "File"
@@ -0,0 +1,58 @@
1
+ from textual.binding import Binding
2
+ from textual.containers import Container
3
+ from textual.screen import ModalScreen
4
+ from textual.widgets import DataTable, Footer, Label
5
+
6
+ from patchfeld.persistence.agents_index import AgentsIndex
7
+
8
+
9
+ class HistoryScreen(ModalScreen[str | None]):
10
+ """Modal listing every agent in agents.json. Selecting dismisses with the id."""
11
+
12
+ DEFAULT_CSS = """
13
+ HistoryScreen {
14
+ align: center middle;
15
+ }
16
+ HistoryScreen > Container {
17
+ width: 75%;
18
+ height: 75%;
19
+ border: thick $primary;
20
+ background: $surface;
21
+ padding: 1 2;
22
+ }
23
+ HistoryScreen DataTable {
24
+ height: 1fr;
25
+ }
26
+ """
27
+
28
+ BINDINGS = [Binding("escape", "dismiss_none", "cancel")]
29
+
30
+ COLUMNS = ("id", "name", "state", "started", "cost")
31
+
32
+ def __init__(self, index: AgentsIndex) -> None:
33
+ super().__init__()
34
+ self._index = index
35
+
36
+ def compose(self):
37
+ with Container():
38
+ yield Label("Agent history (Enter to view transcript, Esc to close):")
39
+ table = DataTable(zebra_stripes=True, cursor_type="row")
40
+ for col in self.COLUMNS:
41
+ table.add_column(col, key=col)
42
+ for info in self._index.load():
43
+ table.add_row(
44
+ info.id,
45
+ info.name,
46
+ info.state.value,
47
+ f"{info.started_at:.0f}",
48
+ f"${info.cost:.4f}",
49
+ key=info.id,
50
+ )
51
+ yield table
52
+ yield Footer()
53
+
54
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
55
+ self.dismiss(str(event.row_key.value))
56
+
57
+ def action_dismiss_none(self) -> None:
58
+ self.dismiss(None)
@@ -0,0 +1,126 @@
1
+ from textual.app import ComposeResult
2
+ from textual.binding import Binding
3
+ from textual.containers import Container, Vertical
4
+ from textual.screen import ModalScreen
5
+ from textual.widgets import Button, Footer, Label, ListItem, ListView, Static
6
+
7
+ from patchfeld.persistence.layouts_store import NamedLayoutsStore
8
+
9
+
10
+ class LayoutSwitcherScreen(ModalScreen[str | None]):
11
+ """Pick a saved layout. Esc dismisses with None; selecting dismisses with the name.
12
+
13
+ Pressing `d` on a row prompts via :class:`ConfirmDeleteLayoutScreen` and,
14
+ on confirmation, removes the layout's JSON file from disk and the row
15
+ from the picker. The currently-active layout is deleted just like any
16
+ other — the in-memory copy keeps working until the user reloads.
17
+ """
18
+
19
+ DEFAULT_CSS = """
20
+ LayoutSwitcherScreen {
21
+ align: center middle;
22
+ }
23
+ LayoutSwitcherScreen > Container {
24
+ width: 50%;
25
+ height: 60%;
26
+ border: thick $primary;
27
+ background: $surface;
28
+ padding: 1 2;
29
+ }
30
+ LayoutSwitcherScreen ListView {
31
+ height: 1fr;
32
+ }
33
+ """
34
+
35
+ BINDINGS = [
36
+ Binding("escape", "dismiss_none", "cancel"),
37
+ Binding("d", "delete_selected", "delete"),
38
+ ]
39
+
40
+ def __init__(self, store: NamedLayoutsStore) -> None:
41
+ super().__init__()
42
+ self._store = store
43
+
44
+ def compose(self) -> ComposeResult:
45
+ items = [ListItem(Label(name), name=name) for name in self._store.list()]
46
+ with Container():
47
+ yield Label("Load layout:")
48
+ yield ListView(*items)
49
+ yield Footer()
50
+
51
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
52
+ self.dismiss(event.item.name)
53
+
54
+ def action_dismiss_none(self) -> None:
55
+ self.dismiss(None)
56
+
57
+ def action_delete_selected(self) -> None:
58
+ list_view = self.query_one(ListView)
59
+ item = list_view.highlighted_child
60
+ if item is None:
61
+ return
62
+ name = item.name
63
+ if not name:
64
+ return
65
+ # Capture the row's index so we can keep the cursor on the same row
66
+ # number after removal (or clamp to the new last row if we removed it).
67
+ index = list_view.index or 0
68
+
69
+ def _on_choice(choice: str | None) -> None:
70
+ if choice != "delete":
71
+ return
72
+ self._store.delete(name)
73
+ # `pop` returns AwaitComplete, which schedules the removal as a
74
+ # task immediately on construction — fire-and-forget is fine here.
75
+ list_view.pop(index)
76
+ new_count = len(list_view) - 1
77
+ if new_count > 0:
78
+ list_view.index = min(index, new_count - 1)
79
+ else:
80
+ list_view.index = None
81
+ self.app.notify(f"Deleted layout '{name}'")
82
+
83
+ self.app.push_screen(ConfirmDeleteLayoutScreen(name=name), _on_choice)
84
+
85
+
86
+ class ConfirmDeleteLayoutScreen(ModalScreen[str]):
87
+ """Yes/No confirmation before unlinking a saved layout.
88
+
89
+ Dismisses with one of: 'delete', 'cancel'. The Cancel button holds focus
90
+ by default so a stray Enter doesn't destroy data.
91
+ """
92
+
93
+ DEFAULT_CSS = """
94
+ ConfirmDeleteLayoutScreen { align: center middle; }
95
+ ConfirmDeleteLayoutScreen > Vertical {
96
+ width: 60; height: auto; padding: 1 2;
97
+ background: $surface; border: round $primary;
98
+ }
99
+ ConfirmDeleteLayoutScreen Button { margin-right: 1; }
100
+ """
101
+
102
+ BINDINGS = [Binding("escape", "cancel", "cancel")]
103
+
104
+ def __init__(self, *, name: str) -> None:
105
+ super().__init__()
106
+ self.layout_name = name
107
+
108
+ def compose(self) -> ComposeResult:
109
+ with Vertical():
110
+ yield Static(
111
+ f"Delete saved layout '{self.layout_name}'? "
112
+ f"This removes the file from disk; the active layout in "
113
+ f"memory is unaffected until you reload."
114
+ )
115
+ yield Button("Delete", id="delete", variant="error")
116
+ yield Button("Cancel", id="cancel", variant="primary")
117
+
118
+ def on_mount(self) -> None:
119
+ # Default focus to Cancel so an accidental Enter doesn't delete.
120
+ self.query_one("#cancel", Button).focus()
121
+
122
+ def on_button_pressed(self, event: Button.Pressed) -> None:
123
+ self.dismiss(event.button.id or "cancel")
124
+
125
+ def action_cancel(self) -> None:
126
+ self.dismiss("cancel")
@@ -0,0 +1,113 @@
1
+ from pathlib import Path
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.containers import VerticalScroll
5
+ from textual.widgets import Static
6
+
7
+
8
+ class LogTail(VerticalScroll):
9
+ """Tails a file: shows existing content, polls every 250ms for additions."""
10
+
11
+ DEFAULT_CSS = """
12
+ LogTail {
13
+ border: round $surface-lighten-2;
14
+ padding: 0 1;
15
+ }
16
+ """
17
+
18
+ def __init__(self, *, file_path: str, tail_lines: int = 200) -> None:
19
+ super().__init__()
20
+ self._path = Path(file_path)
21
+ self._tail_lines = tail_lines
22
+ self._fp = None
23
+ self._inode = None
24
+ self.text = ""
25
+ self._timer = None
26
+
27
+ def compose(self) -> ComposeResult:
28
+ yield Static("", id="log-tail-content")
29
+
30
+ def on_mount(self) -> None:
31
+ if not self._path.exists():
32
+ self.text = f"File not found: {self._path}"
33
+ self._update_static()
34
+ return
35
+ try:
36
+ lines = self._path.read_text(encoding="utf-8", errors="replace").splitlines()
37
+ self.text = "\n".join(lines[-self._tail_lines:])
38
+ except Exception as e:
39
+ self.text = f"Error reading {self._path}: {e}"
40
+ self._update_static()
41
+ return
42
+ self._update_static()
43
+ self._open_at_end()
44
+ self._timer = self.set_interval(0.25, self._tick)
45
+
46
+ def _open_at_end(self) -> None:
47
+ try:
48
+ self._fp = self._path.open("r", encoding="utf-8", errors="replace")
49
+ self._fp.seek(0, 2)
50
+ self._inode = self._path.stat().st_ino
51
+ except Exception:
52
+ self._fp = None
53
+ self._inode = None
54
+
55
+ def on_unmount(self) -> None:
56
+ if self._timer is not None:
57
+ self._timer.stop()
58
+ self._timer = None
59
+ if self._fp is not None:
60
+ try:
61
+ self._fp.close()
62
+ except Exception:
63
+ pass
64
+ self._fp = None
65
+
66
+ def _tick(self) -> None:
67
+ # Detect rotation: if the file's inode changed (or it disappeared
68
+ # and a new one took its place), close the old fp and reopen.
69
+ try:
70
+ current_inode = self._path.stat().st_ino if self._path.exists() else None
71
+ except Exception:
72
+ current_inode = None
73
+ if current_inode != getattr(self, "_inode", None):
74
+ if self._fp is not None:
75
+ try:
76
+ self._fp.close()
77
+ except Exception:
78
+ pass
79
+ self._fp = None
80
+ if current_inode is not None:
81
+ self._open_at_end()
82
+ # After rotation, read from the start of the new file so
83
+ # the user doesn't miss the first lines.
84
+ if self._fp is not None:
85
+ try:
86
+ self._fp.seek(0, 0)
87
+ except Exception:
88
+ pass
89
+
90
+ if self._fp is None:
91
+ return
92
+ new = self._fp.read()
93
+ if not new:
94
+ return
95
+ self.text = (self.text + "\n" + new).strip("\n")
96
+ self._update_static()
97
+ self.scroll_end(animate=False)
98
+
99
+ def _update_static(self) -> None:
100
+ # Wrap in Rich Text so arbitrary file content (which may contain
101
+ # bracket sequences that look like markup) renders verbatim.
102
+ from rich.text import Text
103
+ try:
104
+ self.query_one("#log-tail-content", Static).update(Text(self.text))
105
+ except Exception:
106
+ pass
107
+
108
+ @classmethod
109
+ def default_border_title(cls, props: dict) -> str:
110
+ file_path = props.get("file_path")
111
+ if file_path:
112
+ return f"Log: {Path(file_path).name}"
113
+ return "Log"