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,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()
|