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,89 @@
1
+ """Pure mapping from Textual key events to xterm-compatible byte sequences.
2
+
3
+ Default xterm cursor-key mode (no DECCKM application mode) is assumed —
4
+ that's what real shells expect by default. If we later support DECCKM,
5
+ we'll route through here too.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ ESC = b"\x1b"
11
+
12
+ _SIMPLE: dict[str, bytes] = {
13
+ "enter": b"\r",
14
+ "tab": b"\t",
15
+ "shift+tab": b"\x1b[Z",
16
+ "backspace": b"\x7f",
17
+ "escape": b"\x1b",
18
+ "space": b" ",
19
+ "up": b"\x1b[A",
20
+ "down": b"\x1b[B",
21
+ "right": b"\x1b[C",
22
+ "left": b"\x1b[D",
23
+ "home": b"\x1b[H",
24
+ "end": b"\x1b[F",
25
+ "pageup": b"\x1b[5~",
26
+ "pagedown": b"\x1b[6~",
27
+ "insert": b"\x1b[2~",
28
+ "delete": b"\x1b[3~",
29
+ "f1": b"\x1bOP",
30
+ "f2": b"\x1bOQ",
31
+ "f3": b"\x1bOR",
32
+ "f4": b"\x1bOS",
33
+ "f5": b"\x1b[15~",
34
+ "f6": b"\x1b[17~",
35
+ "f7": b"\x1b[18~",
36
+ "f8": b"\x1b[19~",
37
+ "f9": b"\x1b[20~",
38
+ "f10": b"\x1b[21~",
39
+ "f11": b"\x1b[23~",
40
+ "f12": b"\x1b[24~",
41
+ }
42
+
43
+ _CTRL_NAMED: dict[str, bytes] = {
44
+ "ctrl+space": b"\x00",
45
+ "ctrl+at": b"\x00",
46
+ "ctrl+backslash": b"\x1c",
47
+ "ctrl+right_square_bracket": b"\x1d",
48
+ "ctrl+slash": b"\x1f",
49
+ "ctrl+underscore": b"\x1f",
50
+ "ctrl+question_mark": b"\x7f",
51
+ }
52
+
53
+
54
+ def encode_key(key: str, character: str | None) -> bytes | None:
55
+ """Map a Textual key+character to xterm-style bytes; None if unhandled.
56
+
57
+ Args:
58
+ key: Textual's key descriptor (e.g. "up", "ctrl+c", "alt+x", "f5").
59
+ character: The typed character if any (Textual provides this for
60
+ printable keys including Unicode).
61
+ """
62
+ # Alt+X → ESC + (recursively encoded X).
63
+ if key.startswith("alt+"):
64
+ rest = key[len("alt+") :]
65
+ # Pass the character through only if it matches the un-prefixed key
66
+ # (the printable Alt+letter case, e.g. ("alt+a","a") → recurse with ("a","a")).
67
+ # For named keys like "alt+up" we want recursion to hit _SIMPLE, so drop
68
+ # any character we got (Textual usually doesn't supply one for those anyway).
69
+ sub_char = character if character == rest else None
70
+ sub = encode_key(rest, sub_char)
71
+ if sub is None and character is not None:
72
+ sub = character.encode("utf-8")
73
+ return None if sub is None else ESC + sub
74
+
75
+ if key in _SIMPLE:
76
+ return _SIMPLE[key]
77
+
78
+ if key.startswith("ctrl+"):
79
+ suffix = key[len("ctrl+") :]
80
+ if len(suffix) == 1 and "a" <= suffix.lower() <= "z":
81
+ return bytes([ord(suffix.lower()) - ord("a") + 1])
82
+ if key in _CTRL_NAMED:
83
+ return _CTRL_NAMED[key]
84
+ return None
85
+
86
+ if character is not None and len(character) >= 1:
87
+ return character.encode("utf-8")
88
+
89
+ return None
@@ -0,0 +1,147 @@
1
+ """Pure helpers that turn a pyte.Screen into a rich.text.Text.
2
+
3
+ Kept module-private and side-effect-free so they're trivial to unit-test
4
+ without spinning up a PTY or a Textual app.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import pyte
10
+ from rich.style import Style
11
+ from rich.text import Text
12
+
13
+ # Rich understands these named colors directly (subset of rich.color.ANSI_COLOR_NAMES
14
+ # we expect from pyte after translation below).
15
+ _NAMED_COLORS = frozenset({
16
+ "default",
17
+ "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
18
+ "bright_black", "bright_red", "bright_green", "bright_yellow",
19
+ "bright_blue", "bright_magenta", "bright_cyan", "bright_white",
20
+ })
21
+
22
+ # pyte uses some color names Rich doesn't recognise (notably "brown" for SGR 33
23
+ # and "bright<color>" without an underscore). Translate them to the Rich form
24
+ # before pass-through so Style() construction never raises ColorParseError.
25
+ _PYTE_TO_RICH_NAME = {
26
+ "brown": "yellow",
27
+ "brightblack": "bright_black",
28
+ "brightred": "bright_red",
29
+ "brightgreen": "bright_green",
30
+ # pyte composes SGR 93 (bright yellow) as "bright" + "brown" rather than
31
+ # reusing "brightyellow", so we have to map "brightbrown" specifically.
32
+ # Keep "brightyellow" as defense-in-depth in case future pyte versions
33
+ # change the convention.
34
+ "brightbrown": "bright_yellow",
35
+ "brightyellow": "bright_yellow",
36
+ "brightblue": "bright_blue",
37
+ "brightmagenta": "bright_magenta",
38
+ "brightcyan": "bright_cyan",
39
+ "brightwhite": "bright_white",
40
+ }
41
+
42
+
43
+ def _color_to_rich(color: str) -> str | None:
44
+ """Translate a pyte color string to a Rich color spec, or None for default."""
45
+ if not color or color == "default":
46
+ return None
47
+ # Translate pyte-only names (e.g. 'brown', 'brightred') to their Rich forms.
48
+ if color in _PYTE_TO_RICH_NAME:
49
+ return _PYTE_TO_RICH_NAME[color]
50
+ if color in _NAMED_COLORS:
51
+ return color
52
+ # 6-char hex string (truecolor or 256-color resolved by pyte)
53
+ if len(color) == 6 and all(ch in "0123456789abcdefABCDEF" for ch in color):
54
+ return f"#{color.lower()}"
55
+ # Unknown -- safest is to drop the styling rather than crash.
56
+ return None
57
+
58
+
59
+ def cell_style(cell: pyte.screens.Char) -> Style | None:
60
+ """Return a Rich Style for a pyte cell, or None for fully-default cells."""
61
+ fg = _color_to_rich(cell.fg)
62
+ bg = _color_to_rich(cell.bg)
63
+ bold = bool(cell.bold)
64
+ italic = bool(cell.italics)
65
+ underline = bool(cell.underscore)
66
+ reverse = bool(cell.reverse)
67
+ strike = bool(cell.strikethrough)
68
+ if not (fg or bg or bold or italic or underline or reverse or strike):
69
+ return None
70
+ return Style(
71
+ color=fg,
72
+ bgcolor=bg,
73
+ bold=bold or None,
74
+ italic=italic or None,
75
+ underline=underline or None,
76
+ reverse=reverse or None,
77
+ strike=strike or None,
78
+ )
79
+
80
+
81
+ def render_screen(screen: pyte.Screen, *, show_cursor: bool) -> Text:
82
+ """Render the visible portion of `screen` into a Rich Text.
83
+
84
+ Cells with identical styles are coalesced into runs to keep the span
85
+ list small (one cell per character would be O(rows*cols) spans).
86
+
87
+ If `show_cursor` is True and the cursor is not hidden, the cell under
88
+ the cursor is rendered as its own one-cell run with reverse XOR'd
89
+ against the underlying cell, so it stays visible even when neighbors
90
+ already carry reverse=True.
91
+ """
92
+ text = Text()
93
+ cols = screen.columns
94
+ rows = screen.lines
95
+ cursor_x = screen.cursor.x
96
+ cursor_y = screen.cursor.y
97
+ cursor_visible = show_cursor and not screen.cursor.hidden
98
+
99
+ for y in range(rows):
100
+ if y > 0:
101
+ text.append("\n")
102
+ line_buf = screen.buffer[y]
103
+ run_chars: list[str] = []
104
+ run_style: Style | None = None
105
+ run_started = False
106
+ for x in range(cols):
107
+ cell = line_buf[x]
108
+ base = cell_style(cell)
109
+ is_cursor = cursor_visible and y == cursor_y and x == cursor_x
110
+ if is_cursor:
111
+ # XOR reverse so the cursor cell visually pops even when the
112
+ # underlying cell already has reverse=True.
113
+ base_reverse = bool(base and base.reverse)
114
+ effective = (base or Style()) + Style(reverse=not base_reverse)
115
+ else:
116
+ effective = base
117
+ data = cell.data or " "
118
+ if is_cursor:
119
+ # Always flush before/after the cursor so it cannot coalesce
120
+ # with adjacent cells that happen to share its effective style.
121
+ _flush(text, run_chars, run_style)
122
+ _flush(text, [data], effective)
123
+ run_chars = []
124
+ run_style = None
125
+ run_started = False
126
+ elif not run_started:
127
+ run_chars = [data]
128
+ run_style = effective
129
+ run_started = True
130
+ elif effective == run_style:
131
+ run_chars.append(data)
132
+ else:
133
+ _flush(text, run_chars, run_style)
134
+ run_chars = [data]
135
+ run_style = effective
136
+ _flush(text, run_chars, run_style)
137
+ return text
138
+
139
+
140
+ def _flush(text: Text, chars: list[str], style: Style | None) -> None:
141
+ if not chars:
142
+ return
143
+ chunk = "".join(chars)
144
+ if style is None:
145
+ text.append(chunk)
146
+ else:
147
+ text.append(chunk, style=style)
@@ -0,0 +1,365 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Callable
4
+
5
+ from textual.app import ComposeResult
6
+ from textual.containers import Container, Horizontal, VerticalScroll
7
+ from textual.widgets import Static
8
+
9
+ from patchfeld.activity.log import ActivityEntry, ActivityKind
10
+ from patchfeld.events import ActivityLogged, AgentFocusRequested
11
+
12
+ MODES: tuple[str, ...] = ("audit", "agents", "notifs", "debug")
13
+
14
+ # Per-mode kind allowlists, derived from the design spec table.
15
+ _MODE_KINDS: dict[str, frozenset[str]] = {
16
+ "audit": frozenset({
17
+ ActivityKind.AGENT_SPAWNED, ActivityKind.AGENT_STATE, ActivityKind.AGENT_DONE,
18
+ ActivityKind.AGENT_ASK, ActivityKind.AGENT_NOTIFY, ActivityKind.AGENT_ARCHIVE,
19
+ ActivityKind.ORCH_USER, ActivityKind.ORCH_REPLY, ActivityKind.ORCH_SESSION,
20
+ ActivityKind.LAYOUT_APPLIED, ActivityKind.LAYOUT_FAILED,
21
+ ActivityKind.TAB_ADDED, ActivityKind.TAB_CLOSED,
22
+ ActivityKind.WORKSPACE_CWD,
23
+ }),
24
+ "agents": frozenset({
25
+ ActivityKind.AGENT_SPAWNED, ActivityKind.AGENT_STATE, ActivityKind.AGENT_DONE,
26
+ ActivityKind.AGENT_MESSAGE, ActivityKind.AGENT_ASK, ActivityKind.AGENT_NOTIFY,
27
+ ActivityKind.AGENT_ARCHIVE,
28
+ }),
29
+ "notifs": frozenset({
30
+ ActivityKind.AGENT_DONE, ActivityKind.AGENT_ASK, ActivityKind.AGENT_NOTIFY,
31
+ ActivityKind.LAYOUT_FAILED, ActivityKind.WORKSPACE_CWD,
32
+ }),
33
+ "debug": frozenset({
34
+ ActivityKind.AGENT_SPAWNED, ActivityKind.AGENT_STATE, ActivityKind.AGENT_DONE,
35
+ ActivityKind.AGENT_MESSAGE, ActivityKind.AGENT_TOOL, ActivityKind.AGENT_ASK,
36
+ ActivityKind.AGENT_NOTIFY, ActivityKind.AGENT_ARCHIVE,
37
+ ActivityKind.ORCH_USER, ActivityKind.ORCH_REPLY, ActivityKind.ORCH_SESSION,
38
+ ActivityKind.LAYOUT_APPLIED, ActivityKind.LAYOUT_FAILED,
39
+ ActivityKind.TAB_ADDED, ActivityKind.TAB_CLOSED, ActivityKind.TAB_SWITCHED,
40
+ ActivityKind.WORKSPACE_CWD, ActivityKind.FILE_SELECTED,
41
+ }),
42
+ }
43
+
44
+
45
+ _VARIANT: dict[str, str] = {
46
+ # Compact: routine signals.
47
+ ActivityKind.TAB_ADDED: "compact",
48
+ ActivityKind.TAB_CLOSED: "compact",
49
+ ActivityKind.TAB_SWITCHED: "compact",
50
+ ActivityKind.LAYOUT_APPLIED: "compact",
51
+ ActivityKind.WORKSPACE_CWD: "compact",
52
+ ActivityKind.AGENT_STATE: "compact",
53
+ ActivityKind.AGENT_ARCHIVE: "compact",
54
+ ActivityKind.FILE_SELECTED: "compact",
55
+ ActivityKind.AGENT_TOOL: "compact",
56
+ ActivityKind.ORCH_SESSION: "compact",
57
+
58
+ # Expanded: carries a body worth reading.
59
+ ActivityKind.ORCH_USER: "expanded",
60
+ ActivityKind.ORCH_REPLY: "expanded",
61
+ ActivityKind.AGENT_MESSAGE: "expanded",
62
+ ActivityKind.AGENT_NOTIFY: "expanded",
63
+ ActivityKind.AGENT_SPAWNED: "expanded",
64
+
65
+ # Card: needs attention.
66
+ ActivityKind.AGENT_ASK: "card",
67
+ ActivityKind.LAYOUT_FAILED: "card",
68
+ # AGENT_DONE: "compact" by default; ERROR overrides to "card" — handled by _variant_for.
69
+ ActivityKind.AGENT_DONE: "compact",
70
+ }
71
+
72
+
73
+ def _variant_for(entry: ActivityEntry) -> str:
74
+ """Pick the variant for an entry. Most kinds map statically via _VARIANT;
75
+ agent.done escalates to 'card' when the underlying state is ERROR."""
76
+ if entry.kind == ActivityKind.AGENT_DONE:
77
+ from patchfeld.events import AgentStateChanged
78
+ from patchfeld.agents.state import AgentState
79
+ raw = entry.raw
80
+ if isinstance(raw, AgentStateChanged) and raw.info.state == AgentState.ERROR:
81
+ return "card"
82
+ return "compact"
83
+ return _VARIANT.get(entry.kind, "compact")
84
+
85
+
86
+ def _click_agent(app, entry: ActivityEntry) -> None:
87
+ if entry.agent_id is None:
88
+ return
89
+ app.event_bus.publish(AgentFocusRequested(agent_id=entry.agent_id))
90
+
91
+
92
+ def _click_layout_failed(app, entry: ActivityEntry) -> None:
93
+ msg = entry.detail or "layout failed"
94
+ app.notify(msg, severity="error")
95
+
96
+
97
+ def _click_tab_added(app, entry: ActivityEntry) -> None:
98
+ if entry.tab_id is None:
99
+ return
100
+ from textual.widgets import TabbedContent
101
+ try:
102
+ tc = app.query_one("#app-tabs", TabbedContent)
103
+ except Exception:
104
+ return
105
+ target = f"tab-{entry.tab_id}"
106
+ try:
107
+ tc.active = target
108
+ except Exception:
109
+ pass
110
+
111
+
112
+ def _click_orch_session(app, _entry: ActivityEntry) -> None:
113
+ # _entry is unused; the signature is fixed by _CLICK_HANDLERS' Callable type.
114
+ try:
115
+ target = app.query("OrchestratorChat #orch-input").first()
116
+ except Exception:
117
+ return
118
+ target.focus()
119
+
120
+
121
+ _CLICK_HANDLERS: dict[str, Callable[[object, ActivityEntry], None]] = {
122
+ ActivityKind.AGENT_SPAWNED: _click_agent,
123
+ ActivityKind.AGENT_STATE: _click_agent,
124
+ ActivityKind.AGENT_DONE: _click_agent,
125
+ ActivityKind.AGENT_MESSAGE: _click_agent,
126
+ ActivityKind.AGENT_TOOL: _click_agent,
127
+ ActivityKind.AGENT_ASK: _click_agent,
128
+ ActivityKind.AGENT_NOTIFY: _click_agent,
129
+ ActivityKind.AGENT_ARCHIVE: _click_agent,
130
+ ActivityKind.LAYOUT_FAILED: _click_layout_failed,
131
+ ActivityKind.TAB_ADDED: _click_tab_added,
132
+ ActivityKind.ORCH_SESSION: _click_orch_session,
133
+ }
134
+
135
+
136
+ class _ModeChip(Static):
137
+ """Clickable mode label inside the chip strip. Carries the mode string;
138
+ parent ActivityFeed reads `event.widget.mode` on click."""
139
+
140
+ DEFAULT_CSS = """
141
+ _ModeChip {
142
+ width: auto;
143
+ padding: 0 1;
144
+ margin: 0 1 0 0;
145
+ border: tall $surface-lighten-2;
146
+ color: $text;
147
+ }
148
+ _ModeChip.-active {
149
+ border: tall $primary;
150
+ color: $primary;
151
+ }
152
+ _ModeChip:hover {
153
+ background: $boost;
154
+ }
155
+ """
156
+
157
+ def __init__(self, mode: str, *, active: bool) -> None:
158
+ super().__init__(mode.capitalize())
159
+ self.mode = mode
160
+ if active:
161
+ self.add_class("-active")
162
+
163
+
164
+ class _ModeChips(Horizontal):
165
+ DEFAULT_CSS = """
166
+ _ModeChips {
167
+ height: auto;
168
+ padding: 0 1;
169
+ background: $boost;
170
+ }
171
+ """
172
+
173
+ def __init__(self, active: str) -> None:
174
+ super().__init__()
175
+ self._active = active
176
+
177
+ def compose(self) -> ComposeResult:
178
+ for m in MODES:
179
+ yield _ModeChip(m, active=(m == self._active))
180
+
181
+
182
+ class _ActivityRow(Static):
183
+ """One feed row. Variant comes from `_variant_for(entry)`; CSS classes
184
+ `-variant-compact|expanded|card` drive presentation. Rows for kinds
185
+ with click handlers in `_CLICK_HANDLERS` add a `-clickable` class for
186
+ hover styling and dispatch on click."""
187
+
188
+ DEFAULT_CSS = """
189
+ _ActivityRow {
190
+ height: auto;
191
+ padding: 0 1;
192
+ }
193
+ _ActivityRow.-variant-card {
194
+ border: round $warning;
195
+ padding: 0 1;
196
+ margin: 0 0 1 0;
197
+ }
198
+ _ActivityRow.-clickable:hover {
199
+ background: $boost;
200
+ }
201
+ """
202
+
203
+ def __init__(self, entry: ActivityEntry) -> None:
204
+ variant = _variant_for(entry)
205
+ text = self._format(entry, variant)
206
+ super().__init__(text)
207
+ self.entry = entry
208
+ # Plain attribute mirroring the rendered string. Static stores its
209
+ # content in a private `_renderable` field that isn't part of the
210
+ # public API; consumers (tests, click handlers) read this instead.
211
+ self.text = text
212
+ self.add_class(f"-variant-{variant}")
213
+ if entry.kind in _CLICK_HANDLERS:
214
+ self.add_class("-clickable")
215
+
216
+ def on_click(self, event) -> None:
217
+ handler = _CLICK_HANDLERS.get(self.entry.kind)
218
+ if handler is None:
219
+ return
220
+ handler(self.app, self.entry)
221
+ event.stop()
222
+
223
+ @staticmethod
224
+ def _format(entry: ActivityEntry, variant: str) -> str:
225
+ ts = entry.timestamp.strftime("%H:%M:%S")
226
+ head = f"[{ts}] {entry.kind:<18} {entry.summary}"
227
+ if variant == "compact" or not entry.detail:
228
+ return head
229
+ if variant == "expanded":
230
+ return f"{head}\n ↳ {entry.detail}"
231
+ # card
232
+ return f"{entry.kind} · {entry.summary}\n{entry.detail}"
233
+
234
+
235
+ class ActivityFeed(Container):
236
+ """Real Activity Feed. Reads backlog from `app.activity_log` on mount,
237
+ subscribes to `ActivityLogged` for live updates, and renders rows whose
238
+ `kind` is allowed by the current `mode`. Mode is selected via the `mode`
239
+ prop (one of `MODES`); invalid values silently fall back to `"audit"`."""
240
+
241
+ DEFAULT_BORDER_TITLE = "Activity"
242
+
243
+ DEFAULT_CSS = """
244
+ ActivityFeed {
245
+ border: round $surface-lighten-2;
246
+ padding: 0 1;
247
+ }
248
+ ActivityFeed VerticalScroll {
249
+ height: 1fr;
250
+ }
251
+ """
252
+
253
+ def __init__(self, *, mode: str | None = None) -> None:
254
+ super().__init__()
255
+ if mode is not None and mode not in _MODE_KINDS:
256
+ mode = None # silently fall back to default; no invariant break
257
+ self.mode: str = mode or "audit"
258
+ self._unsub = None
259
+
260
+ def compose(self) -> ComposeResult:
261
+ yield _ModeChips(active=self.mode)
262
+ yield VerticalScroll(id="activity-rows")
263
+
264
+ def on_mount(self) -> None:
265
+ # Tolerate test contexts where the app fixture skipped wiring
266
+ # event_bus / activity_log (mirrors AgentTable's defensive pattern).
267
+ bus = getattr(self.app, "event_bus", None)
268
+ log = getattr(self.app, "activity_log", None)
269
+ if bus is None or log is None:
270
+ return
271
+ scroll = self.query_one("#activity-rows", VerticalScroll)
272
+ allow = _MODE_KINDS[self.mode]
273
+ for entry in log.entries():
274
+ if entry.kind in allow:
275
+ scroll.mount(_ActivityRow(entry))
276
+ self._unsub = bus.subscribe(ActivityLogged, self._on_logged)
277
+
278
+ def on_unmount(self) -> None:
279
+ if self._unsub is not None:
280
+ self._unsub()
281
+ self._unsub = None
282
+
283
+ def _is_at_bottom(self) -> bool:
284
+ scroll = self.query_one("#activity-rows", VerticalScroll)
285
+ # Treat anything within 2 cells of bottom as "at bottom" — accounts
286
+ # for fractional scrolls and avoids edge-case desync.
287
+ return scroll.max_scroll_y - scroll.scroll_y <= 2
288
+
289
+ def _on_logged(self, event: ActivityLogged) -> None:
290
+ entry: ActivityEntry = event.entry # type: ignore[assignment]
291
+ if entry.kind not in _MODE_KINDS[self.mode]:
292
+ return
293
+ scroll = self.query_one("#activity-rows", VerticalScroll)
294
+ was_following = self._is_at_bottom()
295
+ scroll.mount(_ActivityRow(entry))
296
+ if was_following:
297
+ # call_after_refresh so the new row's height is included in
298
+ # max_scroll_y before we jump.
299
+ self.call_after_refresh(lambda: scroll.scroll_end(animate=False))
300
+
301
+ def on_click(self, event) -> None:
302
+ # Identify whether the click landed on a _ModeChip and switch.
303
+ target = event.widget if hasattr(event, "widget") else None
304
+ if not isinstance(target, _ModeChip):
305
+ return
306
+ new_mode = target.mode
307
+ if new_mode == self.mode:
308
+ return
309
+ self._set_mode(new_mode)
310
+ event.stop()
311
+
312
+ def _set_mode(self, new_mode: str) -> None:
313
+ self.mode = new_mode
314
+ # Update chip styling.
315
+ for chip in self.query(_ModeChip):
316
+ chip.set_class(chip.mode == new_mode, "-active")
317
+ # Rebuild the scroll region for the new mode.
318
+ scroll = self.query_one("#activity-rows", VerticalScroll)
319
+ scroll.remove_children()
320
+ log = getattr(self.app, "activity_log", None)
321
+ if log is not None:
322
+ allow = _MODE_KINDS[new_mode]
323
+ for entry in log.entries():
324
+ if entry.kind in allow:
325
+ scroll.mount(_ActivityRow(entry))
326
+ # Persist the new mode into the layout JSON for this panel.
327
+ self._persist_mode(new_mode)
328
+
329
+ def _persist_mode(self, new_mode: str) -> None:
330
+ """Walk the active tab's layout dict, find this widget's panel entry
331
+ by id (panel-{node.id} → node.id == self.id minus prefix), update its
332
+ `props.mode`, and call app._apply_to_tab to validate + save."""
333
+ app = self.app
334
+ active_tab_id = getattr(app, "_active_tab_id", None)
335
+ ws = getattr(app, "_workspace", None)
336
+ if active_tab_id is None or ws is None:
337
+ return
338
+ # The widget id is "panel-{node.id}". Extract the node id.
339
+ if not self.id or not self.id.startswith("panel-"):
340
+ return
341
+ node_id = self.id[len("panel-"):]
342
+ # Find the active tab's spec, deep-copy it, mutate the matching panel.
343
+ from patchfeld.layout.spec import LayoutSpec
344
+ target_tab = next((t for t in ws.tabs if t.id == active_tab_id), None)
345
+ if target_tab is None:
346
+ return
347
+ spec_dict = target_tab.layout.model_dump(mode="json")
348
+
349
+ def _walk(node: dict) -> bool:
350
+ if node.get("widget") == "ActivityFeed" and node.get("id") == node_id:
351
+ node.setdefault("props", {})["mode"] = new_mode
352
+ return True
353
+ for child in node.get("children", []) or []:
354
+ if _walk(child):
355
+ return True
356
+ return False
357
+
358
+ if not _walk(spec_dict["layout"]):
359
+ return
360
+ try:
361
+ new_spec = LayoutSpec.model_validate(spec_dict)
362
+ except Exception:
363
+ return
364
+ import asyncio as _asyncio
365
+ _asyncio.create_task(app._apply_to_tab(active_tab_id, new_spec))