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,244 @@
|
|
|
1
|
+
"""SystemUsage widget — compact CPU + RAM gauges with auto-refresh.
|
|
2
|
+
|
|
3
|
+
Single-row layout: ``CPU 23.4% [▰▰▰▱▱…] RAM 8.4/16.0 GiB [▰▰▰▰…]``.
|
|
4
|
+
|
|
5
|
+
`psutil` is preferred when present (cleanest, cross-platform). When absent
|
|
6
|
+
the widget falls back to non-blocking ``top -l 1 -n 0`` + ``vm_stat`` +
|
|
7
|
+
``sysctl hw.memsize`` shell-outs on macOS via
|
|
8
|
+
``asyncio.create_subprocess_exec`` so the Textual event loop is never
|
|
9
|
+
blocked. On unsupported platforms without psutil, the widget renders an
|
|
10
|
+
error banner and stops scheduling refreshes.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
from textual.widgets import Static
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Try psutil but never require it — patchfeld's deps stay minimal. If a user
|
|
24
|
+
# happens to have psutil in their venv (e.g. via a transitive dep), we use
|
|
25
|
+
# it; otherwise we shell out on macOS.
|
|
26
|
+
try:
|
|
27
|
+
import psutil # type: ignore
|
|
28
|
+
_HAS_PSUTIL = True
|
|
29
|
+
except ImportError:
|
|
30
|
+
psutil = None # type: ignore
|
|
31
|
+
_HAS_PSUTIL = False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_FILLED = "▰"
|
|
35
|
+
_EMPTY = "▱"
|
|
36
|
+
_GIB = 1024 ** 3
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class _Sample:
|
|
41
|
+
cpu_pct: float
|
|
42
|
+
ram_used_gib: float
|
|
43
|
+
ram_total_gib: float
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def ram_pct(self) -> float:
|
|
47
|
+
if self.ram_total_gib <= 0:
|
|
48
|
+
return 0.0
|
|
49
|
+
return 100.0 * self.ram_used_gib / self.ram_total_gib
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _color_for(pct: float) -> str:
|
|
53
|
+
if pct < 50:
|
|
54
|
+
return "green"
|
|
55
|
+
if pct < 80:
|
|
56
|
+
return "yellow"
|
|
57
|
+
return "red"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _bar(pct: float, width: int) -> str:
|
|
61
|
+
pct = max(0.0, min(100.0, pct))
|
|
62
|
+
filled = int(round(pct / 100.0 * width))
|
|
63
|
+
filled = max(0, min(width, filled))
|
|
64
|
+
color = _color_for(pct)
|
|
65
|
+
return (
|
|
66
|
+
f"[{color}]{_FILLED * filled}[/]"
|
|
67
|
+
f"[dim]{_EMPTY * (width - filled)}[/]"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# --------------------------------------------------------------- macOS shells
|
|
72
|
+
|
|
73
|
+
_VMSTAT_PAGE_SIZE_RE = re.compile(r"page size of (\d+) bytes")
|
|
74
|
+
_VMSTAT_LINE_RE = re.compile(r"^([^:]+):\s+(\d+)")
|
|
75
|
+
_TOP_CPU_RE = re.compile(
|
|
76
|
+
r"CPU usage:\s+([\d.]+)%\s+user,\s+([\d.]+)%\s+sys,\s+([\d.]+)%\s+idle"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def _run(*argv: str) -> str:
|
|
81
|
+
"""Run a command without blocking the event loop; return stdout text."""
|
|
82
|
+
proc = await asyncio.create_subprocess_exec(
|
|
83
|
+
*argv,
|
|
84
|
+
stdout=asyncio.subprocess.PIPE,
|
|
85
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
86
|
+
)
|
|
87
|
+
out, _ = await proc.communicate()
|
|
88
|
+
return out.decode("utf-8", errors="replace")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def _macos_cpu_pct() -> float:
|
|
92
|
+
"""Parse ``top -l 1 -n 0`` for the CPU usage line.
|
|
93
|
+
|
|
94
|
+
`top` already samples internally; ``user + sys`` is equivalent to
|
|
95
|
+
``100 - idle`` within rounding, and is more robust to spaces/punctuation
|
|
96
|
+
drift than parsing the idle field.
|
|
97
|
+
"""
|
|
98
|
+
text = await _run("top", "-l", "1", "-n", "0")
|
|
99
|
+
m = _TOP_CPU_RE.search(text)
|
|
100
|
+
if not m:
|
|
101
|
+
raise RuntimeError("could not parse CPU usage from `top`")
|
|
102
|
+
return float(m.group(1)) + float(m.group(2))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def _macos_ram_gib() -> tuple[float, float]:
|
|
106
|
+
"""Return ``(used_gib, total_gib)`` using ``vm_stat`` + ``sysctl``.
|
|
107
|
+
|
|
108
|
+
``used = (active + wired_down + occupied_by_compressor) × page_size`` —
|
|
109
|
+
matches Activity Monitor's "Memory Used" closely. Page size is read from
|
|
110
|
+
the ``vm_stat`` header (16 KiB on Apple Silicon, 4 KiB on Intel).
|
|
111
|
+
"""
|
|
112
|
+
vm_text, mem_text = await asyncio.gather(
|
|
113
|
+
_run("vm_stat"),
|
|
114
|
+
_run("sysctl", "-n", "hw.memsize"),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
page_size = 4096
|
|
118
|
+
m = _VMSTAT_PAGE_SIZE_RE.search(vm_text)
|
|
119
|
+
if m:
|
|
120
|
+
page_size = int(m.group(1))
|
|
121
|
+
|
|
122
|
+
pages: dict[str, int] = {}
|
|
123
|
+
for line in vm_text.splitlines():
|
|
124
|
+
mm = _VMSTAT_LINE_RE.match(line)
|
|
125
|
+
if mm:
|
|
126
|
+
pages[mm.group(1).strip().lower()] = int(mm.group(2))
|
|
127
|
+
|
|
128
|
+
active = pages.get("pages active", 0)
|
|
129
|
+
wired = pages.get("pages wired down", 0)
|
|
130
|
+
compressed = pages.get("pages occupied by compressor", 0)
|
|
131
|
+
used_bytes = (active + wired + compressed) * page_size
|
|
132
|
+
|
|
133
|
+
total_bytes = int(mem_text.strip() or "0")
|
|
134
|
+
if total_bytes <= 0:
|
|
135
|
+
raise RuntimeError("hw.memsize returned 0")
|
|
136
|
+
|
|
137
|
+
return used_bytes / _GIB, total_bytes / _GIB
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def _sample_shellout() -> _Sample:
|
|
141
|
+
cpu, ram = await asyncio.gather(_macos_cpu_pct(), _macos_ram_gib())
|
|
142
|
+
used, total = ram
|
|
143
|
+
return _Sample(cpu_pct=cpu, ram_used_gib=used, ram_total_gib=total)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def _sample_psutil() -> _Sample:
|
|
147
|
+
cpu = psutil.cpu_percent(interval=None) # type: ignore[union-attr]
|
|
148
|
+
vm = psutil.virtual_memory() # type: ignore[union-attr]
|
|
149
|
+
return _Sample(
|
|
150
|
+
cpu_pct=float(cpu),
|
|
151
|
+
ram_used_gib=vm.used / _GIB,
|
|
152
|
+
ram_total_gib=vm.total / _GIB,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ----------------------------------------------------------------- the widget
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class SystemUsage(Static):
|
|
160
|
+
"""Single-row CPU + RAM gauge that refreshes itself.
|
|
161
|
+
|
|
162
|
+
``interval`` (seconds, clamped >= 0.25, default 1.5) controls refresh
|
|
163
|
+
cadence. ``bar_width`` (cells, clamped >= 2, default 12) controls the
|
|
164
|
+
width of each progress bar.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
DEFAULT_CSS = """
|
|
168
|
+
SystemUsage {
|
|
169
|
+
height: auto;
|
|
170
|
+
min-height: 1;
|
|
171
|
+
padding: 0 1;
|
|
172
|
+
content-align: left middle;
|
|
173
|
+
}
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
def __init__(
|
|
177
|
+
self,
|
|
178
|
+
*,
|
|
179
|
+
interval: float = 1.5,
|
|
180
|
+
bar_width: int = 12,
|
|
181
|
+
**kwargs,
|
|
182
|
+
) -> None:
|
|
183
|
+
super().__init__("CPU … RAM …", **kwargs)
|
|
184
|
+
self._interval = max(0.25, float(interval))
|
|
185
|
+
self._bar_width = max(2, int(bar_width))
|
|
186
|
+
self._supported = _HAS_PSUTIL or sys.platform == "darwin"
|
|
187
|
+
self._source = "psutil" if _HAS_PSUTIL else "shell"
|
|
188
|
+
self._timer = None
|
|
189
|
+
|
|
190
|
+
def on_mount(self) -> None:
|
|
191
|
+
# Prime psutil so the very first sample isn't 0.0 (psutil's
|
|
192
|
+
# cpu_percent needs a baseline tick to compute deltas against).
|
|
193
|
+
if _HAS_PSUTIL:
|
|
194
|
+
try:
|
|
195
|
+
psutil.cpu_percent(interval=None) # type: ignore[union-attr]
|
|
196
|
+
except Exception:
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
if not self._supported:
|
|
200
|
+
self._show_error(
|
|
201
|
+
f"unsupported platform: {sys.platform} (install psutil)"
|
|
202
|
+
)
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
self.border_title = f"system — {self._source}"
|
|
206
|
+
# Kick one immediate refresh, then schedule periodic ones.
|
|
207
|
+
self.run_worker(self._tick(), exclusive=True)
|
|
208
|
+
self._timer = self.set_interval(self._interval, self._schedule_tick)
|
|
209
|
+
|
|
210
|
+
def _schedule_tick(self) -> None:
|
|
211
|
+
# `set_interval` fires on the event loop; offload the actual sample
|
|
212
|
+
# to a worker so a slow shell-out can never delay the next tick.
|
|
213
|
+
self.run_worker(self._tick(), exclusive=True)
|
|
214
|
+
|
|
215
|
+
async def _tick(self) -> None:
|
|
216
|
+
try:
|
|
217
|
+
if _HAS_PSUTIL:
|
|
218
|
+
sample = await _sample_psutil()
|
|
219
|
+
else:
|
|
220
|
+
sample = await _sample_shellout()
|
|
221
|
+
except Exception as exc: # never raise into the layout
|
|
222
|
+
self._show_error(str(exc))
|
|
223
|
+
return
|
|
224
|
+
self._show_sample(sample)
|
|
225
|
+
|
|
226
|
+
# Named ``_show_*`` rather than ``_render*`` to avoid colliding with
|
|
227
|
+
# ``Widget._render`` (which has a different signature/return shape).
|
|
228
|
+
def _show_sample(self, s: _Sample) -> None:
|
|
229
|
+
cpu_bar = _bar(s.cpu_pct, self._bar_width)
|
|
230
|
+
ram_bar = _bar(s.ram_pct, self._bar_width)
|
|
231
|
+
cpu_str = f"[b]CPU[/b] {s.cpu_pct:5.1f}% {cpu_bar}"
|
|
232
|
+
ram_str = (
|
|
233
|
+
f"[b]RAM[/b] {s.ram_used_gib:5.1f}/{s.ram_total_gib:.1f} GiB "
|
|
234
|
+
f"{ram_bar}"
|
|
235
|
+
)
|
|
236
|
+
self.update(f"{cpu_str} {ram_str}")
|
|
237
|
+
# Recover from a transient error: title might still say "error".
|
|
238
|
+
if self.border_title and "error" in self.border_title:
|
|
239
|
+
self.border_title = f"system — {self._source}"
|
|
240
|
+
|
|
241
|
+
def _show_error(self, msg: str) -> None:
|
|
242
|
+
self.update("CPU ? RAM ?")
|
|
243
|
+
# Truncate so the border title stays compact.
|
|
244
|
+
self.border_title = f"system — error: {msg[:80]}"
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import select
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import Container
|
|
7
|
+
from textual.widgets import Static
|
|
8
|
+
|
|
9
|
+
import ptyprocess
|
|
10
|
+
import pyte
|
|
11
|
+
|
|
12
|
+
from patchfeld.widgets._terminal_keys import encode_key
|
|
13
|
+
from patchfeld.widgets._terminal_render import render_screen
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _default_command() -> list[str]:
|
|
17
|
+
return [os.environ.get("SHELL", "/bin/sh")]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Terminal(Container):
|
|
21
|
+
"""Real PTY hosted in a Textual panel.
|
|
22
|
+
|
|
23
|
+
Spawns a subprocess via ptyprocess.PtyProcessUnicode, feeds output
|
|
24
|
+
through pyte for ANSI emulation, and re-renders whenever the PTY fd
|
|
25
|
+
becomes readable via asyncio.add_reader. Anything typed here is
|
|
26
|
+
OPAQUE to the orchestrator (intentional escape-hatch behavior — use
|
|
27
|
+
this for an interactive `claude` CLI session inside Patchfeld).
|
|
28
|
+
|
|
29
|
+
Props:
|
|
30
|
+
command: argv list (default: [$SHELL])
|
|
31
|
+
cwd: working directory (default: process cwd)
|
|
32
|
+
env: extra env vars merged into os.environ
|
|
33
|
+
|
|
34
|
+
Limitations: no mouse forwarding; no bracketed paste; no Kitty
|
|
35
|
+
keyboard protocol. POSIX-only (ptyprocess).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
DEFAULT_CSS = """
|
|
39
|
+
Terminal {
|
|
40
|
+
border: round $surface-lighten-2;
|
|
41
|
+
padding: 0 1;
|
|
42
|
+
background: black;
|
|
43
|
+
color: white;
|
|
44
|
+
}
|
|
45
|
+
Terminal Static {
|
|
46
|
+
background: black;
|
|
47
|
+
}
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
can_focus = True
|
|
51
|
+
|
|
52
|
+
DEFAULT_COLS = 80
|
|
53
|
+
DEFAULT_ROWS = 24
|
|
54
|
+
HISTORY_LINES = 2000 # scrollback rows; memory grows linearly with terminal width
|
|
55
|
+
READ_BUDGET_BYTES = 64 * 1024 # per-tick drain cap; hitting it is fine — add_reader fires again next tick
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
*,
|
|
60
|
+
command: list[str] | None = None,
|
|
61
|
+
cwd: str | None = None,
|
|
62
|
+
env: dict | None = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
super().__init__()
|
|
65
|
+
self._command = command or _default_command()
|
|
66
|
+
self._cwd = cwd
|
|
67
|
+
environ = dict(os.environ)
|
|
68
|
+
if env:
|
|
69
|
+
environ.update(env)
|
|
70
|
+
self._env = environ
|
|
71
|
+
self._pty = None
|
|
72
|
+
self._screen = pyte.HistoryScreen(self.DEFAULT_COLS, self.DEFAULT_ROWS, history=self.HISTORY_LINES)
|
|
73
|
+
self._stream = pyte.Stream(self._screen)
|
|
74
|
+
self._timer = None
|
|
75
|
+
self._reader_registered: bool = False
|
|
76
|
+
self._last_write: bytes | None = None
|
|
77
|
+
|
|
78
|
+
def compose(self) -> ComposeResult:
|
|
79
|
+
yield Static("", id="terminal-screen")
|
|
80
|
+
|
|
81
|
+
def on_mount(self) -> None:
|
|
82
|
+
try:
|
|
83
|
+
self._pty = ptyprocess.PtyProcessUnicode.spawn(
|
|
84
|
+
self._command,
|
|
85
|
+
cwd=self._cwd,
|
|
86
|
+
env=self._env,
|
|
87
|
+
dimensions=(self.DEFAULT_ROWS, self.DEFAULT_COLS),
|
|
88
|
+
)
|
|
89
|
+
except Exception as e:
|
|
90
|
+
self._show_error(f"PTY spawn failed: {e}")
|
|
91
|
+
return
|
|
92
|
+
loop = asyncio.get_running_loop()
|
|
93
|
+
loop.add_reader(self._pty.fd, self._tick)
|
|
94
|
+
self._reader_registered = True
|
|
95
|
+
|
|
96
|
+
def on_resize(self, event) -> None:
|
|
97
|
+
"""Propagate Textual size changes to the PTY and the pyte screen.
|
|
98
|
+
|
|
99
|
+
Uses `self.content_size` (region shrunk by the styles gutter, i.e.
|
|
100
|
+
border + padding) as the authoritative inner dimensions. This is
|
|
101
|
+
populated by the time the Resize event fires, so we don't need to
|
|
102
|
+
consult the inner Static (whose auto-height can collapse to 1) or
|
|
103
|
+
subtract CSS chrome by hand.
|
|
104
|
+
"""
|
|
105
|
+
if self._pty is None:
|
|
106
|
+
return
|
|
107
|
+
inner = self.content_size
|
|
108
|
+
cols = max(1, inner.width)
|
|
109
|
+
rows = max(1, inner.height)
|
|
110
|
+
if cols == self._screen.columns and rows == self._screen.lines:
|
|
111
|
+
return
|
|
112
|
+
# setwinsize and screen.resize form a logical pair: if one fails the
|
|
113
|
+
# other leaves the system in an inconsistent state, so they share a
|
|
114
|
+
# single guard. No logging in Phase 1 — failures are silent for now.
|
|
115
|
+
try:
|
|
116
|
+
self._pty.setwinsize(rows, cols)
|
|
117
|
+
self._screen.resize(rows, cols)
|
|
118
|
+
except Exception:
|
|
119
|
+
return
|
|
120
|
+
self._refresh()
|
|
121
|
+
|
|
122
|
+
def on_unmount(self) -> None:
|
|
123
|
+
self._teardown()
|
|
124
|
+
|
|
125
|
+
def _teardown(self) -> None:
|
|
126
|
+
if self._reader_registered and self._pty is not None:
|
|
127
|
+
try:
|
|
128
|
+
asyncio.get_running_loop().remove_reader(self._pty.fd)
|
|
129
|
+
except Exception:
|
|
130
|
+
pass
|
|
131
|
+
self._reader_registered = False
|
|
132
|
+
if self._timer is not None:
|
|
133
|
+
try:
|
|
134
|
+
self._timer.stop()
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
self._timer = None
|
|
138
|
+
if self._pty is not None:
|
|
139
|
+
try:
|
|
140
|
+
self._pty.close(force=True)
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
143
|
+
self._pty = None
|
|
144
|
+
|
|
145
|
+
def _announce_exit(self) -> None:
|
|
146
|
+
# Capture status if available before teardown clears _pty.
|
|
147
|
+
# Banner status: int (real exit code), or "?" sentinel when we can't determine it.
|
|
148
|
+
status: int | str = "?"
|
|
149
|
+
if self._pty is not None:
|
|
150
|
+
try:
|
|
151
|
+
ex = self._pty.exitstatus
|
|
152
|
+
if ex is None:
|
|
153
|
+
# isalive() polls waitpid(WNOHANG) and populates exitstatus
|
|
154
|
+
# as a side effect. Non-blocking; safe even if a grandchild
|
|
155
|
+
# inherited the slave fd and is still running.
|
|
156
|
+
if not self._pty.isalive():
|
|
157
|
+
ex = self._pty.exitstatus
|
|
158
|
+
if ex is not None:
|
|
159
|
+
status = ex
|
|
160
|
+
except Exception:
|
|
161
|
+
pass
|
|
162
|
+
banner = f"\r\n[process exited {status}]\r\n"
|
|
163
|
+
try:
|
|
164
|
+
self._stream.feed(banner)
|
|
165
|
+
except Exception:
|
|
166
|
+
pass
|
|
167
|
+
self._teardown()
|
|
168
|
+
self._refresh()
|
|
169
|
+
|
|
170
|
+
def action_restart(self) -> None:
|
|
171
|
+
"""Respawn the subprocess in-place. Safe to call after exit."""
|
|
172
|
+
if self._pty is not None:
|
|
173
|
+
# Already running — nothing to do.
|
|
174
|
+
return
|
|
175
|
+
# Reset the screen so the old session's tail doesn't accumulate forever.
|
|
176
|
+
self._screen = pyte.HistoryScreen(
|
|
177
|
+
self._screen.columns, self._screen.lines, history=self.HISTORY_LINES
|
|
178
|
+
)
|
|
179
|
+
self._stream = pyte.Stream(self._screen)
|
|
180
|
+
self.on_mount()
|
|
181
|
+
|
|
182
|
+
def _tick(self) -> None:
|
|
183
|
+
if self._pty is None:
|
|
184
|
+
return
|
|
185
|
+
# Drain everything available without blocking, but bounded so a flooding
|
|
186
|
+
# child can't starve the asyncio loop. add_reader will fire again next tick.
|
|
187
|
+
any_data = False
|
|
188
|
+
bytes_read = 0
|
|
189
|
+
eof = False
|
|
190
|
+
while bytes_read < self.READ_BUDGET_BYTES:
|
|
191
|
+
try:
|
|
192
|
+
ready, _, _ = select.select([self._pty.fd], [], [], 0)
|
|
193
|
+
if not ready:
|
|
194
|
+
break
|
|
195
|
+
chunk = self._pty.read(4096)
|
|
196
|
+
except EOFError:
|
|
197
|
+
eof = True
|
|
198
|
+
break
|
|
199
|
+
except Exception:
|
|
200
|
+
# TODO(phase 2): surface PTY read errors via _show_error
|
|
201
|
+
break
|
|
202
|
+
if not chunk:
|
|
203
|
+
eof = True
|
|
204
|
+
break
|
|
205
|
+
self._stream.feed(chunk)
|
|
206
|
+
bytes_read += len(chunk)
|
|
207
|
+
any_data = True
|
|
208
|
+
if eof:
|
|
209
|
+
self._announce_exit()
|
|
210
|
+
elif any_data:
|
|
211
|
+
self._refresh()
|
|
212
|
+
|
|
213
|
+
def _refresh(self) -> None:
|
|
214
|
+
try:
|
|
215
|
+
screen = self.query_one("#terminal-screen", Static)
|
|
216
|
+
except Exception:
|
|
217
|
+
return
|
|
218
|
+
text = render_screen(self._screen, show_cursor=True)
|
|
219
|
+
screen.update(text)
|
|
220
|
+
|
|
221
|
+
def _show_error(self, msg: str) -> None:
|
|
222
|
+
from rich.text import Text
|
|
223
|
+
try:
|
|
224
|
+
self.query_one("#terminal-screen", Static).update(Text(msg))
|
|
225
|
+
except Exception:
|
|
226
|
+
pass
|
|
227
|
+
|
|
228
|
+
def on_key(self, event) -> None:
|
|
229
|
+
if self._pty is None:
|
|
230
|
+
return
|
|
231
|
+
data = encode_key(event.key, event.character)
|
|
232
|
+
if data is None:
|
|
233
|
+
return
|
|
234
|
+
try:
|
|
235
|
+
# encode_key returns bytes; PtyProcessUnicode.write wraps a utf-8 text
|
|
236
|
+
# stream, so decode->re-encode is lossless for any output of encode_key
|
|
237
|
+
# (all paths produce well-formed utf-8).
|
|
238
|
+
self._pty.write(data.decode("utf-8", errors="replace"))
|
|
239
|
+
except Exception:
|
|
240
|
+
# TODO(phase 2): surface PTY write errors via _show_error
|
|
241
|
+
return
|
|
242
|
+
self._last_write = data
|
|
243
|
+
event.stop()
|
|
244
|
+
|
|
245
|
+
@classmethod
|
|
246
|
+
def default_border_title(cls, props: dict) -> str:
|
|
247
|
+
from pathlib import Path as _P
|
|
248
|
+
command = props.get("command")
|
|
249
|
+
if command and isinstance(command, list) and len(command) > 0:
|
|
250
|
+
return f"Terminal: {_P(command[0]).name}"
|
|
251
|
+
return "Terminal"
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from textual.binding import Binding
|
|
2
|
+
from textual.containers import Container
|
|
3
|
+
from textual.screen import ModalScreen
|
|
4
|
+
from textual.widgets import Footer, Label, ListItem, ListView
|
|
5
|
+
|
|
6
|
+
from patchfeld.persistence.themes_store import NamedThemesStore
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ThemeSwitcherScreen(ModalScreen[str | None]):
|
|
10
|
+
"""Pick a theme. Esc dismisses with None; selecting dismisses with the name."""
|
|
11
|
+
|
|
12
|
+
DEFAULT_CSS = """
|
|
13
|
+
ThemeSwitcherScreen {
|
|
14
|
+
align: center middle;
|
|
15
|
+
}
|
|
16
|
+
ThemeSwitcherScreen > Container {
|
|
17
|
+
width: 50%;
|
|
18
|
+
height: 60%;
|
|
19
|
+
border: thick $primary;
|
|
20
|
+
background: $surface;
|
|
21
|
+
padding: 1 2;
|
|
22
|
+
}
|
|
23
|
+
ThemeSwitcherScreen ListView {
|
|
24
|
+
height: 1fr;
|
|
25
|
+
}
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
BINDINGS = [Binding("escape", "dismiss_none", "cancel")]
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
*,
|
|
33
|
+
store: NamedThemesStore,
|
|
34
|
+
available_builtins: list[str],
|
|
35
|
+
active: str,
|
|
36
|
+
) -> None:
|
|
37
|
+
super().__init__()
|
|
38
|
+
self._store = store
|
|
39
|
+
self._builtins = list(available_builtins)
|
|
40
|
+
self._active = active
|
|
41
|
+
|
|
42
|
+
def compose(self):
|
|
43
|
+
items: list[ListItem] = []
|
|
44
|
+
for name in self._store.list():
|
|
45
|
+
label = f"* {name}" if name == self._active else f" {name}"
|
|
46
|
+
items.append(ListItem(Label(label), name=name))
|
|
47
|
+
if self._builtins:
|
|
48
|
+
items.append(ListItem(Label("─ built-ins ─"), name=None))
|
|
49
|
+
for name in self._builtins:
|
|
50
|
+
label = f"* {name}" if name == self._active else f" {name}"
|
|
51
|
+
items.append(ListItem(Label(label), name=name))
|
|
52
|
+
with Container():
|
|
53
|
+
yield Label("Load theme:")
|
|
54
|
+
yield ListView(*items)
|
|
55
|
+
yield Footer()
|
|
56
|
+
|
|
57
|
+
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
58
|
+
if event.item.name is None:
|
|
59
|
+
return # separator row — ignore
|
|
60
|
+
self.dismiss(event.item.name)
|
|
61
|
+
|
|
62
|
+
def action_dismiss_none(self) -> None:
|
|
63
|
+
self.dismiss(None)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from textual.binding import Binding
|
|
2
|
+
from textual.containers import Container
|
|
3
|
+
from textual.screen import ModalScreen
|
|
4
|
+
from textual.widgets import Footer
|
|
5
|
+
|
|
6
|
+
from patchfeld.events import EventBus
|
|
7
|
+
from patchfeld.widgets.agent_transcript import AgentTranscript
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TranscriptScreen(ModalScreen[None]):
|
|
11
|
+
"""Modal overlay showing one agent's transcript. Esc to dismiss."""
|
|
12
|
+
|
|
13
|
+
DEFAULT_CSS = """
|
|
14
|
+
TranscriptScreen {
|
|
15
|
+
align: center middle;
|
|
16
|
+
}
|
|
17
|
+
TranscriptScreen > Container {
|
|
18
|
+
width: 80%;
|
|
19
|
+
height: 80%;
|
|
20
|
+
border: thick $primary;
|
|
21
|
+
background: $surface;
|
|
22
|
+
padding: 1 2;
|
|
23
|
+
}
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
BINDINGS = [Binding("escape", "dismiss", "close")]
|
|
27
|
+
|
|
28
|
+
def __init__(self, agent_id: str, event_bus: EventBus | None = None) -> None:
|
|
29
|
+
super().__init__()
|
|
30
|
+
self._agent_id = agent_id
|
|
31
|
+
self._bus = event_bus
|
|
32
|
+
|
|
33
|
+
def compose(self):
|
|
34
|
+
with Container():
|
|
35
|
+
yield AgentTranscript(agent_id=self._agent_id, event_bus=self._bus)
|
|
36
|
+
yield Footer()
|
|
37
|
+
|
|
38
|
+
def action_dismiss(self) -> None:
|
|
39
|
+
self.dismiss(None)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
2
|
+
|
|
3
|
+
from patchfeld.layout.spec import LayoutSpec, Node, Panel, Tabs
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Tab(BaseModel):
|
|
7
|
+
"""One app-level tab. Owns its own LayoutSpec, which is independently
|
|
8
|
+
mutable. `id` is stable across the tab's lifetime and used by
|
|
9
|
+
switch_tab/close_tab tool calls. `title` is the user-facing tab-strip
|
|
10
|
+
label."""
|
|
11
|
+
model_config = ConfigDict(extra="forbid")
|
|
12
|
+
|
|
13
|
+
id: str
|
|
14
|
+
title: str
|
|
15
|
+
layout: LayoutSpec
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Workspace(BaseModel):
|
|
19
|
+
"""Top-level container. Holds a list of Tabs and an active id.
|
|
20
|
+
|
|
21
|
+
Invariants:
|
|
22
|
+
- Non-empty tab list.
|
|
23
|
+
- `active` references one of `tabs[].id`.
|
|
24
|
+
- At least one OrchestratorChat panel exists across all tabs combined.
|
|
25
|
+
- Tab ids are unique.
|
|
26
|
+
"""
|
|
27
|
+
model_config = ConfigDict(extra="forbid")
|
|
28
|
+
|
|
29
|
+
version: int = 1
|
|
30
|
+
tabs: list[Tab] = Field(min_length=1)
|
|
31
|
+
active: str
|
|
32
|
+
active_theme: str | None = None
|
|
33
|
+
|
|
34
|
+
@model_validator(mode="after")
|
|
35
|
+
def _validate(self) -> "Workspace":
|
|
36
|
+
ids = [t.id for t in self.tabs]
|
|
37
|
+
if len(ids) != len(set(ids)):
|
|
38
|
+
raise ValueError(f"duplicate tab id in {ids}")
|
|
39
|
+
if self.active not in set(ids):
|
|
40
|
+
raise ValueError(
|
|
41
|
+
f"active tab id {self.active!r} not in tab ids {ids}"
|
|
42
|
+
)
|
|
43
|
+
if not any(_contains_chat(t.layout.layout) for t in self.tabs):
|
|
44
|
+
raise ValueError(
|
|
45
|
+
"Workspace must contain at least one OrchestratorChat panel "
|
|
46
|
+
"across all tabs"
|
|
47
|
+
)
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _contains_chat(node: Node) -> bool:
|
|
52
|
+
if isinstance(node, Panel):
|
|
53
|
+
return node.widget == "OrchestratorChat"
|
|
54
|
+
if isinstance(node, Tabs):
|
|
55
|
+
# Tabs.children is list[Panel] (leaf-only invariant in spec.py), so
|
|
56
|
+
# a flat scan is sufficient — no recursion needed. If Tabs ever
|
|
57
|
+
# accepts nested containers, this branch must recurse like Container.
|
|
58
|
+
return any(c.widget == "OrchestratorChat" for c in node.children)
|
|
59
|
+
# node is Container — exhausted by the discriminated Node union.
|
|
60
|
+
return any(_contains_chat(c) for c in node.children)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def workspace_from_layout(spec: LayoutSpec, *, tab_id: str = "default",
|
|
64
|
+
title: str = "default") -> Workspace:
|
|
65
|
+
"""Build a single-tab Workspace wrapping a LayoutSpec — used by app
|
|
66
|
+
launch to seed the workspace from the built-in dashboard or migrate
|
|
67
|
+
a legacy layout.json."""
|
|
68
|
+
return Workspace(
|
|
69
|
+
version=1,
|
|
70
|
+
tabs=[Tab(id=tab_id, title=title, layout=spec)],
|
|
71
|
+
active=tab_id,
|
|
72
|
+
)
|