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.
@@ -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