Initial commit

This commit is contained in:
LZH-YS1998
2026-07-01 17:56:31 +08:00
commit d78931979d
731 changed files with 311088 additions and 0 deletions
@@ -0,0 +1,2 @@
"""Modal screens for the CLI board Textual app."""
+67
View File
@@ -0,0 +1,67 @@
"""Help modal for the CLI board."""
from __future__ import annotations
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.screen import ModalScreen
from textual.widgets import Static
class HelpScreen(ModalScreen[None]):
"""Display keyboard shortcuts."""
DEFAULT_CSS = """
HelpScreen {
align: center middle;
}
.help-dialog {
width: 96;
max-width: 95%;
height: auto;
border: solid $primary;
background: $surface;
padding: 1 2;
}
.help-copy {
margin-top: 1;
}
"""
BINDINGS = [("escape", "close", "Close"), ("enter", "close", "Close")]
def compose(self) -> ComposeResult:
help_text = (
"Navigation\n"
" Arrow keys / h j k l: move selection\n"
" Tab / Shift+Tab: cycle pane focus\n"
" Enter: open focus view or advance context tab\n"
" Space: toggle density\n"
"\n"
"Views\n"
" 1: kanban 2: list 3: focus 4: projection 5: org\n"
" E: switch execution mode (task / company / custom)\n"
"\n"
"Task Actions\n"
" n: create task g: run selected task\n"
" s: reply in session m: move between columns\n"
" a / d: approve / deny checkpoint\n"
" e: checkpoint feedback (approve/deny with message)\n"
" c: done x: cancel t: retry w: runtime recovery\n"
"\n"
"Session Management\n"
" R: rename session D: delete session\n"
"\n"
"Search and Tools\n"
" /: search filter f: toggle done visibility\n"
" r: refresh board Ctrl+K or :: command palette\n"
" ?: this help q: quit"
)
with Vertical(classes="help-dialog"):
yield Static("OpenOPC CLI Board Help", id="help-title")
yield Static(help_text, classes="help-copy")
def action_close(self) -> None:
self.dismiss(None)
@@ -0,0 +1,143 @@
"""Command palette for the CLI board."""
from __future__ import annotations
from dataclasses import dataclass
from rich.console import Group, RenderableType
from rich.text import Text
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.screen import ModalScreen
from textual.widgets import Input, Static
@dataclass(frozen=True)
class PaletteCommand:
command_id: str
label: str
description: str = ""
keys: str = ""
class CommandPaletteScreen(ModalScreen[str | None]):
"""Small command palette with filtering and keyboard navigation."""
DEFAULT_CSS = """
CommandPaletteScreen {
align: center middle;
}
.palette-dialog {
width: 96;
max-width: 95%;
height: auto;
border: solid $primary;
background: $surface;
padding: 1 2;
}
#palette-filter {
margin-top: 1;
}
#palette-list {
margin-top: 1;
max-height: 18;
}
.palette-help {
margin-top: 1;
color: $text-muted;
}
"""
BINDINGS = [
("escape", "close", "Close"),
("down", "cursor_down", "Down"),
("up", "cursor_up", "Up"),
("enter", "submit", "Run"),
]
def __init__(self, *, title: str, commands: list[PaletteCommand]) -> None:
super().__init__()
self.title_text = title
self.commands = list(commands)
self.cursor = 0
def compose(self) -> ComposeResult:
with Vertical(classes="palette-dialog"):
yield Static(self.title_text, id="palette-title")
yield Input(placeholder="Type to filter commands", id="palette-filter")
yield Static(id="palette-list")
yield Static("Enter to run, Esc to close, Up/Down to navigate.", classes="palette-help")
def on_mount(self) -> None:
self.set_focus(self.query_one("#palette-filter", Input))
self._refresh_list()
def on_input_changed(self, event: Input.Changed) -> None:
if event.input.id != "palette-filter":
return
self.cursor = 0
self._refresh_list()
def on_input_submitted(self, event: Input.Submitted) -> None:
if event.input.id == "palette-filter":
self.action_submit()
def action_cursor_down(self) -> None:
commands = self._filtered_commands()
if not commands:
return
self.cursor = min(len(commands) - 1, self.cursor + 1)
self._refresh_list()
def action_cursor_up(self) -> None:
commands = self._filtered_commands()
if not commands:
return
self.cursor = max(0, self.cursor - 1)
self._refresh_list()
def action_submit(self) -> None:
commands = self._filtered_commands()
if not commands:
self.dismiss(None)
return
self.dismiss(commands[self.cursor].command_id)
def action_close(self) -> None:
self.dismiss(None)
def _refresh_list(self) -> None:
self.query_one("#palette-list", Static).update(self._render_list())
def _render_list(self) -> RenderableType:
commands = self._filtered_commands()
if not commands:
return Text("No commands match the current query.", style="dim")
rows: list[Text] = []
for index, command in enumerate(commands):
selected = index == self.cursor
row = Text(style="black on #22d3ee" if selected else "white")
row.append(command.label, style="bold" if not selected else "bold black on #22d3ee")
if command.keys:
row.append(f" {command.keys}", style="dim" if not selected else "black on #22d3ee")
if command.description:
row.append(f"\n{command.description}", style="dim" if not selected else "black on #22d3ee")
rows.append(row)
return Group(*rows)
def _filtered_commands(self) -> list[PaletteCommand]:
query = self.query_one("#palette-filter", Input).value.strip().casefold()
if not query:
return self.commands
filtered = [
command
for command in self.commands
if query in " ".join([command.label, command.description, command.keys]).casefold()
]
if self.cursor >= len(filtered):
self.cursor = max(0, len(filtered) - 1)
return filtered
+152
View File
@@ -0,0 +1,152 @@
"""Reusable modal prompt screen."""
from __future__ import annotations
from dataclasses import dataclass
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import Button, Input, Label, Static, TextArea
@dataclass(frozen=True)
class PromptField:
key: str
label: str
value: str = ""
placeholder: str = ""
password: bool = False
multiline: bool = False
class PromptScreen(ModalScreen[dict[str, str] | None]):
"""Simple form dialog used by the CLI board."""
DEFAULT_CSS = """
PromptScreen {
align: center middle;
}
.prompt-dialog {
width: 88;
max-width: 90%;
height: auto;
border: solid $primary;
background: $surface;
padding: 1 2;
}
.prompt-actions {
align-horizontal: right;
height: auto;
margin-top: 1;
}
.prompt-field {
margin-top: 1;
}
.prompt-title {
text-style: bold;
}
.prompt-help {
color: $text-muted;
margin-top: 1;
}
.prompt-textarea {
height: 6;
margin-top: 1;
}
"""
BINDINGS = [("escape", "cancel", "Cancel")]
def __init__(
self,
*,
title: str,
fields: list[PromptField],
help_text: str = "",
confirm_label: str = "Confirm",
) -> None:
super().__init__()
self.title_text = title
self.fields = fields
self.help_text = help_text
self.confirm_label = confirm_label
def compose(self) -> ComposeResult:
with Vertical(classes="prompt-dialog"):
yield Static(self.title_text, id="prompt-title", classes="prompt-title")
for field in self.fields:
yield Label(field.label, classes="prompt-field")
if field.multiline:
yield TextArea(
field.value,
id=f"field-{field.key}",
classes="prompt-textarea",
tab_behavior="indent",
)
else:
yield Input(
value=field.value,
placeholder=field.placeholder,
password=field.password,
id=f"field-{field.key}",
)
if self.help_text:
yield Static(self.help_text, classes="prompt-help")
with Horizontal(classes="prompt-actions"):
yield Button("Cancel", id="cancel")
yield Button(self.confirm_label, id="confirm", variant="primary")
def on_mount(self) -> None:
if self.fields:
first_id = f"field-{self.fields[0].key}"
try:
widget = self.query_one(f"#{first_id}")
self.set_focus(widget)
except Exception:
pass
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "cancel":
self.dismiss(None)
return
if event.button.id == "confirm":
self.dismiss(self._collect_values())
def on_input_submitted(self, event: Input.Submitted) -> None:
input_field_ids = [f"field-{f.key}" for f in self.fields if not f.multiline]
if event.input.id not in input_field_ids:
return
# Find position among ALL fields (not just Input fields)
all_field_ids = [f"field-{f.key}" for f in self.fields]
idx = all_field_ids.index(event.input.id)
if idx == len(all_field_ids) - 1:
self.dismiss(self._collect_values())
return
next_id = all_field_ids[idx + 1]
try:
next_widget = self.query_one(f"#{next_id}")
self.set_focus(next_widget)
except Exception:
self.dismiss(self._collect_values())
def action_cancel(self) -> None:
self.dismiss(None)
def _collect_values(self) -> dict[str, str]:
result: dict[str, str] = {}
for field in self.fields:
widget_id = f"field-{field.key}"
if field.multiline:
widget = self.query_one(f"#{widget_id}", TextArea)
result[field.key] = widget.text
else:
widget = self.query_one(f"#{widget_id}", Input)
result[field.key] = widget.value
return result
@@ -0,0 +1,126 @@
"""Recovery modal screen for the CLI board."""
from __future__ import annotations
from dataclasses import dataclass
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.screen import ModalScreen
from textual.widgets import Button, Label, Static
from opc.plugins.cli_board.services.recovery import RecoveryStatus
@dataclass
class RecoveryAction:
action: str # "resume" | "cancel" | "dismiss"
parent_task_id: str = ""
class RecoveryScreen(ModalScreen[RecoveryAction | None]):
"""Show interrupted company runtimes with resume/cancel options."""
DEFAULT_CSS = """
RecoveryScreen {
align: center middle;
}
.recovery-dialog {
width: 88;
max-width: 90%;
height: auto;
max-height: 80%;
border: solid $primary;
background: $surface;
padding: 1 2;
}
.recovery-title {
text-style: bold;
margin-bottom: 1;
}
.recovery-runtime {
margin-bottom: 1;
padding: 1;
border: round $secondary;
}
.recovery-actions {
align-horizontal: right;
height: auto;
margin-top: 1;
}
.recovery-empty {
color: $text-muted;
margin: 1;
}
"""
BINDINGS = [("escape", "dismiss_screen", "Close")]
def __init__(self, status: RecoveryStatus) -> None:
super().__init__()
self.status = status
def compose(self) -> ComposeResult:
with Vertical(classes="recovery-dialog"):
yield Static("Interrupted Runtimes", classes="recovery-title")
if not self.status.interrupted:
yield Static("No interrupted runtimes found.", classes="recovery-empty")
else:
with VerticalScroll():
for wf in self.status.interrupted:
with Vertical(classes="recovery-runtime"):
# Runtime header
active = wf.parent_task_id in set(self.status.active_recoveries)
status_label = " (recovering...)" if active else ""
yield Label(f"{wf.title}{status_label}")
yield Static(
f" Profile: {wf.profile or 'unknown'} "
f"Interrupted: {wf.interrupted_at[:19] if wf.interrupted_at else '?'}"
)
# Work-item summary
done = sum(1 for s in wf.work_items if s.status == "done")
total = len(wf.work_items)
failed = sum(1 for s in wf.work_items if s.interrupted)
yield Static(
f" Work items: {done}/{total} done, {failed} interrupted"
)
if not active:
with Horizontal():
yield Button(
"Resume",
id=f"resume-{wf.parent_task_id}",
variant="primary",
)
yield Button(
"Cancel",
id=f"cancel-{wf.parent_task_id}",
variant="error",
)
with Horizontal(classes="recovery-actions"):
yield Button("Close", id="close-recovery")
def on_button_pressed(self, event: Button.Pressed) -> None:
btn_id = event.button.id or ""
if btn_id == "close-recovery":
self.dismiss(None)
return
if btn_id.startswith("resume-"):
task_id = btn_id[len("resume-"):]
self.dismiss(RecoveryAction(action="resume", parent_task_id=task_id))
return
if btn_id.startswith("cancel-"):
task_id = btn_id[len("cancel-"):]
self.dismiss(RecoveryAction(action="cancel", parent_task_id=task_id))
return
def action_dismiss_screen(self) -> None:
self.dismiss(None)