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,5 @@
1
+ """Sibyl CLI package metadata (formerly Parallel Developer)."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.2.0"
@@ -0,0 +1,649 @@
1
+ """Textual-based interactive CLI for parallel developer orchestrator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from contextlib import suppress
7
+ from pathlib import Path
8
+ import shutil
9
+ import subprocess
10
+ import time
11
+ from typing import Dict, List, Optional, Tuple
12
+
13
+ from rich.text import Text
14
+ from textual import events
15
+ from textual.app import App, ComposeResult
16
+ from textual.containers import Container, Vertical
17
+ from textual.dom import NoScreen
18
+ from textual.message import Message
19
+ from textual.widgets import Footer, Header, OptionList
20
+ from textual.widgets.option_list import Option
21
+
22
+ from .controller import CLIController, SessionMode, build_orchestrator
23
+ from .controller.commands import CommandOption, CommandSuggestion
24
+ from .controller.events import ControllerEventType
25
+ from .stores import ManifestStore
26
+ from .ui.widgets import (
27
+ CommandHint,
28
+ CommandPalette,
29
+ CommandTextArea,
30
+ ControllerEvent,
31
+ EventLog,
32
+ PaletteItem,
33
+ StatusPanel,
34
+ )
35
+
36
+
37
+ class ParallelDeveloperApp(App):
38
+ CSS = """
39
+ Screen {
40
+ layout: vertical;
41
+ }
42
+
43
+ #body {
44
+ layout: vertical;
45
+ height: 1fr;
46
+ padding: 1 2;
47
+ min-height: 0;
48
+ }
49
+
50
+ #panel-stack {
51
+ layout: vertical;
52
+ height: 1fr;
53
+ min-height: 0;
54
+ }
55
+
56
+ #log {
57
+ height: 2fr;
58
+ border: round $success;
59
+ margin-bottom: 1;
60
+ overflow-x: hidden;
61
+ min-height: 3;
62
+ }
63
+
64
+ #log.paused {
65
+ border: round $warning;
66
+ }
67
+
68
+ #status {
69
+ border: round $success;
70
+ padding: 1;
71
+ height: 1fr;
72
+ min-height: 1;
73
+ }
74
+
75
+ #status.paused {
76
+ border: round $warning;
77
+ color: $warning;
78
+ }
79
+
80
+ #selection {
81
+ border: round $accent-darken-1;
82
+ padding: 1;
83
+ margin-bottom: 1;
84
+ }
85
+
86
+ #hint {
87
+ padding: 1 0 0 0;
88
+ }
89
+
90
+ #command {
91
+ margin-top: 1;
92
+ height: auto;
93
+ min-height: 3;
94
+ overflow-x: hidden;
95
+ border: round $success;
96
+ background: $surface;
97
+ }
98
+
99
+ #command.paused {
100
+ border: round $warning;
101
+ background: $surface-lighten-3;
102
+ }
103
+
104
+ #hint.paused {
105
+ color: $warning;
106
+ }
107
+ """
108
+
109
+ BINDINGS = [
110
+ ("ctrl+q", "quit", "終了"),
111
+ ("escape", "close_palette", "一時停止/巻き戻し"),
112
+ ]
113
+
114
+ def __init__(self) -> None:
115
+ super().__init__()
116
+ self.status_panel: Optional[StatusPanel] = None
117
+ self.log_panel: Optional[EventLog] = None
118
+ self.selection_list: Optional[OptionList] = None
119
+ self.command_input: Optional[CommandTextArea] = None
120
+ self.command_palette: Optional[CommandPalette] = None
121
+ self.command_hint: Optional[CommandHint] = None
122
+ self._suppress_command_change: bool = False
123
+ self._last_command_text: str = ""
124
+ self._palette_mode: Optional[str] = None
125
+ self._pending_command: Optional[str] = None
126
+ self._default_placeholder: str = "指示または /コマンド"
127
+ self._paused_placeholder: str = "一時停止中: ワーカーへの追加指示を入力"
128
+ self._ctrl_c_armed: bool = False
129
+ self._ctrl_c_armed_at: float = 0.0
130
+ self._ctrl_c_timeout: float = 2.0
131
+ self.controller = CLIController(
132
+ event_handler=self._handle_controller_event,
133
+ manifest_store=ManifestStore(),
134
+ worktree_root=Path.cwd(),
135
+ orchestrator_builder=build_orchestrator,
136
+ )
137
+
138
+ def compose(self) -> ComposeResult:
139
+ yield Header()
140
+ with Container(id="body"):
141
+ with Vertical(id="panel-stack"):
142
+ self.status_panel = StatusPanel(id="status")
143
+ yield self.status_panel
144
+ self.log_panel = EventLog(id="log", max_lines=400)
145
+ yield self.log_panel
146
+ self.selection_list = OptionList(id="selection")
147
+ self.selection_list._allow_focus = True
148
+ self.selection_list.display = False
149
+ yield self.selection_list
150
+ self.command_palette = CommandPalette(id="command-palette")
151
+ self.command_palette.display = False
152
+ yield self.command_palette
153
+ hint = CommandHint(id="hint")
154
+ hint.update_hint(False)
155
+ self.command_hint = hint
156
+ yield hint
157
+ self.command_input = CommandTextArea(
158
+ text="",
159
+ placeholder=self._default_placeholder,
160
+ id="command",
161
+ soft_wrap=True,
162
+ tab_behavior="focus",
163
+ show_line_numbers=False,
164
+ highlight_cursor_line=False,
165
+ compact=True,
166
+ )
167
+ yield self.command_input
168
+ yield Footer()
169
+
170
+ async def on_mount(self) -> None:
171
+ if self.command_input:
172
+ self.command_input.focus()
173
+ self._post_event("status", {"message": "待機中"})
174
+ self.set_class(False, "paused")
175
+ if self.command_hint:
176
+ self.command_hint.update_hint(False)
177
+
178
+ def _submit_command_input(self) -> None:
179
+ if not self.command_input:
180
+ return
181
+ value = self.command_input.text
182
+ if self.command_palette and self.command_palette.display:
183
+ item = self.command_palette.get_active_item()
184
+ if item:
185
+ asyncio.create_task(self._handle_palette_selection(item))
186
+ return
187
+ self._hide_command_palette()
188
+ self._set_command_text("")
189
+ self._ctrl_c_armed = False
190
+ asyncio.create_task(self.controller.handle_input(value.rstrip("\n")))
191
+
192
+ def on_text_area_changed(self, event: TextArea.Changed) -> None:
193
+ if not self.command_input or event.control is not self.command_input:
194
+ return
195
+ if self._suppress_command_change:
196
+ return
197
+ self.controller.history_reset()
198
+ raw_value = self.command_input.text
199
+ if raw_value == self._last_command_text:
200
+ return
201
+ self._last_command_text = raw_value
202
+ processed = raw_value.replace("\n", "")
203
+ if not processed:
204
+ self._pending_command = None
205
+ self._hide_command_palette()
206
+ return
207
+ if not processed.startswith("/"):
208
+ self._pending_command = None
209
+ self._hide_command_palette()
210
+ return
211
+ command, has_space, remainder = processed.partition(" ")
212
+ command = command.lower()
213
+ if not has_space:
214
+ self._pending_command = None
215
+ self._update_command_suggestions(command)
216
+ return
217
+ spec = self.controller._command_specs.get(command)
218
+ if spec is None:
219
+ self._hide_command_palette()
220
+ return
221
+ options = self.controller.get_command_options(command)
222
+ if not options:
223
+ self._last_command_text = value
224
+ self._hide_command_palette()
225
+ return
226
+ remainder = remainder.strip()
227
+ filtered: List[PaletteItem] = []
228
+ for opt in options:
229
+ label = self._format_option_label(opt)
230
+ value_str = str(opt.value)
231
+ search_text = (opt.label + " " + (opt.description or "")).lower()
232
+ if (
233
+ not remainder
234
+ or value_str.startswith(remainder)
235
+ or search_text.startswith(remainder.lower())
236
+ ):
237
+ filtered.append(PaletteItem(label, opt.value))
238
+ if not filtered:
239
+ self._hide_command_palette()
240
+ return
241
+ self._pending_command = command
242
+ self._show_command_palette(filtered, mode="options")
243
+
244
+ def _handle_controller_event(self, event_type: str, payload: Dict[str, object]) -> None:
245
+ def _post() -> None:
246
+ self.post_message(ControllerEvent(event_type, payload))
247
+
248
+ try:
249
+ self.call_from_thread(_post)
250
+ except RuntimeError:
251
+ _post()
252
+
253
+ def _post_event(self, event_type: str, payload: Dict[str, object]) -> None:
254
+ self.post_message(ControllerEvent(event_type, payload))
255
+
256
+ def on_controller_event(self, event: ControllerEvent) -> None:
257
+ event.stop()
258
+ etype = event.event_type
259
+ if etype == ControllerEventType.STATUS.value and self.status_panel:
260
+ message = event.payload.get("message", "")
261
+ self.status_panel.update_status(self.controller._config, str(message))
262
+ elif etype == ControllerEventType.LOG.value and self.log_panel:
263
+ text = str(event.payload.get("text", ""))
264
+ self.log_panel.log(text)
265
+ elif etype == ControllerEventType.SCOREBOARD.value:
266
+ scoreboard = event.payload.get("scoreboard", {})
267
+ if isinstance(scoreboard, dict):
268
+ self._render_scoreboard(scoreboard)
269
+ elif etype == ControllerEventType.LOG_COPY.value:
270
+ message = self._copy_log_to_clipboard()
271
+ self._notify_status(message)
272
+ elif etype == ControllerEventType.LOG_SAVE.value:
273
+ destination = str(event.payload.get("path", "") or "").strip()
274
+ if not destination:
275
+ self._notify_status("保存先パスが指定されていません。")
276
+ else:
277
+ message = self._save_log_to_path(destination)
278
+ self._notify_status(message)
279
+ elif etype == ControllerEventType.PAUSE_STATE.value:
280
+ paused = bool(event.payload.get("paused", False))
281
+ if self.status_panel:
282
+ self.status_panel.set_class(paused, "paused")
283
+ if self.log_panel:
284
+ self.log_panel.set_class(paused, "paused")
285
+ if self.command_input:
286
+ self.command_input.set_class(paused, "paused")
287
+ placeholder = self._paused_placeholder if paused else self._default_placeholder
288
+ self.command_input.placeholder = placeholder
289
+ if self.command_hint:
290
+ self.command_hint.set_class(paused, "paused")
291
+ self.command_hint.update_hint(paused)
292
+ elif etype == ControllerEventType.SELECTION_REQUEST.value:
293
+ candidates = event.payload.get("candidates", [])
294
+ scoreboard = event.payload.get("scoreboard", {})
295
+ self._render_scoreboard(scoreboard)
296
+ if self.selection_list:
297
+ self.selection_list.clear_options()
298
+ for idx, candidate_label in enumerate(candidates, start=1):
299
+ option_text = self._build_option_label(candidate_label, scoreboard)
300
+ option = Option(option_text, str(idx))
301
+ self.selection_list.add_option(option)
302
+ self.selection_list.display = True
303
+ self.selection_list.focus()
304
+ try:
305
+ self.selection_list.cursor_index = 0
306
+ except AttributeError:
307
+ pass
308
+ if self.command_input:
309
+ self.command_input.display = False
310
+ elif etype == ControllerEventType.SELECTION_FINISHED.value:
311
+ if self.selection_list:
312
+ self.selection_list.display = False
313
+ if self.command_input:
314
+ self.command_input.display = True
315
+ self.command_input.focus()
316
+ elif etype == ControllerEventType.QUIT.value:
317
+ self.exit()
318
+
319
+ async def action_quit(self) -> None: # type: ignore[override]
320
+ self.exit()
321
+
322
+ def _handle_ctrl_c(self, event: events.Key) -> bool:
323
+ key = (event.key or "").lower()
324
+ name = (event.name or "").lower()
325
+ if key not in {"ctrl+c", "control+c"} and name not in {"ctrl+c", "control+c"}:
326
+ return False
327
+ event.stop()
328
+ now = time.monotonic()
329
+ if self._ctrl_c_armed and now - self._ctrl_c_armed_at <= self._ctrl_c_timeout:
330
+ self._ctrl_c_armed = False
331
+ self.exit()
332
+ return True
333
+
334
+ self._ctrl_c_armed = True
335
+ self._ctrl_c_armed_at = now
336
+ if self.command_input:
337
+ self._set_command_text("")
338
+ cursor_reset = getattr(self.command_input, "action_cursor_line_start", None)
339
+ if callable(cursor_reset):
340
+ cursor_reset()
341
+ self.controller.history_reset()
342
+ return True
343
+
344
+ def _render_scoreboard(self, scoreboard: Dict[str, Dict[str, object]]) -> None:
345
+ if not self.log_panel:
346
+ return
347
+ if not scoreboard:
348
+ self.log_panel.log("スコアボード情報はありません。")
349
+ return
350
+ lines = ["=== スコアボード ==="]
351
+ for key, data in sorted(
352
+ scoreboard.items(),
353
+ key=lambda item: (item[1].get("score") is None, -(item[1].get("score") or 0.0)),
354
+ ):
355
+ score = data.get("score")
356
+ comment = data.get("comment", "")
357
+ selected = " [selected]" if data.get("selected") else ""
358
+ score_text = "-" if score is None else f"{score:.2f}"
359
+ lines.append(f"{key:>10}: {score_text}{selected} {comment}")
360
+ self.log_panel.log("\n".join(lines))
361
+
362
+ def _build_option_label(self, candidate_label: str, scoreboard: Dict[str, Dict[str, object]]) -> str:
363
+ label_body = candidate_label.split(". ", 1)[1] if ". " in candidate_label else candidate_label
364
+ key = label_body.split(" (", 1)[0].strip()
365
+ entry = scoreboard.get(key, {})
366
+ score = entry.get("score")
367
+ comment = entry.get("comment", "")
368
+ score_text = "-" if score is None else f"{score:.2f}"
369
+ if comment:
370
+ return f"{label_body} • {score_text} • {comment}"
371
+ return f"{label_body} • {score_text}"
372
+
373
+ def _set_command_text(self, value: str) -> None:
374
+ if not self.command_input:
375
+ return
376
+ self._suppress_command_change = True
377
+ self.command_input.text = value
378
+ self._suppress_command_change = False
379
+ self._last_command_text = value
380
+ if not value:
381
+ self._hide_command_palette()
382
+
383
+ def _update_command_suggestions(self, prefix: str) -> None:
384
+ suggestions = self.controller.get_command_suggestions(prefix)
385
+ if not suggestions:
386
+ self._hide_command_palette()
387
+ return
388
+ items = [PaletteItem(f"{s.name:<10} {s.description}", s.name) for s in suggestions]
389
+ self._show_command_palette(items, mode="command")
390
+
391
+ def _format_option_label(self, option: CommandOption) -> str:
392
+ display = getattr(option, "display", None)
393
+ if display:
394
+ return display
395
+ description = getattr(option, "description", None)
396
+ if description:
397
+ return f"{option.label} - {description}"
398
+ return option.label
399
+
400
+ def _show_command_palette(self, items: List[PaletteItem], *, mode: str) -> None:
401
+ if not self.command_palette:
402
+ return
403
+ if not items:
404
+ self._hide_command_palette()
405
+ return
406
+ self._palette_mode = mode
407
+ self.command_palette.set_items(items)
408
+ if self.command_input and self.command_input.has_focus:
409
+ self.set_focus(self.command_input)
410
+
411
+ def _hide_command_palette(self) -> None:
412
+ if self.command_palette:
413
+ self.command_palette.display = False
414
+ self.command_palette.set_items([])
415
+ self._palette_mode = None
416
+ self._pending_command = None
417
+ if self.command_input:
418
+ self.command_input.focus()
419
+
420
+ def action_close_palette(self) -> None:
421
+ self._hide_command_palette()
422
+ self.controller.handle_escape()
423
+
424
+ def action_palette_next(self) -> None:
425
+ if self.command_palette and self.command_palette.display:
426
+ self.command_palette.move_next()
427
+
428
+ def action_palette_previous(self) -> None:
429
+ if self.command_palette and self.command_palette.display:
430
+ self.command_palette.move_previous()
431
+
432
+ def _collect_log_text(self) -> Tuple[str, bool]:
433
+ if not self.log_panel:
434
+ return "", False
435
+ selection = None
436
+ with suppress(NoScreen):
437
+ selection = self.log_panel.text_selection
438
+ if selection:
439
+ extracted = self.log_panel.get_selection(selection)
440
+ if extracted:
441
+ text, ending = extracted
442
+ final_text = text if ending is None else f"{text}{ending}"
443
+ return final_text.rstrip("\n"), True
444
+ if isinstance(self.log_panel, EventLog):
445
+ lines = self.log_panel.entries
446
+ else:
447
+ lines = list(getattr(self.log_panel, "lines", []))
448
+ if lines and lines[-1] == "":
449
+ lines = lines[:-1]
450
+ text = "\n".join(line.rstrip() for line in lines).rstrip("\n")
451
+ return text, False
452
+
453
+ def _copy_log_to_clipboard(self) -> str:
454
+ text, from_selection = self._collect_log_text()
455
+ if not text:
456
+ return "コピー対象のログがありません。"
457
+ self.copy_to_clipboard(text)
458
+ self._copy_to_system_clipboard(text)
459
+ if from_selection:
460
+ return "選択範囲をクリップボードへコピーしました。"
461
+ return "ログ全体をクリップボードへコピーしました。"
462
+
463
+ def _copy_to_system_clipboard(self, text: str) -> None:
464
+ commands = []
465
+ if shutil.which("pbcopy"):
466
+ commands.append(["pbcopy"])
467
+ if shutil.which("wl-copy"):
468
+ commands.append(["wl-copy"])
469
+ if shutil.which("xclip"):
470
+ commands.append(["xclip", "-selection", "clipboard"])
471
+ if shutil.which("clip.exe"):
472
+ commands.append(["clip.exe"])
473
+
474
+ for command in commands:
475
+ try:
476
+ subprocess.run(
477
+ command,
478
+ input=text.encode("utf-8"),
479
+ check=True,
480
+ )
481
+ break
482
+ except Exception:
483
+ continue
484
+
485
+ def _save_log_to_path(self, destination: str) -> str:
486
+ text, _ = self._collect_log_text()
487
+ if not text:
488
+ return "保存対象のログがありません。"
489
+ try:
490
+ path = Path(destination).expanduser()
491
+ path.parent.mkdir(parents=True, exist_ok=True)
492
+ path.write_text(text + "\n", encoding="utf-8")
493
+ except Exception as exc: # noqa: BLE001
494
+ return f"ログの保存に失敗しました: {exc}"
495
+ return f"ログを {path} に保存しました。"
496
+
497
+ def _notify_status(self, message: str, *, also_log: bool = True) -> None:
498
+ if self.status_panel:
499
+ self.status_panel.update_status(self.controller._config, message)
500
+ if also_log and self.log_panel:
501
+ self.log_panel.log(message)
502
+
503
+ def _handle_tab_navigation(self, *, reverse: bool = False) -> None:
504
+ palette = self.command_palette
505
+ palette_visible = bool(palette and palette.display)
506
+ if palette_visible:
507
+ if reverse:
508
+ self.action_palette_previous()
509
+ else:
510
+ self.action_palette_next()
511
+ if self.command_input:
512
+ self.command_input.focus()
513
+
514
+ def on_key(self, event: events.Key) -> None:
515
+ if self._handle_ctrl_c(event):
516
+ return
517
+ if self._handle_text_shortcuts(event):
518
+ return
519
+ key_value = (event.key or "").lower()
520
+ name_value = (event.name or "").lower()
521
+ if key_value in {"tab", "shift+tab"} or name_value in {"tab", "shift_tab"}:
522
+ event.stop()
523
+ event.prevent_default()
524
+ self._handle_tab_navigation(reverse=key_value == "shift+tab" or name_value == "shift_tab")
525
+ return
526
+
527
+ def _handle_text_shortcuts(self, event: events.Key) -> bool:
528
+ shortcuts_select_all = {
529
+ "ctrl+a",
530
+ "control+a",
531
+ "cmd+a",
532
+ "command+a",
533
+ "meta+a",
534
+ "ctrl+shift+a",
535
+ "control+shift+a",
536
+ }
537
+ shortcuts_copy = {
538
+ "ctrl+c",
539
+ "control+c",
540
+ "cmd+c",
541
+ "command+c",
542
+ "meta+c",
543
+ "ctrl+shift+c",
544
+ "control+shift+c",
545
+ "cmd+shift+c",
546
+ "command+shift+c",
547
+ "meta+shift+c",
548
+ "ctrl+alt+c",
549
+ "control+alt+c",
550
+ "cmd+alt+c",
551
+ "command+alt+c",
552
+ "meta+alt+c",
553
+ }
554
+
555
+ def matches(shortcuts: set[str]) -> bool:
556
+ key_value = event.key.lower()
557
+ name_value = (event.name or "").lower()
558
+ if key_value in shortcuts:
559
+ return True
560
+ if name_value and name_value in {shortcut.replace("+", "_") for shortcut in shortcuts}:
561
+ return True
562
+ return False
563
+
564
+ if event.key in {"up", "down"} and not (self.command_palette and self.command_palette.display):
565
+ history_text = self.controller.history_previous() if event.key == "up" else self.controller.history_next()
566
+ if history_text is not None:
567
+ if self.command_input:
568
+ self._set_command_text(history_text)
569
+ self.command_input.action_cursor_end()
570
+ event.stop()
571
+ return True
572
+
573
+ if matches(shortcuts_select_all):
574
+ if self.log_panel:
575
+ self.log_panel.text_select_all()
576
+ self._notify_status("ログ全体を選択しました。", also_log=False)
577
+ event.stop()
578
+ return True
579
+ if matches(shortcuts_copy) and self.log_panel:
580
+ message = self._copy_log_to_clipboard()
581
+ self._notify_status(message)
582
+ event.stop()
583
+ return True
584
+ return False
585
+
586
+ def on_click(self, event: events.Click) -> None:
587
+ if not self.command_input:
588
+ return
589
+ control = event.control
590
+ if control is None:
591
+ self.set_focus(self.command_input)
592
+ return
593
+
594
+ def within(widget: Optional[Widget]) -> bool:
595
+ return bool(widget and widget in control.ancestors_with_self)
596
+
597
+ if within(self.command_input):
598
+ return
599
+ if self.log_panel and within(self.log_panel):
600
+ return
601
+ if self.selection_list and self.selection_list.display and within(self.selection_list):
602
+ return
603
+ if self.command_palette and self.command_palette.display and within(self.command_palette):
604
+ return
605
+ self.set_focus(self.command_input)
606
+
607
+ async def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
608
+ if self.selection_list and event.option_list is self.selection_list:
609
+ event.stop()
610
+ try:
611
+ index = int(event.option_id)
612
+ except (TypeError, ValueError):
613
+ return
614
+ self.controller._resolve_selection(index)
615
+ return
616
+ if self.command_palette and self.command_palette.display:
617
+ event.stop()
618
+ item = self.command_palette.get_active_item()
619
+ if item:
620
+ await self._handle_palette_selection(item)
621
+
622
+ async def _handle_palette_selection(self, item: PaletteItem) -> None:
623
+ if self._palette_mode == "command":
624
+ command_name = str(item.value)
625
+ options = self.controller.get_command_options(command_name)
626
+ if options:
627
+ self._pending_command = command_name
628
+ option_items = [PaletteItem(opt.label, opt.value) for opt in options]
629
+ self._show_command_palette(option_items, mode="options")
630
+ if self.command_input:
631
+ self._set_command_text(f"{command_name} ")
632
+ return
633
+ if self.command_input:
634
+ self._set_command_text("")
635
+ self._hide_command_palette()
636
+ await self.controller.execute_command(command_name)
637
+ return
638
+ if self._palette_mode == "options" and self._pending_command:
639
+ command_name = self._pending_command
640
+ value = item.value
641
+ self._pending_command = None
642
+ if self.command_input:
643
+ self._set_command_text("")
644
+ self._hide_command_palette()
645
+ await self.controller.execute_command(command_name, value)
646
+
647
+
648
+ def run() -> None:
649
+ ParallelDeveloperApp().run()