patchfeld 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. patchfeld/__init__.py +1 -0
  2. patchfeld/__main__.py +32 -0
  3. patchfeld/actions.py +34 -0
  4. patchfeld/activity/__init__.py +0 -0
  5. patchfeld/activity/log.py +237 -0
  6. patchfeld/agents/__init__.py +0 -0
  7. patchfeld/agents/child_tools.py +66 -0
  8. patchfeld/agents/fake_sdk_adapter.py +45 -0
  9. patchfeld/agents/manager.py +365 -0
  10. patchfeld/agents/permission_grants.py +98 -0
  11. patchfeld/agents/permission_inbox.py +91 -0
  12. patchfeld/agents/request_inbox.py +65 -0
  13. patchfeld/agents/sdk_adapter.py +49 -0
  14. patchfeld/agents/session.py +250 -0
  15. patchfeld/agents/sort.py +66 -0
  16. patchfeld/agents/state.py +81 -0
  17. patchfeld/app.py +1433 -0
  18. patchfeld/config.py +128 -0
  19. patchfeld/events.py +260 -0
  20. patchfeld/layout/__init__.py +0 -0
  21. patchfeld/layout/custom_widgets.py +82 -0
  22. patchfeld/layout/defaults.py +33 -0
  23. patchfeld/layout/engine.py +241 -0
  24. patchfeld/layout/local_widgets.py +188 -0
  25. patchfeld/layout/registry.py +69 -0
  26. patchfeld/layout/spec.py +104 -0
  27. patchfeld/layout/splitter.py +170 -0
  28. patchfeld/layout/titles.py +70 -0
  29. patchfeld/orchestrator/__init__.py +0 -0
  30. patchfeld/orchestrator/formatting.py +15 -0
  31. patchfeld/orchestrator/session.py +785 -0
  32. patchfeld/orchestrator/tabs_tools.py +149 -0
  33. patchfeld/orchestrator/tools.py +976 -0
  34. patchfeld/persistence/__init__.py +0 -0
  35. patchfeld/persistence/agents_index.py +68 -0
  36. patchfeld/persistence/atomic.py +47 -0
  37. patchfeld/persistence/layout_store.py +25 -0
  38. patchfeld/persistence/layouts_store.py +61 -0
  39. patchfeld/persistence/orchestrator_sessions.py +127 -0
  40. patchfeld/persistence/paths.py +48 -0
  41. patchfeld/persistence/themes_store.py +44 -0
  42. patchfeld/persistence/transcript_store.py +64 -0
  43. patchfeld/persistence/workspace_store.py +25 -0
  44. patchfeld/theme/__init__.py +0 -0
  45. patchfeld/theme/engine.py +75 -0
  46. patchfeld/theme/spec.py +31 -0
  47. patchfeld/widgets/__init__.py +0 -0
  48. patchfeld/widgets/_file_lang.py +36 -0
  49. patchfeld/widgets/_terminal_keys.py +89 -0
  50. patchfeld/widgets/_terminal_render.py +147 -0
  51. patchfeld/widgets/activity_feed.py +365 -0
  52. patchfeld/widgets/agent_table.py +236 -0
  53. patchfeld/widgets/agent_transcript.py +85 -0
  54. patchfeld/widgets/change_cwd_screen.py +39 -0
  55. patchfeld/widgets/chrome.py +210 -0
  56. patchfeld/widgets/diff_viewer.py +52 -0
  57. patchfeld/widgets/file_editor.py +258 -0
  58. patchfeld/widgets/file_tree.py +33 -0
  59. patchfeld/widgets/file_viewer.py +77 -0
  60. patchfeld/widgets/history_screen.py +58 -0
  61. patchfeld/widgets/layout_switcher.py +126 -0
  62. patchfeld/widgets/log_tail.py +113 -0
  63. patchfeld/widgets/markdown.py +65 -0
  64. patchfeld/widgets/new_tab_screen.py +31 -0
  65. patchfeld/widgets/notebook.py +45 -0
  66. patchfeld/widgets/orchestrator_chat.py +73 -0
  67. patchfeld/widgets/permission_modal.py +185 -0
  68. patchfeld/widgets/permission_request_bar.py +90 -0
  69. patchfeld/widgets/resume_screen.py +179 -0
  70. patchfeld/widgets/rich_transcript.py +606 -0
  71. patchfeld/widgets/system_usage.py +244 -0
  72. patchfeld/widgets/terminal.py +251 -0
  73. patchfeld/widgets/theme_switcher.py +63 -0
  74. patchfeld/widgets/transcript_screen.py +39 -0
  75. patchfeld/workspace/__init__.py +3 -0
  76. patchfeld/workspace/spec.py +72 -0
  77. patchfeld-0.2.0.dist-info/METADATA +584 -0
  78. patchfeld-0.2.0.dist-info/RECORD +81 -0
  79. patchfeld-0.2.0.dist-info/WHEEL +4 -0
  80. patchfeld-0.2.0.dist-info/entry_points.txt +3 -0
  81. patchfeld-0.2.0.dist-info/licenses/LICENSE +21 -0
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
@@ -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)