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.
- patchfeld/__init__.py +1 -0
- patchfeld/__main__.py +32 -0
- patchfeld/actions.py +34 -0
- patchfeld/activity/__init__.py +0 -0
- patchfeld/activity/log.py +237 -0
- patchfeld/agents/__init__.py +0 -0
- patchfeld/agents/child_tools.py +66 -0
- patchfeld/agents/fake_sdk_adapter.py +45 -0
- patchfeld/agents/manager.py +365 -0
- patchfeld/agents/permission_grants.py +98 -0
- patchfeld/agents/permission_inbox.py +91 -0
- patchfeld/agents/request_inbox.py +65 -0
- patchfeld/agents/sdk_adapter.py +49 -0
- patchfeld/agents/session.py +250 -0
- patchfeld/agents/sort.py +66 -0
- patchfeld/agents/state.py +81 -0
- patchfeld/app.py +1433 -0
- patchfeld/config.py +128 -0
- patchfeld/events.py +260 -0
- patchfeld/layout/__init__.py +0 -0
- patchfeld/layout/custom_widgets.py +82 -0
- patchfeld/layout/defaults.py +33 -0
- patchfeld/layout/engine.py +241 -0
- patchfeld/layout/local_widgets.py +188 -0
- patchfeld/layout/registry.py +69 -0
- patchfeld/layout/spec.py +104 -0
- patchfeld/layout/splitter.py +170 -0
- patchfeld/layout/titles.py +70 -0
- patchfeld/orchestrator/__init__.py +0 -0
- patchfeld/orchestrator/formatting.py +15 -0
- patchfeld/orchestrator/session.py +785 -0
- patchfeld/orchestrator/tabs_tools.py +149 -0
- patchfeld/orchestrator/tools.py +976 -0
- patchfeld/persistence/__init__.py +0 -0
- patchfeld/persistence/agents_index.py +68 -0
- patchfeld/persistence/atomic.py +47 -0
- patchfeld/persistence/layout_store.py +25 -0
- patchfeld/persistence/layouts_store.py +61 -0
- patchfeld/persistence/orchestrator_sessions.py +127 -0
- patchfeld/persistence/paths.py +48 -0
- patchfeld/persistence/themes_store.py +44 -0
- patchfeld/persistence/transcript_store.py +64 -0
- patchfeld/persistence/workspace_store.py +25 -0
- patchfeld/theme/__init__.py +0 -0
- patchfeld/theme/engine.py +75 -0
- patchfeld/theme/spec.py +31 -0
- patchfeld/widgets/__init__.py +0 -0
- patchfeld/widgets/_file_lang.py +36 -0
- patchfeld/widgets/_terminal_keys.py +89 -0
- patchfeld/widgets/_terminal_render.py +147 -0
- patchfeld/widgets/activity_feed.py +365 -0
- patchfeld/widgets/agent_table.py +236 -0
- patchfeld/widgets/agent_transcript.py +85 -0
- patchfeld/widgets/change_cwd_screen.py +39 -0
- patchfeld/widgets/chrome.py +210 -0
- patchfeld/widgets/diff_viewer.py +52 -0
- patchfeld/widgets/file_editor.py +258 -0
- patchfeld/widgets/file_tree.py +33 -0
- patchfeld/widgets/file_viewer.py +77 -0
- patchfeld/widgets/history_screen.py +58 -0
- patchfeld/widgets/layout_switcher.py +126 -0
- patchfeld/widgets/log_tail.py +113 -0
- patchfeld/widgets/markdown.py +65 -0
- patchfeld/widgets/new_tab_screen.py +31 -0
- patchfeld/widgets/notebook.py +45 -0
- patchfeld/widgets/orchestrator_chat.py +73 -0
- patchfeld/widgets/permission_modal.py +185 -0
- patchfeld/widgets/permission_request_bar.py +90 -0
- patchfeld/widgets/resume_screen.py +179 -0
- patchfeld/widgets/rich_transcript.py +606 -0
- patchfeld/widgets/system_usage.py +244 -0
- patchfeld/widgets/terminal.py +251 -0
- patchfeld/widgets/theme_switcher.py +63 -0
- patchfeld/widgets/transcript_screen.py +39 -0
- patchfeld/workspace/__init__.py +3 -0
- patchfeld/workspace/spec.py +72 -0
- patchfeld-0.2.0.dist-info/METADATA +584 -0
- patchfeld-0.2.0.dist-info/RECORD +81 -0
- patchfeld-0.2.0.dist-info/WHEEL +4 -0
- patchfeld-0.2.0.dist-info/entry_points.txt +3 -0
- 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"
|