172 lines
7.7 KiB
Python
172 lines
7.7 KiB
Python
"""Main Kanban board render widget."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from rich.columns import Columns
|
|
from rich.console import Group, RenderableType
|
|
from rich.panel import Panel
|
|
from rich.text import Text
|
|
from textual.widgets import Static
|
|
|
|
from opc.presentation.kanban import DEFAULT_KANBAN_COLUMNS
|
|
|
|
from ..state.store import BoardStateStore
|
|
from .render_utils import adaptive_summary, badge, humanize_age, priority_style, status_style, truncate_text
|
|
|
|
|
|
class KanbanBoardWidget(Static):
|
|
"""Render the board columns from the current state store."""
|
|
|
|
def __init__(self, state: BoardStateStore) -> None:
|
|
super().__init__(id="kanban-board")
|
|
self.state = state
|
|
|
|
_TASK_COLUMN_HINTS = {
|
|
"todo": "Press n to create a task",
|
|
"in-progress": "Select a task, press g to run",
|
|
"done": "Completed tasks appear here",
|
|
}
|
|
_WORK_ITEM_COLUMN_HINTS = {
|
|
"todo": "Press n to create a work item",
|
|
"in-progress": "Select a work item, press g to run",
|
|
"done": "Completed work items appear here",
|
|
}
|
|
|
|
def _company_mode(self) -> bool:
|
|
return self.state.snapshot.mode == "company"
|
|
|
|
def render(self) -> RenderableType:
|
|
grouped = self.state.tasks_by_column()
|
|
total_tasks = sum(len(v) for v in grouped.values())
|
|
focused = self.state.pane_focus == "main" and self.state.view_mode == "kanban"
|
|
|
|
# Empty board: show welcome guide instead of empty columns
|
|
if total_tasks == 0:
|
|
return self._render_welcome(focused)
|
|
|
|
panels: list[Panel] = []
|
|
for column in DEFAULT_KANBAN_COLUMNS:
|
|
tasks = grouped.get(column.column_id, [])
|
|
is_selected_column = any(task.task_id == self.state.selected_task_id for task in tasks)
|
|
active_count = sum(1 for task in tasks if task.status not in {"done", "failed", "cancelled"})
|
|
checkpoint_count = sum(1 for task in tasks if task.pending_checkpoint is not None)
|
|
title = f"{column.name} {len(tasks)}"
|
|
subtitle_bits = []
|
|
if active_count:
|
|
subtitle_bits.append(f"live {active_count}")
|
|
if checkpoint_count:
|
|
subtitle_bits.append(f"review {checkpoint_count}")
|
|
if tasks:
|
|
renderables: list[RenderableType] = [self._render_task(task) for task in tasks]
|
|
else:
|
|
hints = self._WORK_ITEM_COLUMN_HINTS if self._company_mode() else self._TASK_COLUMN_HINTS
|
|
renderables = [Text(hints.get(column.column_id, ""), style="dim")]
|
|
panels.append(
|
|
Panel(
|
|
Group(*renderables),
|
|
title=title,
|
|
subtitle=" | ".join(subtitle_bits) if subtitle_bits else "",
|
|
border_style="cyan" if focused and is_selected_column else "white",
|
|
padding=(0, 1),
|
|
)
|
|
)
|
|
return Columns(panels, expand=True, equal=True)
|
|
|
|
def _render_welcome(self, focused: bool) -> RenderableType:
|
|
item_label = "work item" if self._company_mode() else "task"
|
|
text = Text()
|
|
text.append("OpenOPC CLI Board\n\n", style="bold #38bdf8")
|
|
text.append("Get started:\n", style="bold white")
|
|
text.append(" n", style="bold cyan")
|
|
text.append(f" Create a {item_label} - describe what you need\n", style="white")
|
|
text.append(" E", style="bold cyan")
|
|
text.append(" Switch mode \u2014 choose task or company\n", style="white")
|
|
text.append(" ?", style="bold cyan")
|
|
text.append(" Help \u2014 see all keyboard shortcuts\n\n", style="white")
|
|
text.append("Quick start:\n", style="bold white")
|
|
text.append(" 1. Press ", style="dim")
|
|
text.append("n", style="bold cyan")
|
|
text.append(f", type your {item_label} description\n", style="dim")
|
|
text.append(" 2. Press ", style="dim")
|
|
text.append("g", style="bold cyan")
|
|
text.append(" to run it with an agent\n", style="dim")
|
|
text.append(" 3. Press ", style="dim")
|
|
text.append("s", style="bold cyan")
|
|
text.append(" to chat with the agent\n", style="dim")
|
|
text.append(" 4. Press ", style="dim")
|
|
text.append("3", style="bold cyan")
|
|
text.append(" to see full conversation\n", style="dim")
|
|
return Panel(text, title="Welcome [Focused]" if focused else "Welcome",
|
|
border_style="cyan" if focused else "white")
|
|
|
|
def _render_task(self, task) -> Text:
|
|
runtime = self.state.runtime_for(task.task_id)
|
|
adaptive = adaptive_summary(task.metadata)
|
|
selected = task.task_id == self.state.selected_task_id
|
|
compact = self.state.density_mode == "compact"
|
|
|
|
text = Text()
|
|
title_style = "bold black on #22d3ee" if selected else "bold white"
|
|
meta_style = "black on #22d3ee" if selected else "dim"
|
|
marker = "◆" if selected else "•"
|
|
title = truncate_text(task.title, 28 if compact else 34)
|
|
prefix = f"{task.display_id or task.task_id[:8]} "
|
|
text.append(f"{marker} {prefix}{title}\n", style=title_style)
|
|
|
|
badges = Text(style=meta_style)
|
|
badges += badge(task.status.upper(), status_style(task.status))
|
|
if task.priority:
|
|
badges.append(" ")
|
|
badges += badge(task.priority.upper(), priority_style(task.priority))
|
|
if task.pending_checkpoint:
|
|
badges.append(" ")
|
|
badges += badge("REVIEW", status_style("warn"))
|
|
if runtime and runtime.status not in {"idle", ""}:
|
|
badges.append(" ")
|
|
badges += badge(runtime.status.upper(), status_style(runtime.status))
|
|
if task.linked_task_count:
|
|
badges.append(f" linked:{task.linked_task_count}", style=meta_style)
|
|
text.append_text(badges)
|
|
|
|
meta = Text("\n", style=meta_style)
|
|
owner = truncate_text(task.assigned_to or "unassigned", 18 if compact else 20)
|
|
meta.append(f"{owner}", style=meta_style)
|
|
if runtime and runtime.current_tool:
|
|
meta.append(f" \u2699{truncate_text(runtime.current_tool, 14)}", style=meta_style)
|
|
if runtime and runtime.iteration > 0:
|
|
meta.append(f" iter:{runtime.iteration}", style=meta_style)
|
|
meta.append(f" {humanize_age(task.updated_at)}", style=meta_style)
|
|
text.append_text(meta)
|
|
|
|
if not compact:
|
|
# Latest progress message
|
|
if runtime and runtime.progress_entries:
|
|
last_progress = truncate_text(runtime.progress_entries[-1], 48)
|
|
text.append(f"\n> {last_progress}", style="italic " + meta_style)
|
|
elif adaptive["blocked_reason"]:
|
|
text.append(
|
|
f"\n! {truncate_text(adaptive['blocked_reason'], 48)}",
|
|
style="italic " + meta_style,
|
|
)
|
|
elif task.description:
|
|
text.append(f"\n{truncate_text(task.description, 52)}", style="white" if not selected else "black on #22d3ee")
|
|
adaptive_bits = []
|
|
if adaptive["invalidated"]:
|
|
adaptive_bits.append("invalidated")
|
|
if adaptive["gate_owner"]:
|
|
adaptive_bits.append(f"gate:{truncate_text(adaptive['gate_owner'], 12)}")
|
|
if adaptive["missing_signals"]:
|
|
adaptive_bits.append(
|
|
f"signals:{truncate_text(', '.join(adaptive['missing_signals']), 18)}"
|
|
)
|
|
if adaptive["confidence_label"]:
|
|
adaptive_bits.append(f"confidence:{adaptive['confidence_label']}")
|
|
if adaptive_bits:
|
|
text.append(
|
|
f"\n{truncate_text(' | '.join(adaptive_bits), 52)}",
|
|
style="italic " + meta_style,
|
|
)
|
|
|
|
text.append("\n")
|
|
return text
|