Initial commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
"""Modal screens for the CLI board Textual app."""
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user