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
|
File without changes
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from patchfeld.agents.state import AgentInfo, AgentState
|
|
7
|
+
from patchfeld.persistence.atomic import write_json_atomic
|
|
8
|
+
from patchfeld.persistence.paths import project_state_dir
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _index_path(cwd: Path) -> Path:
|
|
14
|
+
return project_state_dir(cwd) / "agents.json"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AgentsIndex:
|
|
18
|
+
def __init__(self, cwd: Path) -> None:
|
|
19
|
+
self._cwd = cwd
|
|
20
|
+
self._path = _index_path(cwd)
|
|
21
|
+
|
|
22
|
+
def load(self) -> list[AgentInfo]:
|
|
23
|
+
if not self._path.exists():
|
|
24
|
+
return []
|
|
25
|
+
try:
|
|
26
|
+
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
|
27
|
+
if not isinstance(raw, list):
|
|
28
|
+
log.warning("agents.json is not a list at %s", self._path)
|
|
29
|
+
return []
|
|
30
|
+
return [AgentInfo.from_dict(entry) for entry in raw]
|
|
31
|
+
except Exception:
|
|
32
|
+
log.exception("Failed to load agents.json from %s", self._path)
|
|
33
|
+
return []
|
|
34
|
+
|
|
35
|
+
def save(self, infos: list[AgentInfo]) -> None:
|
|
36
|
+
write_json_atomic(self._path, [info.to_dict() for info in infos])
|
|
37
|
+
|
|
38
|
+
def upsert(self, info: AgentInfo) -> None:
|
|
39
|
+
current = self.load()
|
|
40
|
+
for i, existing in enumerate(current):
|
|
41
|
+
if existing.id == info.id:
|
|
42
|
+
current[i] = info
|
|
43
|
+
self.save(current)
|
|
44
|
+
return
|
|
45
|
+
current.append(info)
|
|
46
|
+
self.save(current)
|
|
47
|
+
|
|
48
|
+
def reconcile_orphans(self) -> list[AgentInfo]:
|
|
49
|
+
# On boot the manager has no live sessions yet, so any persisted
|
|
50
|
+
# agent in a non-terminal state is from a previous process that died
|
|
51
|
+
# without marking it done (e.g. crash). Flip it to ERROR so the table
|
|
52
|
+
# doesn't claim those rows are still running.
|
|
53
|
+
# The orchestrator is excluded — it owns its own boot lifecycle and
|
|
54
|
+
# will overwrite the entry on start().
|
|
55
|
+
infos = self.load()
|
|
56
|
+
now = time.time()
|
|
57
|
+
changed = False
|
|
58
|
+
for info in infos:
|
|
59
|
+
if info.id == "orchestrator":
|
|
60
|
+
continue
|
|
61
|
+
if not info.state.is_terminal:
|
|
62
|
+
info.state = AgentState.ERROR
|
|
63
|
+
if info.ended_at is None:
|
|
64
|
+
info.ended_at = now
|
|
65
|
+
changed = True
|
|
66
|
+
if changed:
|
|
67
|
+
self.save(infos)
|
|
68
|
+
return infos
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def write_json_atomic(path: Path, data: Any) -> None:
|
|
9
|
+
"""Write JSON to `path` atomically: write to a temp file in the same
|
|
10
|
+
directory, fsync, then rename. Same-directory rename is atomic on POSIX.
|
|
11
|
+
Parent directories are created if missing."""
|
|
12
|
+
path = Path(path)
|
|
13
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
|
|
15
|
+
fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp",
|
|
16
|
+
dir=str(path.parent))
|
|
17
|
+
tmp_path = Path(tmp_name)
|
|
18
|
+
try:
|
|
19
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
20
|
+
json.dump(data, f, indent=2)
|
|
21
|
+
f.flush()
|
|
22
|
+
os.fsync(f.fileno())
|
|
23
|
+
os.replace(tmp_path, path)
|
|
24
|
+
except Exception:
|
|
25
|
+
tmp_path.unlink(missing_ok=True)
|
|
26
|
+
raise
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def write_text_atomic(path: Path, text: str) -> None:
|
|
30
|
+
"""Write `text` to `path` atomically: write to a temp file in the same
|
|
31
|
+
directory, fsync, then rename. Same-directory rename is atomic on POSIX.
|
|
32
|
+
Parent directories are created if missing."""
|
|
33
|
+
path = Path(path)
|
|
34
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
|
|
36
|
+
fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp",
|
|
37
|
+
dir=str(path.parent))
|
|
38
|
+
tmp_path = Path(tmp_name)
|
|
39
|
+
try:
|
|
40
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
41
|
+
f.write(text)
|
|
42
|
+
f.flush()
|
|
43
|
+
os.fsync(f.fileno())
|
|
44
|
+
os.replace(tmp_path, path)
|
|
45
|
+
except Exception:
|
|
46
|
+
tmp_path.unlink(missing_ok=True)
|
|
47
|
+
raise
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from patchfeld.layout.spec import LayoutSpec
|
|
6
|
+
from patchfeld.persistence.atomic import write_json_atomic
|
|
7
|
+
from patchfeld.persistence.paths import project_layout_path
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def save_layout(cwd: Path, spec: LayoutSpec) -> None:
|
|
13
|
+
write_json_atomic(project_layout_path(cwd), spec.model_dump(mode="json"))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_layout(cwd: Path) -> LayoutSpec | None:
|
|
17
|
+
path = project_layout_path(cwd)
|
|
18
|
+
if not path.exists():
|
|
19
|
+
return None
|
|
20
|
+
try:
|
|
21
|
+
raw = json.loads(path.read_text())
|
|
22
|
+
return LayoutSpec.model_validate(raw)
|
|
23
|
+
except Exception:
|
|
24
|
+
log.exception("Failed to load layout from %s", path)
|
|
25
|
+
return None
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from patchfeld.layout.spec import LayoutSpec
|
|
7
|
+
from patchfeld.persistence.atomic import write_json_atomic
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
_VALID_NAME = re.compile(r"^[A-Za-z0-9_\-]+$")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NamedLayoutsStore:
|
|
15
|
+
"""Read/write named LayoutSpecs at <global_dir>/layouts/<name>.json."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, global_dir: Path) -> None:
|
|
18
|
+
self._dir = Path(global_dir) / "layouts"
|
|
19
|
+
|
|
20
|
+
def save(self, name: str, spec: LayoutSpec) -> None:
|
|
21
|
+
if not name or not _VALID_NAME.match(name):
|
|
22
|
+
raise ValueError(
|
|
23
|
+
f"layout name must match {_VALID_NAME.pattern!r}, got {name!r}"
|
|
24
|
+
)
|
|
25
|
+
write_json_atomic(self._dir / f"{name}.json", spec.model_dump(mode="json"))
|
|
26
|
+
|
|
27
|
+
def load(self, name: str) -> LayoutSpec | None:
|
|
28
|
+
path = self._dir / f"{name}.json"
|
|
29
|
+
if not path.exists():
|
|
30
|
+
return None
|
|
31
|
+
try:
|
|
32
|
+
return LayoutSpec.model_validate(json.loads(path.read_text(encoding="utf-8")))
|
|
33
|
+
except Exception:
|
|
34
|
+
log.exception("Failed to load named layout %r", name)
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
def list(self) -> list[str]:
|
|
38
|
+
if not self._dir.exists():
|
|
39
|
+
return []
|
|
40
|
+
names = []
|
|
41
|
+
for p in self._dir.iterdir():
|
|
42
|
+
if p.is_file() and p.suffix == ".json":
|
|
43
|
+
names.append(p.stem)
|
|
44
|
+
return sorted(names)
|
|
45
|
+
|
|
46
|
+
def delete(self, name: str) -> bool:
|
|
47
|
+
"""Remove the named layout's JSON file. Returns True if a file was
|
|
48
|
+
removed, False if no such file existed. Raises ValueError on names
|
|
49
|
+
that don't match the same charset enforced by `save()` — this guards
|
|
50
|
+
against any caller smuggling in `..` segments or path separators.
|
|
51
|
+
"""
|
|
52
|
+
if not name or not _VALID_NAME.match(name):
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f"layout name must match {_VALID_NAME.pattern!r}, got {name!r}"
|
|
55
|
+
)
|
|
56
|
+
path = self._dir / f"{name}.json"
|
|
57
|
+
try:
|
|
58
|
+
path.unlink()
|
|
59
|
+
except FileNotFoundError:
|
|
60
|
+
return False
|
|
61
|
+
return True
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from dataclasses import asdict, dataclass, fields
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from patchfeld.persistence.atomic import write_json_atomic
|
|
9
|
+
from patchfeld.persistence.paths import project_state_dir
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class OrchestratorSessionEntry:
|
|
16
|
+
session_id: str
|
|
17
|
+
transcript_path: str
|
|
18
|
+
started_at: float
|
|
19
|
+
last_activity: float
|
|
20
|
+
first_user_message: str | None = None
|
|
21
|
+
title: str | None = None # explicit title; defaults to first_user_message-derived
|
|
22
|
+
num_turns: int = 0
|
|
23
|
+
tokens_in: int = 0
|
|
24
|
+
tokens_out: int = 0
|
|
25
|
+
cost: float = 0.0
|
|
26
|
+
legacy: bool = False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _index_path(cwd: Path) -> Path:
|
|
30
|
+
return project_state_dir(cwd) / "orchestrator_sessions.json"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class OrchestratorSessionsIndex:
|
|
34
|
+
"""Per-cwd index of past orchestrator sessions for resume/picker."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, cwd: Path) -> None:
|
|
37
|
+
self._cwd = cwd
|
|
38
|
+
self._path = _index_path(cwd)
|
|
39
|
+
|
|
40
|
+
def list(self) -> list[OrchestratorSessionEntry]:
|
|
41
|
+
if not self._path.exists():
|
|
42
|
+
return []
|
|
43
|
+
try:
|
|
44
|
+
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
|
45
|
+
if not isinstance(raw, list):
|
|
46
|
+
log.warning("orchestrator_sessions.json is not a list at %s", self._path)
|
|
47
|
+
return []
|
|
48
|
+
valid = {f.name for f in fields(OrchestratorSessionEntry)}
|
|
49
|
+
out: list[OrchestratorSessionEntry] = []
|
|
50
|
+
for item in raw:
|
|
51
|
+
if not isinstance(item, dict):
|
|
52
|
+
continue
|
|
53
|
+
kwargs = {k: v for k, v in item.items() if k in valid}
|
|
54
|
+
out.append(OrchestratorSessionEntry(**kwargs))
|
|
55
|
+
return out
|
|
56
|
+
except Exception:
|
|
57
|
+
log.exception("Failed to load orchestrator_sessions.json from %s", self._path)
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
def upsert(self, entry: OrchestratorSessionEntry) -> None:
|
|
61
|
+
current = self.list()
|
|
62
|
+
for i, existing in enumerate(current):
|
|
63
|
+
if existing.session_id == entry.session_id:
|
|
64
|
+
current[i] = entry
|
|
65
|
+
self._save(current)
|
|
66
|
+
return
|
|
67
|
+
current.append(entry)
|
|
68
|
+
self._save(current)
|
|
69
|
+
|
|
70
|
+
def most_recent(self) -> OrchestratorSessionEntry | None:
|
|
71
|
+
entries = self.list()
|
|
72
|
+
if not entries:
|
|
73
|
+
return None
|
|
74
|
+
return max(entries, key=lambda e: e.last_activity)
|
|
75
|
+
|
|
76
|
+
def get(self, session_id: str) -> OrchestratorSessionEntry | None:
|
|
77
|
+
for e in self.list():
|
|
78
|
+
if e.session_id == session_id:
|
|
79
|
+
return e
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
def set_title(self, session_id: str, title: str | None) -> bool:
|
|
83
|
+
"""Update an entry's title. Returns True if the entry was found."""
|
|
84
|
+
entry = self.get(session_id)
|
|
85
|
+
if entry is None:
|
|
86
|
+
return False
|
|
87
|
+
entry.title = title
|
|
88
|
+
self.upsert(entry)
|
|
89
|
+
return True
|
|
90
|
+
|
|
91
|
+
def migrate_legacy_if_needed(self) -> None:
|
|
92
|
+
"""One-time migration: rename .patchfeld/transcripts/orchestrator.jsonl
|
|
93
|
+
to orchestrator.legacy-<ts>.jsonl and register a legacy=True entry.
|
|
94
|
+
|
|
95
|
+
No-op if the index already has any entries OR if no legacy file exists.
|
|
96
|
+
"""
|
|
97
|
+
from patchfeld.persistence.paths import project_transcripts_dir
|
|
98
|
+
|
|
99
|
+
if self._path.exists():
|
|
100
|
+
return # index already exists — don't touch
|
|
101
|
+
|
|
102
|
+
legacy_path = project_transcripts_dir(self._cwd) / "orchestrator.jsonl"
|
|
103
|
+
if not legacy_path.exists():
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
mtime = legacy_path.stat().st_mtime
|
|
107
|
+
legacy_id = f"legacy-{int(mtime)}"
|
|
108
|
+
new_path = project_transcripts_dir(self._cwd) / f"orchestrator.{legacy_id}.jsonl"
|
|
109
|
+
legacy_path.rename(new_path)
|
|
110
|
+
|
|
111
|
+
entry = OrchestratorSessionEntry(
|
|
112
|
+
session_id=legacy_id,
|
|
113
|
+
transcript_path=str(new_path.relative_to(self._cwd))
|
|
114
|
+
if new_path.is_relative_to(self._cwd) else str(new_path),
|
|
115
|
+
started_at=mtime,
|
|
116
|
+
last_activity=mtime,
|
|
117
|
+
first_user_message=None,
|
|
118
|
+
num_turns=0,
|
|
119
|
+
tokens_in=0,
|
|
120
|
+
tokens_out=0,
|
|
121
|
+
cost=0.0,
|
|
122
|
+
legacy=True,
|
|
123
|
+
)
|
|
124
|
+
self.upsert(entry)
|
|
125
|
+
|
|
126
|
+
def _save(self, entries: list[OrchestratorSessionEntry]) -> None:
|
|
127
|
+
write_json_atomic(self._path, [asdict(e) for e in entries])
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def project_state_dir(cwd: Path) -> Path:
|
|
6
|
+
return Path(cwd) / ".patchfeld"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def project_layout_path(cwd: Path) -> Path:
|
|
10
|
+
return project_state_dir(cwd) / "layout.json"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def project_workspace_path(cwd: Path) -> Path:
|
|
14
|
+
return project_state_dir(cwd) / "workspace.json"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def project_transcripts_dir(cwd: Path) -> Path:
|
|
18
|
+
return project_state_dir(cwd) / "transcripts"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def project_transcript_path(cwd: Path, agent_id: str) -> Path:
|
|
22
|
+
return project_transcripts_dir(cwd) / f"{agent_id}.jsonl"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def project_orchestrator_transcript(cwd: Path) -> Path:
|
|
26
|
+
return project_transcripts_dir(cwd) / "orchestrator.jsonl"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def orchestrator_session_transcript_path(cwd: Path, session_id: str) -> Path:
|
|
30
|
+
return project_transcripts_dir(cwd) / f"orchestrator.{session_id}.jsonl"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def global_config_dir() -> Path:
|
|
34
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
35
|
+
if xdg:
|
|
36
|
+
return Path(xdg) / "patchfeld"
|
|
37
|
+
return Path.home() / ".config" / "patchfeld"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def local_widgets_dir(global_dir: Path | None = None) -> Path:
|
|
41
|
+
"""Return the directory where user-authored custom widgets live.
|
|
42
|
+
|
|
43
|
+
With `global_dir` provided, returns `<global_dir>/widgets/` — useful for
|
|
44
|
+
tests that pin a per-tmp_path config root. Without it, derives from
|
|
45
|
+
`global_config_dir()` (which honors `XDG_CONFIG_HOME`).
|
|
46
|
+
"""
|
|
47
|
+
base = Path(global_dir) if global_dir else global_config_dir()
|
|
48
|
+
return base / "widgets"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from patchfeld.persistence.atomic import write_json_atomic
|
|
7
|
+
from patchfeld.theme.spec import ThemeSpec
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
_VALID_NAME = re.compile(r"^[A-Za-z0-9_\-]+$")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NamedThemesStore:
|
|
15
|
+
"""Read/write named ThemeSpecs at <global_dir>/themes/<name>.json."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, global_dir: Path) -> None:
|
|
18
|
+
self._dir = Path(global_dir) / "themes"
|
|
19
|
+
|
|
20
|
+
def save(self, name: str, spec: ThemeSpec) -> None:
|
|
21
|
+
if not name or not _VALID_NAME.match(name):
|
|
22
|
+
raise ValueError(
|
|
23
|
+
f"theme name must match {_VALID_NAME.pattern!r}, got {name!r}"
|
|
24
|
+
)
|
|
25
|
+
write_json_atomic(self._dir / f"{name}.json", spec.model_dump(mode="json"))
|
|
26
|
+
|
|
27
|
+
def load(self, name: str) -> ThemeSpec | None:
|
|
28
|
+
path = self._dir / f"{name}.json"
|
|
29
|
+
if not path.exists():
|
|
30
|
+
return None
|
|
31
|
+
try:
|
|
32
|
+
return ThemeSpec.model_validate(json.loads(path.read_text(encoding="utf-8")))
|
|
33
|
+
except Exception:
|
|
34
|
+
log.exception("Failed to load named theme %r", name)
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
def list(self) -> list[str]:
|
|
38
|
+
if not self._dir.exists():
|
|
39
|
+
return []
|
|
40
|
+
names = []
|
|
41
|
+
for p in self._dir.iterdir():
|
|
42
|
+
if p.is_file() and p.suffix == ".json":
|
|
43
|
+
names.append(p.stem)
|
|
44
|
+
return sorted(names)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from dataclasses import asdict, dataclass, fields
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from patchfeld.persistence.paths import project_transcript_path
|
|
7
|
+
|
|
8
|
+
log = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class TranscriptEntry:
|
|
13
|
+
role: str # "user" | "assistant" | "tool_use" | "tool_result" | "thinking" | "system" | "orch"
|
|
14
|
+
text: str
|
|
15
|
+
tool_id: str | None = None
|
|
16
|
+
tool_name: str | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AgentTranscript:
|
|
20
|
+
"""Append-only JSONL transcript for one agent.
|
|
21
|
+
|
|
22
|
+
Use agent_id="orchestrator" for the orchestrator's own transcript;
|
|
23
|
+
`OrchestratorTranscript` is provided as a thin alias for that case
|
|
24
|
+
so plan-1 callers don't have to change.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
cwd: Path,
|
|
30
|
+
agent_id: str,
|
|
31
|
+
*,
|
|
32
|
+
path: Path | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
self._cwd = cwd
|
|
35
|
+
self._agent_id = agent_id
|
|
36
|
+
self._path = Path(path) if path is not None else project_transcript_path(cwd, agent_id)
|
|
37
|
+
|
|
38
|
+
def append(self, entry: TranscriptEntry) -> None:
|
|
39
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
with self._path.open("a", encoding="utf-8") as f:
|
|
41
|
+
f.write(json.dumps(asdict(entry)) + "\n")
|
|
42
|
+
|
|
43
|
+
def read_all(self) -> list[TranscriptEntry]:
|
|
44
|
+
if not self._path.exists():
|
|
45
|
+
return []
|
|
46
|
+
out: list[TranscriptEntry] = []
|
|
47
|
+
valid_keys = {f.name for f in fields(TranscriptEntry)}
|
|
48
|
+
for line in self._path.read_text(encoding="utf-8").splitlines():
|
|
49
|
+
if not line.strip():
|
|
50
|
+
continue
|
|
51
|
+
try:
|
|
52
|
+
raw = json.loads(line)
|
|
53
|
+
kwargs = {k: v for k, v in raw.items() if k in valid_keys}
|
|
54
|
+
out.append(TranscriptEntry(**kwargs))
|
|
55
|
+
except Exception:
|
|
56
|
+
log.warning("Skipping corrupted transcript line: %r", line)
|
|
57
|
+
return out
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class OrchestratorTranscript(AgentTranscript):
|
|
61
|
+
"""Plan-1 alias for AgentTranscript(agent_id='orchestrator')."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, cwd: Path) -> None:
|
|
64
|
+
super().__init__(cwd=cwd, agent_id="orchestrator")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from patchfeld.persistence.atomic import write_json_atomic
|
|
6
|
+
from patchfeld.persistence.paths import project_workspace_path
|
|
7
|
+
from patchfeld.workspace.spec import Workspace
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def save_workspace(cwd: Path, ws: Workspace) -> None:
|
|
13
|
+
write_json_atomic(project_workspace_path(cwd), ws.model_dump(mode="json"))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_workspace(cwd: Path) -> Workspace | None:
|
|
17
|
+
path = project_workspace_path(cwd)
|
|
18
|
+
if not path.exists():
|
|
19
|
+
return None
|
|
20
|
+
try:
|
|
21
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
22
|
+
return Workspace.model_validate(raw)
|
|
23
|
+
except Exception:
|
|
24
|
+
log.exception("Failed to load workspace from %s", path)
|
|
25
|
+
return None
|
|
File without changes
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Apply a ThemeSpec to a live Textual App.
|
|
2
|
+
|
|
3
|
+
apply_theme() is the single seam every theme-load path goes through:
|
|
4
|
+
- the load_theme orchestrator tool,
|
|
5
|
+
- the theme switcher modal,
|
|
6
|
+
- boot-time apply.
|
|
7
|
+
|
|
8
|
+
The function is idempotent under same-name re-apply.
|
|
9
|
+
"""
|
|
10
|
+
from textual.app import App
|
|
11
|
+
from textual.css.tokenize import tokenize
|
|
12
|
+
from textual.theme import Theme
|
|
13
|
+
|
|
14
|
+
from patchfeld.theme.spec import ThemePalette, ThemeSpec
|
|
15
|
+
|
|
16
|
+
_EXTRA_CSS_KEY = ("patchfeld_theme", "extra_css")
|
|
17
|
+
_THEME_NAME_PREFIX = "patchfeld:"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def palette_from_textual_theme(textual_theme) -> ThemePalette:
|
|
21
|
+
"""Snapshot a live textual.theme.Theme into our ThemePalette."""
|
|
22
|
+
return ThemePalette(
|
|
23
|
+
primary=textual_theme.primary,
|
|
24
|
+
secondary=textual_theme.secondary,
|
|
25
|
+
warning=textual_theme.warning,
|
|
26
|
+
error=textual_theme.error,
|
|
27
|
+
success=textual_theme.success,
|
|
28
|
+
accent=textual_theme.accent,
|
|
29
|
+
foreground=textual_theme.foreground,
|
|
30
|
+
background=textual_theme.background,
|
|
31
|
+
surface=textual_theme.surface,
|
|
32
|
+
panel=textual_theme.panel,
|
|
33
|
+
boost=textual_theme.boost,
|
|
34
|
+
dark=textual_theme.dark,
|
|
35
|
+
luminosity_spread=textual_theme.luminosity_spread,
|
|
36
|
+
text_alpha=textual_theme.text_alpha,
|
|
37
|
+
variables=dict(textual_theme.variables),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def apply_theme(app: App, spec: ThemeSpec, *, theme_name: str) -> None:
|
|
42
|
+
"""Register/update the theme, set it active, and (re)install extra_css.
|
|
43
|
+
|
|
44
|
+
Order of operations: validate everything that can fail BEFORE mutating
|
|
45
|
+
app.theme. If anything raises, the previous theme stays active.
|
|
46
|
+
"""
|
|
47
|
+
# 1. Pre-validate extra_css by tokenizing it on the raw tokenizer.
|
|
48
|
+
# This catches structural/syntax errors (e.g. unclosed braces) without
|
|
49
|
+
# trying to resolve $variables — which are theme-time, not parse-time.
|
|
50
|
+
if spec.extra_css:
|
|
51
|
+
list(tokenize(spec.extra_css, _EXTRA_CSS_KEY)) # raises TokenError on bad syntax
|
|
52
|
+
|
|
53
|
+
# 2. Build the Textual Theme. Will raise on bad color strings.
|
|
54
|
+
# Note: palette validation is best-effort — Textual accepts color strings opaquely.
|
|
55
|
+
full_name = f"{_THEME_NAME_PREFIX}{theme_name}"
|
|
56
|
+
theme = Theme(name=full_name, **spec.palette.model_dump())
|
|
57
|
+
|
|
58
|
+
# 3. Replace any prior registration for this name. register_theme would
|
|
59
|
+
# raise on duplicate, and we want re-apply to mean "swap in place."
|
|
60
|
+
if full_name in app.available_themes:
|
|
61
|
+
app.unregister_theme(full_name)
|
|
62
|
+
|
|
63
|
+
# 4. Register and activate. Reactive watcher refreshes $primary etc.
|
|
64
|
+
app.register_theme(theme)
|
|
65
|
+
app.theme = full_name
|
|
66
|
+
|
|
67
|
+
# 5. Swap the named CSS source.
|
|
68
|
+
if _EXTRA_CSS_KEY in app.stylesheet.source:
|
|
69
|
+
del app.stylesheet.source[_EXTRA_CSS_KEY]
|
|
70
|
+
if spec.extra_css:
|
|
71
|
+
app.stylesheet.add_source(spec.extra_css, read_from=_EXTRA_CSS_KEY)
|
|
72
|
+
app.refresh_css()
|
|
73
|
+
|
|
74
|
+
# 6. Cache the applied extra_css for snapshotting (save_theme).
|
|
75
|
+
app._active_theme_extra_css = spec.extra_css
|
patchfeld/theme/spec.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ThemePalette(BaseModel):
|
|
5
|
+
"""Maps 1:1 to textual.theme.Theme constructor args."""
|
|
6
|
+
model_config = ConfigDict(extra="forbid")
|
|
7
|
+
|
|
8
|
+
primary: str
|
|
9
|
+
secondary: str | None = None
|
|
10
|
+
warning: str | None = None
|
|
11
|
+
error: str | None = None
|
|
12
|
+
success: str | None = None
|
|
13
|
+
accent: str | None = None
|
|
14
|
+
foreground: str | None = None
|
|
15
|
+
background: str | None = None
|
|
16
|
+
surface: str | None = None
|
|
17
|
+
panel: str | None = None
|
|
18
|
+
boost: str | None = None
|
|
19
|
+
dark: bool = True
|
|
20
|
+
luminosity_spread: float = 0.15
|
|
21
|
+
text_alpha: float = 0.95
|
|
22
|
+
variables: dict[str, str] = Field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ThemeSpec(BaseModel):
|
|
26
|
+
"""Saved theme. Applied to a live App via theme.engine.apply_theme."""
|
|
27
|
+
model_config = ConfigDict(extra="forbid")
|
|
28
|
+
|
|
29
|
+
version: int = 1
|
|
30
|
+
palette: ThemePalette
|
|
31
|
+
extra_css: str = ""
|
|
File without changes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
_EXTENSION_LANGUAGES = {
|
|
5
|
+
".py": "python",
|
|
6
|
+
".js": "javascript",
|
|
7
|
+
".jsx": "javascript",
|
|
8
|
+
".ts": "javascript", # TextArea ships JS lexer; TS falls back well.
|
|
9
|
+
".tsx": "javascript",
|
|
10
|
+
".json": "json",
|
|
11
|
+
".html": "html",
|
|
12
|
+
".css": "css",
|
|
13
|
+
".md": "markdown",
|
|
14
|
+
".rs": "rust",
|
|
15
|
+
".go": "go",
|
|
16
|
+
".sh": "bash",
|
|
17
|
+
".bash": "bash",
|
|
18
|
+
".sql": "sql",
|
|
19
|
+
".toml": "toml",
|
|
20
|
+
".yaml": "yaml",
|
|
21
|
+
".yml": "yaml",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def detect_language(path: Path) -> str | None:
|
|
26
|
+
return _EXTENSION_LANGUAGES.get(path.suffix.lower())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_text(path: Path) -> tuple[str, str | None]:
|
|
30
|
+
try:
|
|
31
|
+
text = path.read_text(encoding="utf-8")
|
|
32
|
+
except FileNotFoundError:
|
|
33
|
+
text = f"File not found: {path}"
|
|
34
|
+
except Exception as e:
|
|
35
|
+
text = f"Error loading {path}: {e}"
|
|
36
|
+
return text, detect_language(path)
|