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,236 @@
|
|
|
1
|
+
from textual.app import ComposeResult
|
|
2
|
+
from textual.binding import Binding
|
|
3
|
+
from textual.containers import Container
|
|
4
|
+
from textual.widgets import DataTable
|
|
5
|
+
|
|
6
|
+
from patchfeld.agents.sort import sort_agents
|
|
7
|
+
from patchfeld.agents.state import AgentInfo
|
|
8
|
+
from patchfeld.events import (
|
|
9
|
+
AgentArchiveChanged,
|
|
10
|
+
AgentFocusRequested,
|
|
11
|
+
AgentMessageAppended,
|
|
12
|
+
AgentSpawned,
|
|
13
|
+
AgentStateChanged,
|
|
14
|
+
EventBus,
|
|
15
|
+
)
|
|
16
|
+
from patchfeld.persistence.agents_index import AgentsIndex
|
|
17
|
+
|
|
18
|
+
from patchfeld.agents.state import AgentState as _AgentState
|
|
19
|
+
|
|
20
|
+
_STATUS_STYLES: dict[_AgentState, str] = {
|
|
21
|
+
_AgentState.IDLE: "dim",
|
|
22
|
+
_AgentState.RUNNING: "green",
|
|
23
|
+
_AgentState.WAITING: "yellow",
|
|
24
|
+
_AgentState.AWAITING_PERMISSION: "orange1",
|
|
25
|
+
_AgentState.DONE: "bold",
|
|
26
|
+
_AgentState.ERROR: "red",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AgentTable(Container):
|
|
31
|
+
"""Sortable table of agents — name, status, elapsed, last action, cost.
|
|
32
|
+
|
|
33
|
+
Archived agents are hidden by default; press `a` to toggle visibility,
|
|
34
|
+
`d` to archive (or un-archive, when shown) the cursor's agent.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
DEFAULT_BORDER_TITLE = "Agents"
|
|
38
|
+
|
|
39
|
+
DEFAULT_CSS = """
|
|
40
|
+
AgentTable {
|
|
41
|
+
border: round $surface-lighten-2;
|
|
42
|
+
padding: 0 1;
|
|
43
|
+
}
|
|
44
|
+
AgentTable DataTable {
|
|
45
|
+
height: 1fr;
|
|
46
|
+
}
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
BINDINGS = [
|
|
50
|
+
Binding("d", "toggle_archive", "archive/unarchive"),
|
|
51
|
+
Binding("a", "toggle_show_archived", "show/hide archived"),
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
COLUMNS = ("name", "status", "elapsed", "last action", "cost")
|
|
55
|
+
|
|
56
|
+
def __init__(self, *, event_bus: EventBus | None = None) -> None:
|
|
57
|
+
super().__init__()
|
|
58
|
+
self._bus = event_bus
|
|
59
|
+
# agent_id → row_key for DataTable updates. Only contains agents
|
|
60
|
+
# whose row is currently rendered (archived rows are absent when
|
|
61
|
+
# `_show_archived` is False).
|
|
62
|
+
self._rows: dict[str, str] = {}
|
|
63
|
+
# agent_id → last AgentInfo snapshot. This is the canonical source
|
|
64
|
+
# for filtering and re-rendering; it includes archived agents that
|
|
65
|
+
# may not currently have a row in the DataTable.
|
|
66
|
+
self._infos: dict[str, AgentInfo] = {}
|
|
67
|
+
# agent_id → most recent message text (last action).
|
|
68
|
+
self._last_actions: dict[str, str] = {}
|
|
69
|
+
# When False, archived agents are filtered out of the table.
|
|
70
|
+
self._show_archived: bool = False
|
|
71
|
+
self._unsubs: list = []
|
|
72
|
+
|
|
73
|
+
def compose(self) -> ComposeResult:
|
|
74
|
+
table = DataTable(zebra_stripes=True, cursor_type="row")
|
|
75
|
+
for col in self.COLUMNS:
|
|
76
|
+
table.add_column(col, key=col)
|
|
77
|
+
yield table
|
|
78
|
+
|
|
79
|
+
def on_mount(self) -> None:
|
|
80
|
+
# Seed past agents from disk so a fresh process boot still surfaces
|
|
81
|
+
# the agents the user spawned in the previous session. AgentManager
|
|
82
|
+
# has already reconciled any non-terminal records to ERROR, so what
|
|
83
|
+
# we read here is safe to display as-is.
|
|
84
|
+
cwd = getattr(self.app, "cwd", None)
|
|
85
|
+
if cwd is not None:
|
|
86
|
+
for info in AgentsIndex(cwd=cwd).load():
|
|
87
|
+
if info.id == "orchestrator":
|
|
88
|
+
continue
|
|
89
|
+
# Just record into _infos; the rebuild below renders rows in order.
|
|
90
|
+
self._infos[info.id] = info
|
|
91
|
+
self._rebuild_sorted()
|
|
92
|
+
|
|
93
|
+
bus = self._bus or getattr(self.app, "event_bus", None)
|
|
94
|
+
if bus is None:
|
|
95
|
+
return
|
|
96
|
+
self._unsubs.append(bus.subscribe(AgentSpawned, self._on_spawned))
|
|
97
|
+
self._unsubs.append(bus.subscribe(AgentStateChanged, self._on_state))
|
|
98
|
+
self._unsubs.append(bus.subscribe(AgentMessageAppended, self._on_msg))
|
|
99
|
+
self._unsubs.append(
|
|
100
|
+
bus.subscribe(AgentArchiveChanged, self._on_archive_changed)
|
|
101
|
+
)
|
|
102
|
+
self._unsubs.append(
|
|
103
|
+
bus.subscribe(AgentFocusRequested, self._on_focus_requested)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def on_unmount(self) -> None:
|
|
107
|
+
for u in self._unsubs:
|
|
108
|
+
u()
|
|
109
|
+
self._unsubs = []
|
|
110
|
+
|
|
111
|
+
# --- event handlers ---------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def _on_spawned(self, event: AgentSpawned) -> None:
|
|
114
|
+
# Record the new agent and rebuild so it lands at the right priority.
|
|
115
|
+
self._infos[event.info.id] = event.info
|
|
116
|
+
self._rebuild_sorted()
|
|
117
|
+
|
|
118
|
+
def _on_state(self, event: AgentStateChanged) -> None:
|
|
119
|
+
# Preserve any archived flag we already know about — AgentStateChanged
|
|
120
|
+
# is emitted by AgentSession with the live info, but the SDK side
|
|
121
|
+
# doesn't touch `archived`, so an existing snapshot's flag is the
|
|
122
|
+
# source of truth.
|
|
123
|
+
prev = self._infos.get(event.info.id)
|
|
124
|
+
if prev is not None and prev.archived and not event.info.archived:
|
|
125
|
+
event.info.archived = True
|
|
126
|
+
self._infos[event.info.id] = event.info
|
|
127
|
+
self._rebuild_sorted()
|
|
128
|
+
|
|
129
|
+
def _on_msg(self, event: AgentMessageAppended) -> None:
|
|
130
|
+
self._last_actions[event.agent_id] = f"[{event.role}] {event.text[:60]}"
|
|
131
|
+
if event.agent_id in self._infos:
|
|
132
|
+
# last_activity feeds the sort tiebreaker, so rebuild to let the
|
|
133
|
+
# row bubble up within its bucket and refresh its last-action cell.
|
|
134
|
+
self._rebuild_sorted()
|
|
135
|
+
|
|
136
|
+
def _on_archive_changed(self, event: AgentArchiveChanged) -> None:
|
|
137
|
+
self._infos[event.info.id] = event.info
|
|
138
|
+
self._rebuild_sorted()
|
|
139
|
+
|
|
140
|
+
def _on_focus_requested(self, event: AgentFocusRequested) -> None:
|
|
141
|
+
"""Select the row matching event.agent_id and scroll it into view."""
|
|
142
|
+
agent_id = event.agent_id
|
|
143
|
+
if agent_id not in self._rows:
|
|
144
|
+
return
|
|
145
|
+
try:
|
|
146
|
+
table = self.query_one(DataTable)
|
|
147
|
+
except Exception:
|
|
148
|
+
return
|
|
149
|
+
for index, row_key in enumerate(table.rows.keys()):
|
|
150
|
+
if str(row_key.value) == agent_id:
|
|
151
|
+
table.move_cursor(row=index)
|
|
152
|
+
table.scroll_to(0, index, animate=False)
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
# --- actions ----------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def action_toggle_archive(self) -> None:
|
|
158
|
+
manager = getattr(self.app, "manager", None)
|
|
159
|
+
if manager is None:
|
|
160
|
+
return
|
|
161
|
+
agent_id = self._cursor_agent_id()
|
|
162
|
+
if agent_id is None:
|
|
163
|
+
return
|
|
164
|
+
info = self._infos.get(agent_id)
|
|
165
|
+
if info is None:
|
|
166
|
+
return
|
|
167
|
+
manager.set_archived(agent_id, archived=not info.archived)
|
|
168
|
+
|
|
169
|
+
def action_toggle_show_archived(self) -> None:
|
|
170
|
+
self._show_archived = not self._show_archived
|
|
171
|
+
self._rebuild_sorted()
|
|
172
|
+
|
|
173
|
+
# --- internals --------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
def _cursor_agent_id(self) -> str | None:
|
|
176
|
+
table = self.query_one(DataTable)
|
|
177
|
+
if table.row_count == 0:
|
|
178
|
+
return None
|
|
179
|
+
try:
|
|
180
|
+
row_key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key
|
|
181
|
+
except Exception:
|
|
182
|
+
return None
|
|
183
|
+
return None if row_key.value is None else str(row_key.value)
|
|
184
|
+
|
|
185
|
+
def _is_visible(self, info: AgentInfo) -> bool:
|
|
186
|
+
return self._show_archived or not info.archived
|
|
187
|
+
|
|
188
|
+
def _rebuild_sorted(self) -> None:
|
|
189
|
+
"""Clear and re-add rows from `_infos` in default sort order, honoring
|
|
190
|
+
the visibility filter. Preserves the cursor's focused agent across the
|
|
191
|
+
rebuild so a sort-induced reorder doesn't snap the user back to row 0."""
|
|
192
|
+
table = self.query_one(DataTable)
|
|
193
|
+
|
|
194
|
+
# Capture cursor agent BEFORE clear(); coordinate_to_cell_key throws
|
|
195
|
+
# after the table is empty.
|
|
196
|
+
cursor_agent_id = self._cursor_agent_id()
|
|
197
|
+
|
|
198
|
+
table.clear()
|
|
199
|
+
self._rows.clear()
|
|
200
|
+
|
|
201
|
+
visible = [info for info in self._infos.values() if self._is_visible(info)]
|
|
202
|
+
for info in sort_agents(visible):
|
|
203
|
+
table.add_row(*self._render_cells(info), key=info.id)
|
|
204
|
+
self._rows[info.id] = info.id
|
|
205
|
+
|
|
206
|
+
# Restore cursor onto the same agent if it's still visible.
|
|
207
|
+
if cursor_agent_id is not None and cursor_agent_id in self._rows:
|
|
208
|
+
for index, row_key in enumerate(table.rows.keys()):
|
|
209
|
+
if str(row_key.value) == cursor_agent_id:
|
|
210
|
+
table.move_cursor(row=index)
|
|
211
|
+
break
|
|
212
|
+
|
|
213
|
+
def _render_cells(self, info: AgentInfo) -> tuple:
|
|
214
|
+
# Wrap each cell in Rich Text so values that may contain markup-like
|
|
215
|
+
# text (especially the "last action" cell which echoes tool args)
|
|
216
|
+
# render verbatim rather than tripping the markup parser.
|
|
217
|
+
from rich.text import Text
|
|
218
|
+
elapsed = info.elapsed_seconds()
|
|
219
|
+
elapsed_str = f"{elapsed:5.1f}s"
|
|
220
|
+
last = self._last_actions.get(info.id, "")
|
|
221
|
+
cost_str = f"${info.cost:.4f}"
|
|
222
|
+
if info.archived:
|
|
223
|
+
status = "archived"
|
|
224
|
+
status_style = ""
|
|
225
|
+
name = f"{info.name} (archived)"
|
|
226
|
+
else:
|
|
227
|
+
status = info.state.value
|
|
228
|
+
status_style = _STATUS_STYLES.get(info.state, "")
|
|
229
|
+
name = info.name
|
|
230
|
+
return (
|
|
231
|
+
Text(name),
|
|
232
|
+
Text(status, style=status_style),
|
|
233
|
+
Text(elapsed_str),
|
|
234
|
+
Text(last),
|
|
235
|
+
Text(cost_str),
|
|
236
|
+
)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from textual.app import ComposeResult
|
|
2
|
+
from textual.containers import Vertical
|
|
3
|
+
from textual.widgets import Input
|
|
4
|
+
|
|
5
|
+
from patchfeld.events import DirectMessageToAgent, EventBus, PermissionRequested, PermissionResolved
|
|
6
|
+
from patchfeld.widgets.rich_transcript import RichTranscript
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AgentTranscript(Vertical):
|
|
10
|
+
"""Per-child-agent transcript panel: RichTranscript + input box."""
|
|
11
|
+
|
|
12
|
+
DEFAULT_CSS = """
|
|
13
|
+
AgentTranscript {
|
|
14
|
+
border: round $surface-lighten-2;
|
|
15
|
+
padding: 0 1;
|
|
16
|
+
}
|
|
17
|
+
AgentTranscript > RichTranscript {
|
|
18
|
+
height: 1fr;
|
|
19
|
+
}
|
|
20
|
+
AgentTranscript #transcript-input {
|
|
21
|
+
dock: bottom;
|
|
22
|
+
height: 3;
|
|
23
|
+
}
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
*,
|
|
29
|
+
agent_id: str,
|
|
30
|
+
event_bus: EventBus | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
super().__init__()
|
|
33
|
+
self._agent_id = agent_id
|
|
34
|
+
self._bus = event_bus
|
|
35
|
+
self._unsub_perm_req = lambda: None
|
|
36
|
+
self._unsub_perm_res = lambda: None
|
|
37
|
+
|
|
38
|
+
def compose(self) -> ComposeResult:
|
|
39
|
+
yield RichTranscript(agent_id=self._agent_id, event_bus=self._bus)
|
|
40
|
+
yield Input(placeholder=f"Message {self._agent_id}…", id="transcript-input")
|
|
41
|
+
|
|
42
|
+
def on_mount(self) -> None:
|
|
43
|
+
bus = self._bus or getattr(self.app, "event_bus", None)
|
|
44
|
+
if bus is not None:
|
|
45
|
+
self._unsub_perm_req = bus.subscribe(PermissionRequested, self._on_perm_request)
|
|
46
|
+
self._unsub_perm_res = bus.subscribe(PermissionResolved, self._on_perm_resolved)
|
|
47
|
+
|
|
48
|
+
def on_unmount(self) -> None:
|
|
49
|
+
self._unsub_perm_req()
|
|
50
|
+
self._unsub_perm_res()
|
|
51
|
+
|
|
52
|
+
def _on_perm_request(self, event: PermissionRequested) -> None:
|
|
53
|
+
if event.agent_id != self._agent_id:
|
|
54
|
+
return
|
|
55
|
+
from patchfeld.widgets.permission_request_bar import PermissionRequestBar
|
|
56
|
+
bar = PermissionRequestBar(request=event)
|
|
57
|
+
self.mount(bar, before=self.query_one(RichTranscript))
|
|
58
|
+
|
|
59
|
+
def _on_perm_resolved(self, event: PermissionResolved) -> None:
|
|
60
|
+
if event.agent_id != self._agent_id:
|
|
61
|
+
return
|
|
62
|
+
from patchfeld.widgets.permission_request_bar import PermissionRequestBar
|
|
63
|
+
for bar in list(self.query(PermissionRequestBar)):
|
|
64
|
+
if bar.request_id == event.request_id:
|
|
65
|
+
bar.remove()
|
|
66
|
+
|
|
67
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
68
|
+
text = event.value.strip()
|
|
69
|
+
if not text:
|
|
70
|
+
return
|
|
71
|
+
bus = self._bus or getattr(self.app, "event_bus", None)
|
|
72
|
+
if bus is not None:
|
|
73
|
+
bus.publish(DirectMessageToAgent(agent_id=self._agent_id, text=text))
|
|
74
|
+
event.input.value = ""
|
|
75
|
+
|
|
76
|
+
def rendered_text(self) -> str:
|
|
77
|
+
"""Test helper — delegates to the inner RichTranscript."""
|
|
78
|
+
return self.query_one(RichTranscript).rendered_text()
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def default_border_title(cls, props: dict) -> str:
|
|
82
|
+
agent_id = props.get("agent_id")
|
|
83
|
+
if agent_id:
|
|
84
|
+
return f"Agent: {agent_id}"
|
|
85
|
+
return "Agent"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from textual.app import ComposeResult
|
|
2
|
+
from textual.containers import Vertical
|
|
3
|
+
from textual.screen import ModalScreen
|
|
4
|
+
from textual.widgets import Input, Static
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ChangeCwdScreen(ModalScreen[str | None]):
|
|
8
|
+
"""Tiny modal that asks for a new workspace cwd. Dismisses with the
|
|
9
|
+
trimmed string on submit, or None on escape."""
|
|
10
|
+
|
|
11
|
+
DEFAULT_CSS = """
|
|
12
|
+
ChangeCwdScreen { align: center middle; }
|
|
13
|
+
ChangeCwdScreen > Vertical {
|
|
14
|
+
width: 70; height: auto; padding: 1 2;
|
|
15
|
+
background: $surface; border: round $primary;
|
|
16
|
+
}
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
BINDINGS = [("escape", "cancel", "cancel")]
|
|
20
|
+
|
|
21
|
+
def __init__(self, *, initial: str = "") -> None:
|
|
22
|
+
super().__init__()
|
|
23
|
+
self._initial = initial
|
|
24
|
+
|
|
25
|
+
def compose(self) -> ComposeResult:
|
|
26
|
+
with Vertical():
|
|
27
|
+
yield Static("Change workspace cwd:")
|
|
28
|
+
yield Input(
|
|
29
|
+
value=self._initial,
|
|
30
|
+
placeholder="e.g., ~/Developer/other-project",
|
|
31
|
+
id="change-cwd-input",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
35
|
+
path = (event.value or "").strip()
|
|
36
|
+
self.dismiss(path or None)
|
|
37
|
+
|
|
38
|
+
def action_cancel(self) -> None:
|
|
39
|
+
self.dismiss(None)
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from textual.app import ComposeResult
|
|
5
|
+
from textual.containers import Horizontal
|
|
6
|
+
from textual.widgets import Input, Static
|
|
7
|
+
|
|
8
|
+
from patchfeld.events import EventBus, OrchestratorReply, UserMessageToOrchestrator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _format_cwd(path: Path, *, available_width: int) -> str:
|
|
12
|
+
"""Render `path` for the StatusBar, abbreviating under $HOME and
|
|
13
|
+
left-truncating with '…/' to fit `available_width` characters.
|
|
14
|
+
|
|
15
|
+
Pure: no I/O, no widget access. Drives `_on_cwd_changed` and
|
|
16
|
+
`on_resize` in StatusBar.
|
|
17
|
+
|
|
18
|
+
Uses ``os.path.abspath`` rather than ``Path.resolve`` so the
|
|
19
|
+
formatter does not follow symlinks — the user sees the path they
|
|
20
|
+
typed (e.g. ``/var/log/foo``) rather than the kernel-canonical form
|
|
21
|
+
(``/private/var/log/foo`` on macOS). Path canonicalisation belongs
|
|
22
|
+
in the validation step inside ``App.change_cwd``, not in the footer.
|
|
23
|
+
"""
|
|
24
|
+
try:
|
|
25
|
+
home = Path.home()
|
|
26
|
+
abs_p = Path(os.path.abspath(path))
|
|
27
|
+
try:
|
|
28
|
+
rel = abs_p.relative_to(home)
|
|
29
|
+
display = "~" + ("/" + str(rel) if str(rel) != "." else "")
|
|
30
|
+
except ValueError:
|
|
31
|
+
display = str(abs_p)
|
|
32
|
+
except Exception:
|
|
33
|
+
display = str(path)
|
|
34
|
+
|
|
35
|
+
if available_width <= 0 or len(display) <= available_width:
|
|
36
|
+
return display
|
|
37
|
+
# Try left-truncation that ends at a segment boundary.
|
|
38
|
+
parts = display.split("/")
|
|
39
|
+
# Keep peeling leading segments until "…/" + tail fits.
|
|
40
|
+
for keep in range(len(parts) - 1, 0, -1):
|
|
41
|
+
candidate = "…/" + "/".join(parts[-keep:])
|
|
42
|
+
if len(candidate) <= available_width:
|
|
43
|
+
return candidate
|
|
44
|
+
# Budget too tight even for "…/leaf" — return bare basename.
|
|
45
|
+
return parts[-1]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CommandBar(Horizontal):
|
|
49
|
+
"""Top bar — `/` focuses; submitting sends to the orchestrator."""
|
|
50
|
+
|
|
51
|
+
DEFAULT_CSS = """
|
|
52
|
+
CommandBar {
|
|
53
|
+
height: 3;
|
|
54
|
+
}
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, *, event_bus: EventBus | None = None) -> None:
|
|
58
|
+
super().__init__()
|
|
59
|
+
self._bus = event_bus
|
|
60
|
+
# True between a command-bar submit and the next OrchestratorReply.
|
|
61
|
+
# Gates the toast so replies from other input surfaces (the
|
|
62
|
+
# orchestrator chat panel) don't pop a toast as well.
|
|
63
|
+
self._awaiting_reply = False
|
|
64
|
+
self._unsub_reply = lambda: None
|
|
65
|
+
|
|
66
|
+
def compose(self) -> ComposeResult:
|
|
67
|
+
# Plain Textual Input with no styling overrides — default 3-row
|
|
68
|
+
# height, default colors, default focus behavior. Earlier attempts
|
|
69
|
+
# to compress this to 1-row (custom CSS, -textual-compact) clashed
|
|
70
|
+
# with Textual's color/cursor internals and produced invisible text.
|
|
71
|
+
yield Input(placeholder="message orchestrator", id="cmd-input")
|
|
72
|
+
|
|
73
|
+
def on_mount(self) -> None:
|
|
74
|
+
bus = self._bus or getattr(self.app, "event_bus", None)
|
|
75
|
+
if bus is not None:
|
|
76
|
+
self._unsub_reply = bus.subscribe(OrchestratorReply, self._on_reply)
|
|
77
|
+
|
|
78
|
+
def on_unmount(self) -> None:
|
|
79
|
+
self._unsub_reply()
|
|
80
|
+
|
|
81
|
+
def focus_input(self) -> None:
|
|
82
|
+
self.query_one("#cmd-input", Input).focus()
|
|
83
|
+
|
|
84
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
85
|
+
if not event.value.strip():
|
|
86
|
+
return
|
|
87
|
+
bus = self._bus or getattr(self.app, "event_bus", None)
|
|
88
|
+
if bus is not None:
|
|
89
|
+
self._awaiting_reply = True
|
|
90
|
+
bus.publish(UserMessageToOrchestrator(event.value))
|
|
91
|
+
event.input.value = ""
|
|
92
|
+
|
|
93
|
+
def _on_reply(self, event: OrchestratorReply) -> None:
|
|
94
|
+
if not self._awaiting_reply:
|
|
95
|
+
return
|
|
96
|
+
self._awaiting_reply = False
|
|
97
|
+
text = (event.text or "").strip()
|
|
98
|
+
if not text:
|
|
99
|
+
return
|
|
100
|
+
try:
|
|
101
|
+
self.app.notify(text, title="orchestrator")
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class StatusBar(Horizontal):
|
|
107
|
+
"""Bottom bar: tokens / cost / active agents / current layout name / [E]."""
|
|
108
|
+
|
|
109
|
+
DEFAULT_CSS = """
|
|
110
|
+
StatusBar {
|
|
111
|
+
height: 1;
|
|
112
|
+
background: $surface-darken-1;
|
|
113
|
+
}
|
|
114
|
+
StatusBar Static {
|
|
115
|
+
width: auto;
|
|
116
|
+
padding: 0 1;
|
|
117
|
+
}
|
|
118
|
+
/* The hints Static expands to absorb all leftover horizontal space and
|
|
119
|
+
right-aligns its text, parking the shortcut hint flush with the right
|
|
120
|
+
edge of the bar regardless of how wide the terminal is. The id selector
|
|
121
|
+
outranks the type selector above (CSS specificity), so it overrides
|
|
122
|
+
`width: auto` for this one widget. */
|
|
123
|
+
StatusBar #sb-hints {
|
|
124
|
+
width: 1fr;
|
|
125
|
+
text-align: right;
|
|
126
|
+
color: $text-muted;
|
|
127
|
+
}
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def __init__(self, *, event_bus: EventBus | None = None,
|
|
131
|
+
layout_name: str = "default") -> None:
|
|
132
|
+
super().__init__()
|
|
133
|
+
self._bus = event_bus
|
|
134
|
+
self._layout_name = layout_name
|
|
135
|
+
self._unsub = lambda: None
|
|
136
|
+
self._unsub_layout = lambda: None
|
|
137
|
+
self._unsub_cwd = lambda: None
|
|
138
|
+
self._cwd_path: Path | None = None
|
|
139
|
+
|
|
140
|
+
def compose(self) -> ComposeResult:
|
|
141
|
+
yield Static("tokens 0/0", id="sb-tokens")
|
|
142
|
+
yield Static("$0.00", id="sb-cost")
|
|
143
|
+
yield Static("0 agents", id="sb-agents")
|
|
144
|
+
yield Static(f"layout: {self._layout_name}", id="sb-layout")
|
|
145
|
+
yield Static("", id="sb-cwd")
|
|
146
|
+
yield Static("", id="sb-error")
|
|
147
|
+
# Always-visible hint for the two most fundamental keybindings:
|
|
148
|
+
# `?` opens the help notification, `Ctrl+Q` quits. Verified against
|
|
149
|
+
# PatchfeldApp.BINDINGS. New users never have to guess how to escape.
|
|
150
|
+
yield Static("? help · ^Q quit", id="sb-hints")
|
|
151
|
+
|
|
152
|
+
def on_mount(self) -> None:
|
|
153
|
+
from patchfeld.events import LayoutApplied, StatsUpdated, WorkspaceCwdChanged
|
|
154
|
+
bus = self._bus or getattr(self.app, "event_bus", None)
|
|
155
|
+
# Initial cwd render — read app.cwd directly so we display correctly
|
|
156
|
+
# even if the WorkspaceCwdChanged event was published before this
|
|
157
|
+
# widget mounted.
|
|
158
|
+
try:
|
|
159
|
+
cwd = getattr(self.app, "cwd", None)
|
|
160
|
+
if cwd is not None:
|
|
161
|
+
self._render_cwd(Path(cwd))
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
if bus is None:
|
|
165
|
+
return
|
|
166
|
+
self._unsub = bus.subscribe(StatsUpdated, self._on_stats)
|
|
167
|
+
self._unsub_layout = bus.subscribe(LayoutApplied, self._on_layout_applied)
|
|
168
|
+
self._unsub_cwd = bus.subscribe(WorkspaceCwdChanged, self._on_cwd_changed)
|
|
169
|
+
|
|
170
|
+
def on_unmount(self) -> None:
|
|
171
|
+
self._unsub()
|
|
172
|
+
self._unsub_layout()
|
|
173
|
+
self._unsub_cwd()
|
|
174
|
+
|
|
175
|
+
def _on_stats(self, event) -> None:
|
|
176
|
+
self.query_one("#sb-tokens", Static).update(
|
|
177
|
+
f"tokens {event.tokens_in}/{event.tokens_out}"
|
|
178
|
+
)
|
|
179
|
+
self.query_one("#sb-cost", Static).update(f"${event.cost:.2f}")
|
|
180
|
+
self.query_one("#sb-agents", Static).update(f"{event.active_agents} agents")
|
|
181
|
+
|
|
182
|
+
def _on_layout_applied(self, event) -> None:
|
|
183
|
+
name = event.layout_name or "default"
|
|
184
|
+
self.set_layout_name(name)
|
|
185
|
+
|
|
186
|
+
def _render_cwd(self, path: Path) -> None:
|
|
187
|
+
self._cwd_path = path
|
|
188
|
+
widget = self.query_one("#sb-cwd", Static)
|
|
189
|
+
# Allocate roughly half the bar width to cwd, capped at 40 chars.
|
|
190
|
+
try:
|
|
191
|
+
container_width = max(self.size.width, 0)
|
|
192
|
+
except Exception:
|
|
193
|
+
container_width = 0
|
|
194
|
+
budget = max(0, min(40, container_width // 2 if container_width else 40))
|
|
195
|
+
widget.update(f"cwd: {_format_cwd(path, available_width=budget)}")
|
|
196
|
+
|
|
197
|
+
def _on_cwd_changed(self, event) -> None:
|
|
198
|
+
self._render_cwd(Path(event.cwd))
|
|
199
|
+
|
|
200
|
+
def on_resize(self, _event) -> None:
|
|
201
|
+
# Re-render so the cwd budget tracks the actual container width.
|
|
202
|
+
if self._cwd_path is not None:
|
|
203
|
+
self._render_cwd(self._cwd_path)
|
|
204
|
+
|
|
205
|
+
def set_layout_name(self, name: str) -> None:
|
|
206
|
+
self._layout_name = name
|
|
207
|
+
self.query_one("#sb-layout", Static).update(f"layout: {name}")
|
|
208
|
+
|
|
209
|
+
def set_error(self, msg: str | None) -> None:
|
|
210
|
+
self.query_one("#sb-error", Static).update("[E]" if msg else "")
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import difflib
|
|
2
|
+
|
|
3
|
+
from rich.syntax import Syntax
|
|
4
|
+
from textual.containers import VerticalScroll
|
|
5
|
+
from textual.widgets import Static
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _compute_diff(before: str, after: str) -> str:
|
|
9
|
+
return "".join(
|
|
10
|
+
difflib.unified_diff(
|
|
11
|
+
before.splitlines(keepends=True),
|
|
12
|
+
after.splitlines(keepends=True),
|
|
13
|
+
fromfile="before",
|
|
14
|
+
tofile="after",
|
|
15
|
+
)
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DiffViewer(VerticalScroll):
|
|
20
|
+
"""Scrollable unified-diff viewer.
|
|
21
|
+
|
|
22
|
+
Accepts either a precomputed `diff: str` or a `before` + `after` pair from
|
|
23
|
+
which a unified diff is computed. The result is rendered as syntax-
|
|
24
|
+
highlighted `diff` content.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
DEFAULT_BORDER_TITLE = "Diff"
|
|
28
|
+
|
|
29
|
+
DEFAULT_CSS = """
|
|
30
|
+
DiffViewer {
|
|
31
|
+
border: round $surface-lighten-2;
|
|
32
|
+
padding: 0 1;
|
|
33
|
+
}
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
diff: str | None = None,
|
|
40
|
+
before: str | None = None,
|
|
41
|
+
after: str | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
super().__init__()
|
|
44
|
+
if diff is None and (before is not None or after is not None):
|
|
45
|
+
diff = _compute_diff(before or "", after or "")
|
|
46
|
+
self.diff_text = diff or ""
|
|
47
|
+
|
|
48
|
+
def compose(self):
|
|
49
|
+
if self.diff_text:
|
|
50
|
+
yield Static(Syntax(self.diff_text, "diff", theme="ansi_dark"))
|
|
51
|
+
else:
|
|
52
|
+
yield Static("[dim]No diff to display[/dim]")
|