Files
2026-07-01 17:56:31 +08:00

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