sibyl-cli 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.
- parallel_developer/__init__.py +5 -0
- parallel_developer/cli.py +649 -0
- parallel_developer/controller/__init__.py +1398 -0
- parallel_developer/controller/commands.py +132 -0
- parallel_developer/controller/events.py +17 -0
- parallel_developer/controller/flow.py +43 -0
- parallel_developer/controller/history.py +70 -0
- parallel_developer/controller/pause.py +94 -0
- parallel_developer/controller/workflow_runner.py +135 -0
- parallel_developer/orchestrator.py +1234 -0
- parallel_developer/services/__init__.py +14 -0
- parallel_developer/services/codex_monitor.py +627 -0
- parallel_developer/services/log_manager.py +161 -0
- parallel_developer/services/tmux_manager.py +245 -0
- parallel_developer/services/worktree_manager.py +119 -0
- parallel_developer/stores/__init__.py +20 -0
- parallel_developer/stores/session_manifest.py +165 -0
- parallel_developer/stores/settings_store.py +242 -0
- parallel_developer/ui/widgets.py +269 -0
- sibyl_cli-0.2.0.dist-info/METADATA +15 -0
- sibyl_cli-0.2.0.dist-info/RECORD +23 -0
- sibyl_cli-0.2.0.dist-info/WHEEL +4 -0
- sibyl_cli-0.2.0.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Session manifest management for the interactive parallel developer CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field, asdict
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, Iterable, List, Optional
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True)
|
|
15
|
+
class PaneRecord:
|
|
16
|
+
role: str # "main" | "boss" | "worker"
|
|
17
|
+
name: Optional[str]
|
|
18
|
+
session_id: str
|
|
19
|
+
worktree: Optional[str] = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class SessionManifest:
|
|
24
|
+
session_id: str
|
|
25
|
+
created_at: str
|
|
26
|
+
tmux_session: str
|
|
27
|
+
worker_count: int
|
|
28
|
+
mode: str
|
|
29
|
+
logs_dir: str
|
|
30
|
+
latest_instruction: Optional[str] = None
|
|
31
|
+
scoreboard: Dict[str, Dict[str, object]] = field(default_factory=dict)
|
|
32
|
+
conversation_log: Optional[str] = None
|
|
33
|
+
selected_session_id: Optional[str] = None
|
|
34
|
+
main: PaneRecord = field(default_factory=lambda: PaneRecord(role="main", name=None, session_id=""))
|
|
35
|
+
boss: Optional[PaneRecord] = None
|
|
36
|
+
workers: Dict[str, PaneRecord] = field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> Dict[str, object]:
|
|
39
|
+
return {
|
|
40
|
+
"session_id": self.session_id,
|
|
41
|
+
"created_at": self.created_at,
|
|
42
|
+
"tmux_session": self.tmux_session,
|
|
43
|
+
"worker_count": self.worker_count,
|
|
44
|
+
"mode": self.mode,
|
|
45
|
+
"logs_dir": self.logs_dir,
|
|
46
|
+
"latest_instruction": self.latest_instruction,
|
|
47
|
+
"scoreboard": self.scoreboard,
|
|
48
|
+
"conversation_log": self.conversation_log,
|
|
49
|
+
"selected_session_id": self.selected_session_id,
|
|
50
|
+
"main": asdict(self.main),
|
|
51
|
+
"boss": asdict(self.boss) if self.boss else None,
|
|
52
|
+
"workers": {name: asdict(record) for name, record in self.workers.items()},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_dict(cls, data: Dict[str, object]) -> "SessionManifest":
|
|
57
|
+
workers = {
|
|
58
|
+
name: PaneRecord(**record)
|
|
59
|
+
for name, record in (data.get("workers") or {}).items()
|
|
60
|
+
}
|
|
61
|
+
boss_data = data.get("boss")
|
|
62
|
+
return cls(
|
|
63
|
+
session_id=data["session_id"],
|
|
64
|
+
created_at=data["created_at"],
|
|
65
|
+
tmux_session=data["tmux_session"],
|
|
66
|
+
worker_count=int(data.get("worker_count", len(workers))),
|
|
67
|
+
mode=data.get("mode", "parallel"),
|
|
68
|
+
logs_dir=data["logs_dir"],
|
|
69
|
+
latest_instruction=data.get("latest_instruction"),
|
|
70
|
+
scoreboard=data.get("scoreboard", {}) or {},
|
|
71
|
+
conversation_log=data.get("conversation_log"),
|
|
72
|
+
selected_session_id=data.get("selected_session_id"),
|
|
73
|
+
main=PaneRecord(**data.get("main", {})),
|
|
74
|
+
boss=PaneRecord(**boss_data) if boss_data else None,
|
|
75
|
+
workers=workers,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(slots=True)
|
|
80
|
+
class SessionReference:
|
|
81
|
+
session_id: str
|
|
82
|
+
tmux_session: str
|
|
83
|
+
manifest_path: Path
|
|
84
|
+
created_at: str
|
|
85
|
+
worker_count: int
|
|
86
|
+
mode: str
|
|
87
|
+
latest_instruction: Optional[str]
|
|
88
|
+
logs_dir: Path
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ManifestStore:
|
|
92
|
+
"""Persist CLI session manifests and maintain an index for /resume."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, base_dir: Optional[Path] = None) -> None:
|
|
95
|
+
self.base_dir = base_dir or Path.home() / ".parallel-dev" / "manifests"
|
|
96
|
+
self.base_dir.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
self.index_path = self.base_dir / "index.json"
|
|
98
|
+
if not self.index_path.exists():
|
|
99
|
+
self.index_path.write_text(json.dumps({"sessions": {}}), encoding="utf-8")
|
|
100
|
+
|
|
101
|
+
def save_manifest(self, manifest: SessionManifest) -> None:
|
|
102
|
+
manifest_path = self.base_dir / f"{manifest.session_id}.yaml"
|
|
103
|
+
manifest_path.write_text(
|
|
104
|
+
yaml.safe_dump(manifest.to_dict(), sort_keys=False),
|
|
105
|
+
encoding="utf-8",
|
|
106
|
+
)
|
|
107
|
+
self._update_index(
|
|
108
|
+
SessionReference(
|
|
109
|
+
session_id=manifest.session_id,
|
|
110
|
+
tmux_session=manifest.tmux_session,
|
|
111
|
+
manifest_path=manifest_path,
|
|
112
|
+
created_at=manifest.created_at,
|
|
113
|
+
worker_count=manifest.worker_count,
|
|
114
|
+
mode=manifest.mode,
|
|
115
|
+
latest_instruction=manifest.latest_instruction,
|
|
116
|
+
logs_dir=Path(manifest.logs_dir),
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def list_sessions(self) -> List[SessionReference]:
|
|
121
|
+
data = self._load_index()
|
|
122
|
+
sessions = []
|
|
123
|
+
for session_id, payload in data.get("sessions", {}).items():
|
|
124
|
+
try:
|
|
125
|
+
sessions.append(
|
|
126
|
+
SessionReference(
|
|
127
|
+
session_id=session_id,
|
|
128
|
+
tmux_session=payload["tmux_session"],
|
|
129
|
+
manifest_path=Path(payload["manifest_path"]),
|
|
130
|
+
created_at=payload["created_at"],
|
|
131
|
+
worker_count=int(payload.get("worker_count", 0)),
|
|
132
|
+
mode=payload.get("mode", "parallel"),
|
|
133
|
+
latest_instruction=payload.get("latest_instruction"),
|
|
134
|
+
logs_dir=Path(payload["logs_dir"]),
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
except KeyError:
|
|
138
|
+
continue
|
|
139
|
+
sessions.sort(key=lambda ref: ref.created_at, reverse=True)
|
|
140
|
+
return sessions
|
|
141
|
+
|
|
142
|
+
def load_manifest(self, session_id: str) -> SessionManifest:
|
|
143
|
+
data = self._load_index()
|
|
144
|
+
payload = data.get("sessions", {}).get(session_id)
|
|
145
|
+
if not payload:
|
|
146
|
+
raise KeyError(f"Session {session_id!r} not found in manifest index.")
|
|
147
|
+
manifest_path = Path(payload["manifest_path"])
|
|
148
|
+
manifest_data = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) or {}
|
|
149
|
+
return SessionManifest.from_dict(manifest_data)
|
|
150
|
+
|
|
151
|
+
def _update_index(self, reference: SessionReference) -> None:
|
|
152
|
+
data = self._load_index()
|
|
153
|
+
data.setdefault("sessions", {})[reference.session_id] = {
|
|
154
|
+
"tmux_session": reference.tmux_session,
|
|
155
|
+
"manifest_path": str(reference.manifest_path),
|
|
156
|
+
"created_at": reference.created_at,
|
|
157
|
+
"worker_count": reference.worker_count,
|
|
158
|
+
"mode": reference.mode,
|
|
159
|
+
"latest_instruction": reference.latest_instruction,
|
|
160
|
+
"logs_dir": str(reference.logs_dir),
|
|
161
|
+
}
|
|
162
|
+
self.index_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
163
|
+
|
|
164
|
+
def _load_index(self) -> Dict[str, object]:
|
|
165
|
+
return json.loads(self.index_path.read_text(encoding="utf-8"))
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Persistence helper for CLI settings stored in the user configuration directory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, Optional
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
from platformdirs import PlatformDirs
|
|
13
|
+
|
|
14
|
+
CONFIG_FILENAME = "config.yaml"
|
|
15
|
+
ENV_CONFIG_PATH = "PARALLEL_DEV_CONFIG_PATH"
|
|
16
|
+
ENV_WORKTREE_ROOT = "PARALLEL_DEV_WORKTREE_ROOT"
|
|
17
|
+
_UNSET = object()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def default_config_dir() -> Path:
|
|
21
|
+
system = platform.system().lower()
|
|
22
|
+
if "windows" in system:
|
|
23
|
+
dirs = PlatformDirs(appname="ParallelDeveloper", appauthor="ParallelDeveloper", roaming=True)
|
|
24
|
+
return Path(dirs.user_config_path)
|
|
25
|
+
return Path.home() / ".parallel-dev"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def resolve_settings_path(explicit_path: Optional[Path] = None) -> Path:
|
|
29
|
+
if explicit_path is not None:
|
|
30
|
+
return Path(explicit_path)
|
|
31
|
+
env_path = os.getenv(ENV_CONFIG_PATH)
|
|
32
|
+
if env_path:
|
|
33
|
+
return Path(env_path).expanduser()
|
|
34
|
+
return default_config_dir() / CONFIG_FILENAME
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def resolve_worktree_root(config_value: Optional[str], fallback: Path) -> Path:
|
|
38
|
+
env_value = os.getenv(ENV_WORKTREE_ROOT)
|
|
39
|
+
if env_value:
|
|
40
|
+
return Path(env_value).expanduser()
|
|
41
|
+
if config_value:
|
|
42
|
+
return Path(config_value).expanduser()
|
|
43
|
+
return Path(fallback)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class SettingsData:
|
|
48
|
+
attach: str = "auto"
|
|
49
|
+
boss: str = "score"
|
|
50
|
+
flow: str = "full_auto"
|
|
51
|
+
parallel: str = "3"
|
|
52
|
+
mode: str = "parallel"
|
|
53
|
+
commit: str = "manual"
|
|
54
|
+
merge: str = "auto"
|
|
55
|
+
worktree_root: Optional[str] = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SettingsStore:
|
|
59
|
+
"""Load and persist CLI configuration flags."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, path: Path) -> None:
|
|
62
|
+
self._path = path
|
|
63
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
self._data: SettingsData = self._load()
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def _normalize_merge(value: Optional[str]) -> str:
|
|
68
|
+
if value is None:
|
|
69
|
+
return "auto"
|
|
70
|
+
token = str(value).strip().lower()
|
|
71
|
+
if token not in {"manual", "auto"}:
|
|
72
|
+
return "auto"
|
|
73
|
+
return token
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def attach(self) -> str:
|
|
77
|
+
return self._data.attach
|
|
78
|
+
|
|
79
|
+
@attach.setter
|
|
80
|
+
def attach(self, value: str) -> None:
|
|
81
|
+
self._data.attach = value
|
|
82
|
+
self._save()
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def boss(self) -> str:
|
|
86
|
+
return self._data.boss
|
|
87
|
+
|
|
88
|
+
@boss.setter
|
|
89
|
+
def boss(self, value: str) -> None:
|
|
90
|
+
self._data.boss = value
|
|
91
|
+
self._save()
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def flow(self) -> str:
|
|
95
|
+
return self._data.flow
|
|
96
|
+
|
|
97
|
+
@flow.setter
|
|
98
|
+
def flow(self, value: str) -> None:
|
|
99
|
+
self._data.flow = value
|
|
100
|
+
self._save()
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def parallel(self) -> str:
|
|
104
|
+
return self._data.parallel
|
|
105
|
+
|
|
106
|
+
@parallel.setter
|
|
107
|
+
def parallel(self, value: str) -> None:
|
|
108
|
+
self._data.parallel = value
|
|
109
|
+
self._save()
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def mode(self) -> str:
|
|
113
|
+
return self._data.mode
|
|
114
|
+
|
|
115
|
+
@mode.setter
|
|
116
|
+
def mode(self, value: str) -> None:
|
|
117
|
+
self._data.mode = value
|
|
118
|
+
self._save()
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def commit(self) -> str:
|
|
122
|
+
return self._data.commit
|
|
123
|
+
|
|
124
|
+
@commit.setter
|
|
125
|
+
def commit(self, value: str) -> None:
|
|
126
|
+
self._data.commit = value
|
|
127
|
+
self._save()
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def worktree_root(self) -> Optional[str]:
|
|
131
|
+
return self._data.worktree_root
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def merge(self) -> str:
|
|
135
|
+
return self._data.merge
|
|
136
|
+
|
|
137
|
+
@merge.setter
|
|
138
|
+
def merge(self, value: str) -> None:
|
|
139
|
+
self._data.merge = value
|
|
140
|
+
self._save()
|
|
141
|
+
|
|
142
|
+
@worktree_root.setter
|
|
143
|
+
def worktree_root(self, value: Optional[object]) -> None:
|
|
144
|
+
self._data.worktree_root = str(value) if value else None
|
|
145
|
+
self._save()
|
|
146
|
+
|
|
147
|
+
def snapshot(self) -> Dict[str, object]:
|
|
148
|
+
payload: Dict[str, object] = {
|
|
149
|
+
"commands": {
|
|
150
|
+
"attach": self._data.attach,
|
|
151
|
+
"boss": self._data.boss,
|
|
152
|
+
"flow": self._data.flow,
|
|
153
|
+
"parallel": self._data.parallel,
|
|
154
|
+
"mode": self._data.mode,
|
|
155
|
+
"commit": self._data.commit,
|
|
156
|
+
"merge": self._data.merge,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if self._data.worktree_root:
|
|
160
|
+
payload["paths"] = {"worktree_root": self._data.worktree_root}
|
|
161
|
+
return payload
|
|
162
|
+
|
|
163
|
+
def update(
|
|
164
|
+
self,
|
|
165
|
+
*,
|
|
166
|
+
attach: Optional[str] = None,
|
|
167
|
+
boss: Optional[str] = None,
|
|
168
|
+
flow: Optional[str] = None,
|
|
169
|
+
parallel: Optional[str] = None,
|
|
170
|
+
mode: Optional[str] = None,
|
|
171
|
+
commit: Optional[str] = None,
|
|
172
|
+
merge: Optional[str] = None,
|
|
173
|
+
worktree_root: object = _UNSET,
|
|
174
|
+
) -> None:
|
|
175
|
+
if attach is not None:
|
|
176
|
+
self._data.attach = attach
|
|
177
|
+
if boss is not None:
|
|
178
|
+
self._data.boss = boss
|
|
179
|
+
if flow is not None:
|
|
180
|
+
self._data.flow = flow
|
|
181
|
+
if parallel is not None:
|
|
182
|
+
self._data.parallel = parallel
|
|
183
|
+
if mode is not None:
|
|
184
|
+
self._data.mode = mode
|
|
185
|
+
if commit is not None:
|
|
186
|
+
self._data.commit = commit
|
|
187
|
+
if merge is not None:
|
|
188
|
+
self._data.merge = merge
|
|
189
|
+
if worktree_root is not _UNSET:
|
|
190
|
+
self._data.worktree_root = str(worktree_root) if worktree_root else None
|
|
191
|
+
self._save()
|
|
192
|
+
|
|
193
|
+
def _load(self) -> SettingsData:
|
|
194
|
+
payload: Dict[str, object]
|
|
195
|
+
if self._path.exists():
|
|
196
|
+
try:
|
|
197
|
+
payload = yaml.safe_load(self._path.read_text(encoding="utf-8")) or {}
|
|
198
|
+
except yaml.YAMLError:
|
|
199
|
+
payload = {}
|
|
200
|
+
else:
|
|
201
|
+
payload = {}
|
|
202
|
+
|
|
203
|
+
commands = payload.get("commands") if isinstance(payload, dict) else None
|
|
204
|
+
|
|
205
|
+
paths_data = payload.get("paths") if isinstance(payload, dict) else None
|
|
206
|
+
worktree_root_value: Optional[str] = None
|
|
207
|
+
if isinstance(paths_data, dict):
|
|
208
|
+
raw_root = paths_data.get("worktree_root")
|
|
209
|
+
if raw_root:
|
|
210
|
+
worktree_root_value = str(raw_root)
|
|
211
|
+
if isinstance(commands, dict):
|
|
212
|
+
return SettingsData(
|
|
213
|
+
attach=str(commands.get("attach", "auto")),
|
|
214
|
+
boss=str(commands.get("boss", "score")),
|
|
215
|
+
flow=str(commands.get("flow", "full_auto")),
|
|
216
|
+
parallel=str(commands.get("parallel", "3")),
|
|
217
|
+
mode=str(commands.get("mode", "parallel")),
|
|
218
|
+
commit=str(commands.get("commit", "manual")),
|
|
219
|
+
merge=self._normalize_merge(commands.get("merge")),
|
|
220
|
+
worktree_root=worktree_root_value,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Legacy YAML keys fallback
|
|
224
|
+
return SettingsData(
|
|
225
|
+
attach=str(payload.get("attach_mode", "auto")),
|
|
226
|
+
boss=str(payload.get("boss_mode", "score")),
|
|
227
|
+
flow=str(payload.get("flow_mode", "full_auto")),
|
|
228
|
+
parallel=str(payload.get("worker_count", "3")),
|
|
229
|
+
mode=str(payload.get("session_mode", "parallel")),
|
|
230
|
+
commit="auto" if bool(payload.get("auto_commit", False)) else "manual",
|
|
231
|
+
merge="auto",
|
|
232
|
+
worktree_root=None,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def _save(self) -> None:
|
|
236
|
+
try:
|
|
237
|
+
self._path.write_text(
|
|
238
|
+
yaml.safe_dump(self.snapshot(), sort_keys=True, allow_unicode=True),
|
|
239
|
+
encoding="utf-8",
|
|
240
|
+
)
|
|
241
|
+
except OSError:
|
|
242
|
+
pass
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""UI widgets extracted from the parallel developer CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Dict, List, Optional, TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
from textual import events
|
|
10
|
+
from textual.message import Message
|
|
11
|
+
from textual.widget import Widget
|
|
12
|
+
from textual.widgets import OptionList, RichLog, Static, TextArea
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from parallel_developer.controller import SessionConfig
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"PaletteItem",
|
|
19
|
+
"ControllerEvent",
|
|
20
|
+
"StatusPanel",
|
|
21
|
+
"EventLog",
|
|
22
|
+
"CommandTextArea",
|
|
23
|
+
"CommandHint",
|
|
24
|
+
"CommandPalette",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class PaletteItem:
|
|
30
|
+
label: str
|
|
31
|
+
value: object
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ControllerEvent(Message):
|
|
35
|
+
def __init__(self, event_type: str, payload: Optional[Dict[str, object]] = None) -> None:
|
|
36
|
+
super().__init__()
|
|
37
|
+
self.event_type = event_type
|
|
38
|
+
self.payload = payload or {}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class StatusPanel(Static):
|
|
42
|
+
def update_status(self, config: "SessionConfig", message: str) -> None:
|
|
43
|
+
flow_value = getattr(config, "flow_mode", None)
|
|
44
|
+
if hasattr(flow_value, "value"):
|
|
45
|
+
flow_text = flow_value.value # type: ignore[attr-defined]
|
|
46
|
+
elif flow_value is not None:
|
|
47
|
+
flow_text = str(flow_value)
|
|
48
|
+
else:
|
|
49
|
+
flow_text = "manual"
|
|
50
|
+
lines = [
|
|
51
|
+
f"tmux session : {config.tmux_session}",
|
|
52
|
+
f"mode : {config.mode.value}",
|
|
53
|
+
f"flow : {flow_text}",
|
|
54
|
+
f"workers : {config.worker_count}",
|
|
55
|
+
f"logs root : {config.logs_root}",
|
|
56
|
+
f"status : {message}",
|
|
57
|
+
]
|
|
58
|
+
self.update("\n".join(lines))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class EventLog(RichLog):
|
|
62
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
63
|
+
super().__init__(*args, highlight=True, markup=True, **kwargs)
|
|
64
|
+
self.wrap = True
|
|
65
|
+
self.auto_scroll = True
|
|
66
|
+
self.min_width = 0
|
|
67
|
+
self._entries: List[str] = []
|
|
68
|
+
|
|
69
|
+
def log(self, text: str) -> None:
|
|
70
|
+
for line in text.splitlines():
|
|
71
|
+
self._entries.append(line)
|
|
72
|
+
self._write_line(line)
|
|
73
|
+
|
|
74
|
+
def _write_line(self, line: str) -> None:
|
|
75
|
+
if self.markup:
|
|
76
|
+
renderable = Text.from_markup(line)
|
|
77
|
+
else:
|
|
78
|
+
renderable = Text(line)
|
|
79
|
+
renderable.no_wrap = False
|
|
80
|
+
renderable.overflow = "fold"
|
|
81
|
+
super().write(renderable)
|
|
82
|
+
|
|
83
|
+
def on_resize(self, event: events.Resize) -> None:
|
|
84
|
+
super().on_resize(event)
|
|
85
|
+
self._redraw()
|
|
86
|
+
|
|
87
|
+
def _redraw(self) -> None:
|
|
88
|
+
if not self._entries:
|
|
89
|
+
return
|
|
90
|
+
if not getattr(self, "_size_known", False):
|
|
91
|
+
return
|
|
92
|
+
super().clear()
|
|
93
|
+
for line in self._entries:
|
|
94
|
+
self._write_line(line)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def entries(self) -> List[str]:
|
|
98
|
+
return list(self._entries)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class CommandTextArea(TextArea):
|
|
102
|
+
async def _on_key(self, event: events.Key) -> None: # type: ignore[override]
|
|
103
|
+
key = event.key or ""
|
|
104
|
+
name = event.name or ""
|
|
105
|
+
aliases = set(event.aliases)
|
|
106
|
+
|
|
107
|
+
if not hasattr(self, "_shift_next_enter"):
|
|
108
|
+
self._shift_next_enter = False # type: ignore[attr-defined]
|
|
109
|
+
|
|
110
|
+
if key == "shift":
|
|
111
|
+
event.stop()
|
|
112
|
+
event.prevent_default()
|
|
113
|
+
self._shift_next_enter = True # type: ignore[attr-defined]
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
if key in {"ctrl+enter", "meta+enter"} or name in {"ctrl_enter", "meta_enter"} or aliases.intersection({"ctrl+enter", "meta+enter"}):
|
|
117
|
+
event.stop()
|
|
118
|
+
event.prevent_default()
|
|
119
|
+
self._shift_next_enter = False # type: ignore[attr-defined]
|
|
120
|
+
app = self.app
|
|
121
|
+
if hasattr(app, "_submit_command_input"):
|
|
122
|
+
app._submit_command_input() # type: ignore[attr-defined]
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
if key == "shift+enter" or name == "shift_enter" or "shift+enter" in aliases:
|
|
126
|
+
event.stop()
|
|
127
|
+
event.prevent_default()
|
|
128
|
+
self._shift_next_enter = False # type: ignore[attr-defined]
|
|
129
|
+
self.insert("\n")
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
if key == "tab" or name == "tab" or "tab" in aliases:
|
|
133
|
+
event.stop()
|
|
134
|
+
event.prevent_default()
|
|
135
|
+
self._shift_next_enter = False # type: ignore[attr-defined]
|
|
136
|
+
handler = getattr(self.app, "_handle_tab_navigation", None)
|
|
137
|
+
if callable(handler):
|
|
138
|
+
handler(reverse=False)
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
if key == "shift+tab" or name == "shift_tab" or "shift+tab" in aliases:
|
|
142
|
+
event.stop()
|
|
143
|
+
event.prevent_default()
|
|
144
|
+
self._shift_next_enter = False # type: ignore[attr-defined]
|
|
145
|
+
handler = getattr(self.app, "_handle_tab_navigation", None)
|
|
146
|
+
if callable(handler):
|
|
147
|
+
handler(reverse=True)
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
if key == "enter":
|
|
151
|
+
event.stop()
|
|
152
|
+
event.prevent_default()
|
|
153
|
+
if getattr(self, "_shift_next_enter", False):
|
|
154
|
+
self._shift_next_enter = False # type: ignore[attr-defined]
|
|
155
|
+
self.insert("\n")
|
|
156
|
+
else:
|
|
157
|
+
app = self.app
|
|
158
|
+
if hasattr(app, "_submit_command_input"):
|
|
159
|
+
app._submit_command_input() # type: ignore[attr-defined]
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
self._shift_next_enter = False # type: ignore[attr-defined]
|
|
163
|
+
await super()._on_key(event)
|
|
164
|
+
|
|
165
|
+
def action_cursor_down(self, select: bool = False) -> None: # type: ignore[override]
|
|
166
|
+
if select:
|
|
167
|
+
super().action_cursor_down(select)
|
|
168
|
+
return
|
|
169
|
+
app = self.app
|
|
170
|
+
if getattr(getattr(app, "command_palette", None), "display", False):
|
|
171
|
+
app.command_palette.move_next() # type: ignore[union-attr]
|
|
172
|
+
return
|
|
173
|
+
super().action_cursor_down(select)
|
|
174
|
+
|
|
175
|
+
def action_cursor_up(self, select: bool = False) -> None: # type: ignore[override]
|
|
176
|
+
if select:
|
|
177
|
+
super().action_cursor_up(select)
|
|
178
|
+
return
|
|
179
|
+
app = self.app
|
|
180
|
+
if getattr(getattr(app, "command_palette", None), "display", False):
|
|
181
|
+
app.command_palette.move_previous() # type: ignore[union-attr]
|
|
182
|
+
return
|
|
183
|
+
super().action_cursor_up(select)
|
|
184
|
+
|
|
185
|
+
def action_cursor_end(self, select: bool = False) -> None: # type: ignore[override]
|
|
186
|
+
doc = getattr(self, "document", None)
|
|
187
|
+
if doc is None:
|
|
188
|
+
self.move_cursor((0, 0), select=select)
|
|
189
|
+
return
|
|
190
|
+
line_count = getattr(doc, "line_count", 0)
|
|
191
|
+
if line_count <= 0:
|
|
192
|
+
self.move_cursor((0, 0), select=select)
|
|
193
|
+
return
|
|
194
|
+
last_row = line_count - 1
|
|
195
|
+
try:
|
|
196
|
+
last_line = doc.get_line(last_row)
|
|
197
|
+
last_col = len(last_line)
|
|
198
|
+
except Exception: # pragma: no cover - document API should not fail
|
|
199
|
+
last_row, last_col = 0, 0
|
|
200
|
+
self.move_cursor((last_row, last_col), select=select)
|
|
201
|
+
self.scroll_cursor_visible()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class CommandHint(Static):
|
|
205
|
+
def update_hint(self, paused: bool = False) -> None:
|
|
206
|
+
suffix = ""
|
|
207
|
+
if paused:
|
|
208
|
+
suffix = " | [orange1]一時停止モード: ESCで巻き戻し、入力はワーカーへ送信[/]"
|
|
209
|
+
self.update(
|
|
210
|
+
"Commands : /attach, /parallel, /mode, /flow, /resume, /log, /status, /scoreboard, /done, /help, /exit | ESC: 一時停止/巻き戻し"
|
|
211
|
+
+ suffix
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class CommandPalette(Static):
|
|
216
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
217
|
+
super().__init__(*args, **kwargs)
|
|
218
|
+
self.display = False
|
|
219
|
+
self._items: List[PaletteItem] = []
|
|
220
|
+
self._active_index: int = 0
|
|
221
|
+
self._renderable: Text = Text()
|
|
222
|
+
self._max_items: int = 7
|
|
223
|
+
|
|
224
|
+
def set_items(self, items: List[PaletteItem]) -> None:
|
|
225
|
+
self._items = items[: self._max_items]
|
|
226
|
+
self._active_index = 0
|
|
227
|
+
if not self._items:
|
|
228
|
+
self.display = False
|
|
229
|
+
self._renderable = Text()
|
|
230
|
+
return
|
|
231
|
+
self.display = True
|
|
232
|
+
self._rebuild_renderable()
|
|
233
|
+
|
|
234
|
+
def _rebuild_renderable(self) -> None:
|
|
235
|
+
if not self._items:
|
|
236
|
+
self._renderable = Text()
|
|
237
|
+
return
|
|
238
|
+
lines: List[Text] = []
|
|
239
|
+
for idx, item in enumerate(self._items):
|
|
240
|
+
prefix = "▶ " if idx == self._active_index else " "
|
|
241
|
+
style = "bold yellow" if idx == self._active_index else ""
|
|
242
|
+
lines.append(Text(prefix + item.label, style=style))
|
|
243
|
+
combined = Text()
|
|
244
|
+
for idx, segment in enumerate(lines):
|
|
245
|
+
if idx:
|
|
246
|
+
combined.append("\n")
|
|
247
|
+
combined.append(segment)
|
|
248
|
+
self._renderable = combined
|
|
249
|
+
self.refresh()
|
|
250
|
+
|
|
251
|
+
def move_next(self) -> None:
|
|
252
|
+
if not self._items:
|
|
253
|
+
return
|
|
254
|
+
self._active_index = (self._active_index + 1) % len(self._items)
|
|
255
|
+
self._rebuild_renderable()
|
|
256
|
+
|
|
257
|
+
def move_previous(self) -> None:
|
|
258
|
+
if not self._items:
|
|
259
|
+
return
|
|
260
|
+
self._active_index = (self._active_index - 1) % len(self._items)
|
|
261
|
+
self._rebuild_renderable()
|
|
262
|
+
|
|
263
|
+
def get_active_item(self) -> Optional[PaletteItem]:
|
|
264
|
+
if not self._items:
|
|
265
|
+
return None
|
|
266
|
+
return self._items[self._active_index]
|
|
267
|
+
|
|
268
|
+
def render(self) -> Text:
|
|
269
|
+
return self._renderable
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sibyl-cli
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: CLI orchestrator for parallel Codex agents with tmux and git worktree integration.
|
|
5
|
+
Author: Parallel Developer Team
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: gitpython>=3.1.45
|
|
8
|
+
Requires-Dist: libtmux>=0.46.2
|
|
9
|
+
Requires-Dist: platformdirs>=4.3.6
|
|
10
|
+
Requires-Dist: pydantic>=2.9
|
|
11
|
+
Requires-Dist: pyyaml>=6.0.2
|
|
12
|
+
Requires-Dist: rich>=13.9.2
|
|
13
|
+
Requires-Dist: textual>=0.51.1
|
|
14
|
+
Requires-Dist: typer>=0.12.3
|
|
15
|
+
Requires-Dist: watchfiles>=0.23.0
|