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,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))
|