Initial commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
"""Widgets used by the CLI board plugin."""
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Activity and alert pane."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from ..state.models import TaskDetailView
|
||||
from ..state.store import BoardStateStore
|
||||
from .render_utils import badge, format_clock, status_style, truncate_text
|
||||
|
||||
|
||||
class ActivityPaneWidget(Static):
|
||||
"""Render board alerts and recent task activity."""
|
||||
|
||||
def __init__(self, state: BoardStateStore) -> None:
|
||||
super().__init__(id="activity-pane")
|
||||
self.state = state
|
||||
self.detail: TaskDetailView | None = None
|
||||
|
||||
def set_detail(self, detail: TaskDetailView | None) -> None:
|
||||
self.detail = detail
|
||||
self.refresh()
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
rows: list[RenderableType] = []
|
||||
runtime = None
|
||||
if self.detail is not None:
|
||||
runtime = self.state.runtime_for(self.detail.task.task_id)
|
||||
summary = Text()
|
||||
summary.append(self.detail.task.title, style="bold white")
|
||||
summary.append("\n")
|
||||
summary += badge(self.detail.task.status.upper(), status_style(self.detail.task.status))
|
||||
if runtime and runtime.status not in {"idle", ""}:
|
||||
summary.append(" ")
|
||||
summary += badge(runtime.status.upper(), status_style(runtime.status))
|
||||
if runtime and runtime.current_tool:
|
||||
summary.append(f"\nactive tool: {runtime.current_tool}", style="dim")
|
||||
rows.append(summary)
|
||||
|
||||
alerts = self.state.alerts()[:6]
|
||||
if alerts:
|
||||
alert_text = Text("\nBoard alerts\n", style="bold #cbd5e1")
|
||||
for alert in alerts:
|
||||
alert_text += badge(alert.level.upper(), status_style(alert.level))
|
||||
alert_text.append(f" {truncate_text(alert.title, 26)}", style="bold white")
|
||||
alert_text.append(f"\n{truncate_text(alert.message, 76)}\n", style="dim")
|
||||
rows.append(alert_text)
|
||||
|
||||
if runtime and runtime.progress_entries:
|
||||
recent = Text("\nLive runtime tail\n", style="bold #cbd5e1")
|
||||
for entry in runtime.progress_entries[-10:]:
|
||||
recent.append(f"[{format_clock(runtime.updated_at)}] {truncate_text(entry, 76)}\n", style="dim")
|
||||
rows.append(recent)
|
||||
|
||||
if not rows:
|
||||
rows.append(Text("No activity yet.", style="dim"))
|
||||
|
||||
title = "Activity"
|
||||
if self.state.pane_focus == "context" and self.state.context_tab == "activity":
|
||||
title += " [Focused]"
|
||||
return Panel(
|
||||
Group(*rows),
|
||||
title=title,
|
||||
border_style="cyan" if self.state.pane_focus == "context" and self.state.context_tab == "activity" else "white",
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Context dock tab header."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from ..state.store import BoardStateStore
|
||||
|
||||
|
||||
class ContextTabsWidget(Static):
|
||||
"""Render the context dock tab strip."""
|
||||
|
||||
def __init__(self, state: BoardStateStore) -> None:
|
||||
super().__init__(id="context-tabs")
|
||||
self.state = state
|
||||
|
||||
def render(self) -> Text:
|
||||
focused = self.state.pane_focus == "context"
|
||||
session_label = "Runtime Session" if self.state.snapshot.mode == "company" else "Session"
|
||||
tabs = [
|
||||
("detail", "Detail"),
|
||||
("session", session_label),
|
||||
("activity", "Activity"),
|
||||
]
|
||||
text = Text()
|
||||
for tab_id, label in tabs:
|
||||
selected = self.state.context_tab == tab_id
|
||||
style = "bold black on #22d3ee" if selected else "bold #94a3b8"
|
||||
if focused and selected:
|
||||
style = "bold black on #38bdf8"
|
||||
text.append(f" {label} ", style=style)
|
||||
text.append(" ")
|
||||
return text
|
||||
@@ -0,0 +1,408 @@
|
||||
"""Task detail pane with structured checkpoint panels."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from ..state.models import PendingCheckpointView, TaskDetailView
|
||||
from ..state.store import BoardStateStore
|
||||
from .render_utils import adaptive_summary, badge, format_clock, humanize_age, priority_style, status_style, truncate_text
|
||||
|
||||
_RISK_STYLES = {
|
||||
"low": "bold #22c55e",
|
||||
"medium": "bold #f59e0b",
|
||||
"high": "bold #ef4444",
|
||||
"critical": "bold white on #ef4444",
|
||||
}
|
||||
|
||||
_SCOPE_LABELS = {
|
||||
"task_adjustment": "Task Adjustment",
|
||||
"org_mutation": "Org Mutation",
|
||||
}
|
||||
|
||||
_CHANGE_ACTION_STYLES = {
|
||||
"add": ("+ ", "bold #22c55e"),
|
||||
"remove": ("- ", "bold #ef4444"),
|
||||
"replace": ("~ ", "bold #f59e0b"),
|
||||
"update": ("~ ", "bold #f59e0b"),
|
||||
}
|
||||
|
||||
|
||||
def _progress_bar(ratio: float | None, width: int = 10) -> str:
|
||||
"""Render a block progress bar: ████████░░"""
|
||||
try:
|
||||
val = float(ratio if ratio is not None else 0)
|
||||
if math.isnan(val) or math.isinf(val):
|
||||
val = 0.0
|
||||
clamped = max(0.0, min(1.0, val))
|
||||
except (TypeError, ValueError):
|
||||
clamped = 0.0
|
||||
filled = int(clamped * width)
|
||||
return "\u2588" * filled + "\u2591" * (width - filled)
|
||||
|
||||
|
||||
class DetailPaneWidget(Static):
|
||||
"""Render the selected task's metadata and linked executions."""
|
||||
|
||||
def __init__(self, state: BoardStateStore) -> None:
|
||||
super().__init__(id="detail-pane")
|
||||
self.state = state
|
||||
self.detail: TaskDetailView | None = None
|
||||
|
||||
def set_detail(self, detail: TaskDetailView | None) -> None:
|
||||
self.detail = detail
|
||||
self.refresh()
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
focused = self.state.pane_focus == "context" and self.state.context_tab == "detail"
|
||||
company_mode = self.state.snapshot.mode == "company"
|
||||
detail_title = "Work Item Detail" if company_mode else "Task Detail"
|
||||
if self.detail is None:
|
||||
return Panel(
|
||||
Text(
|
||||
"Select a work item to inspect details." if company_mode else "Select a task to inspect details.",
|
||||
style="dim",
|
||||
),
|
||||
title=f"{detail_title} [Focused]" if focused else detail_title,
|
||||
border_style="cyan" if focused else "white",
|
||||
)
|
||||
|
||||
task = self.detail.task
|
||||
runtime = self.state.runtime_for(task.task_id)
|
||||
blocks: list[RenderableType] = [self._render_overview(task, runtime)]
|
||||
|
||||
if self.detail.pending_checkpoint:
|
||||
blocks.append(self._render_checkpoint())
|
||||
if self.detail.linked_executions:
|
||||
blocks.append(self._render_linked())
|
||||
if self.detail.result_content:
|
||||
blocks.append(self._render_section("Latest Result", self.detail.result_content, 560))
|
||||
if self.detail.context_preview:
|
||||
blocks.append(self._render_section("Context Preview", self.detail.context_preview, 560))
|
||||
|
||||
return Panel(
|
||||
Group(*blocks),
|
||||
title=f"{detail_title} [Focused]" if focused else detail_title,
|
||||
border_style="cyan" if focused else "white",
|
||||
)
|
||||
|
||||
def _render_overview(self, task: Any, runtime: Any) -> Text:
|
||||
adaptive = adaptive_summary(task.metadata)
|
||||
header = Text()
|
||||
header.append(f"{task.display_id or task.task_id}\n", style="bold #38bdf8")
|
||||
header.append(f"{task.title}\n", style="bold white")
|
||||
header += badge(task.status.upper(), status_style(task.status))
|
||||
if task.priority:
|
||||
header.append(" ")
|
||||
header += badge(task.priority.upper(), priority_style(task.priority))
|
||||
if task.pending_checkpoint:
|
||||
header.append(" ")
|
||||
header += badge("REVIEW", status_style("warn"))
|
||||
if runtime and runtime.status not in {"idle", ""}:
|
||||
header.append(" ")
|
||||
header += badge(runtime.status.upper(), status_style(runtime.status))
|
||||
header.append("\n")
|
||||
header.append(f"column {task.column_id}", style="dim")
|
||||
header.append(f" updated {humanize_age(task.updated_at)}", style="dim")
|
||||
header.append(f" created {format_clock(task.created_at)}", style="dim")
|
||||
if task.assigned_to:
|
||||
header.append(f"\nowner {task.assigned_to}", style="dim")
|
||||
if task.tags:
|
||||
header.append(f"\ntags {truncate_text(', '.join(task.tags), 70)}", style="dim")
|
||||
if task.dependencies:
|
||||
header.append(f"\ndeps {truncate_text(', '.join(task.dependencies), 70)}", style="dim")
|
||||
if adaptive["state"]:
|
||||
header.append(f"\nadaptive {adaptive['state']}", style="dim")
|
||||
if adaptive["gate_owner"]:
|
||||
header.append(f"\ngate owner {adaptive['gate_owner']}", style="dim")
|
||||
if adaptive["missing_signals"]:
|
||||
header.append(
|
||||
f"\nmissing signals {truncate_text(', '.join(adaptive['missing_signals']), 70)}",
|
||||
style="dim",
|
||||
)
|
||||
if adaptive["confidence_label"]:
|
||||
header.append(f"\nconfidence {adaptive['confidence_label']}", style="dim")
|
||||
if adaptive["blocked_reason"]:
|
||||
header.append(f"\nwaiting {truncate_text(adaptive['blocked_reason'], 120)}", style="dim")
|
||||
if runtime and runtime.current_tool:
|
||||
header.append(f"\nactive tool {runtime.current_tool}", style="dim")
|
||||
if task.description:
|
||||
header.append(f"\n\n{truncate_text(task.description, 420)}", style="white")
|
||||
return header
|
||||
|
||||
# ----- Checkpoint dispatcher -----
|
||||
|
||||
def _render_checkpoint(self) -> RenderableType:
|
||||
checkpoint = self.detail.pending_checkpoint
|
||||
assert checkpoint is not None
|
||||
cp_type = checkpoint.checkpoint_type.strip().lower()
|
||||
if cp_type == "company_staffing_selection":
|
||||
return self._render_staffing_checkpoint(checkpoint)
|
||||
if cp_type == "company_recruitment_confirmation":
|
||||
return self._render_recruitment_checkpoint(checkpoint)
|
||||
if cp_type == "company_reorg_pending":
|
||||
return self._render_reorg_checkpoint(checkpoint)
|
||||
if cp_type == "human_escalation":
|
||||
return self._render_escalation_checkpoint(checkpoint)
|
||||
return self._render_generic_checkpoint(checkpoint)
|
||||
|
||||
# ----- Recruitment -----
|
||||
|
||||
def _render_staffing_checkpoint(self, checkpoint: PendingCheckpointView) -> RenderableType:
|
||||
payload = checkpoint.payload
|
||||
roles = payload.get("staffing_roles", []) or []
|
||||
pool = payload.get("staffing_pool", {}) or {}
|
||||
employees = {
|
||||
str(item.get("employee_id", "") or ""): item
|
||||
for item in list(pool.get("employees", []) or [])
|
||||
if str(item.get("employee_id", "") or "")
|
||||
}
|
||||
profile = payload.get("company_profile", "")
|
||||
|
||||
header = Text()
|
||||
header.append("MANUAL STAFFING", style="bold #22c55e")
|
||||
header.append(" ")
|
||||
header += badge("PENDING", status_style("warn"))
|
||||
if profile:
|
||||
header.append(f" {profile}", style="dim italic")
|
||||
|
||||
parts: list[RenderableType] = [header]
|
||||
for index, role in enumerate(roles, start=1):
|
||||
role_id = str(role.get("role_id", "") or "?")
|
||||
role_label = str(role.get("role_label", "") or role_id)
|
||||
selection = role.get("default_selection", {}) or {}
|
||||
text = Text(f"\n{index}. ", style="bold white")
|
||||
text.append(role_id, style="bold #38bdf8")
|
||||
if role_label and role_label != role_id:
|
||||
text.append(f" {role_label}", style="dim")
|
||||
if selection.get("kind") == "employee":
|
||||
employee_id = str(selection.get("employee_id") or selection.get("id") or "")
|
||||
employee = employees.get(employee_id, {})
|
||||
name = employee.get("employee_name") or employee_id
|
||||
text.append(f"\n default: {name}", style="bold white")
|
||||
text.append(f" ({employee_id})", style="dim")
|
||||
else:
|
||||
text.append("\n default: fallback role-only", style="dim")
|
||||
parts.append(text)
|
||||
parts.append(Text("\n[a] Approve defaults [r] Auto Recruit [d] Deny", style="dim italic"))
|
||||
return Panel(Group(*parts), title="Checkpoint: Manual Staffing", border_style="#22c55e")
|
||||
|
||||
def _render_recruitment_checkpoint(self, checkpoint: PendingCheckpointView) -> RenderableType:
|
||||
payload = checkpoint.payload
|
||||
plan = payload.get("recruitment_plan", {})
|
||||
proposals = plan.get("proposals", [])
|
||||
summary_text = plan.get("summary", "") or checkpoint.summary
|
||||
profile = plan.get("company_profile", "")
|
||||
|
||||
header = Text()
|
||||
header.append("RECRUITMENT", style="bold #fbbf24")
|
||||
header.append(" ")
|
||||
header += badge("PENDING", status_style("warn"))
|
||||
if profile:
|
||||
header.append(f" {profile}", style="dim italic")
|
||||
|
||||
parts: list[RenderableType] = [header]
|
||||
|
||||
if summary_text:
|
||||
parts.append(Text(f"\n{truncate_text(summary_text, 280)}", style="white"))
|
||||
|
||||
for i, proposal in enumerate(proposals):
|
||||
parts.append(self._render_proposal(i + 1, proposal))
|
||||
|
||||
parts.append(Text("\n[a] Approve [d] Deny [e] Feedback", style="dim italic"))
|
||||
|
||||
return Panel(Group(*parts), title="Checkpoint: Recruitment", border_style="#fbbf24")
|
||||
|
||||
def _render_proposal(self, index: int, proposal: dict[str, Any]) -> Text:
|
||||
role_id = proposal.get("role_id") or "?"
|
||||
status = proposal.get("status") or ""
|
||||
status_label = {"proposed_hire": "New Hire", "existing_staff": "Existing"}.get(status, status or "Fallback")
|
||||
role_labels = proposal.get("role_labels") or []
|
||||
|
||||
text = Text(f"\n{index}. ", style="bold white")
|
||||
text.append(role_id, style="bold #38bdf8")
|
||||
text.append(" ")
|
||||
text += badge(status_label, "bold black on #64748b")
|
||||
|
||||
if role_labels:
|
||||
text.append(f"\n roles: {', '.join(str(label) for label in role_labels)}", style="dim")
|
||||
|
||||
candidate = proposal.get("candidate")
|
||||
if candidate and isinstance(candidate, dict):
|
||||
name = candidate.get("proposed_employee_name") or candidate.get("template_name") or "unnamed"
|
||||
category = candidate.get("category") or ""
|
||||
domains = candidate.get("domains") or []
|
||||
rationale = candidate.get("rationale") or ""
|
||||
text.append(f"\n {name}", style="bold white")
|
||||
if category:
|
||||
text.append(f" [{category}]", style="dim")
|
||||
if domains:
|
||||
text.append(f"\n domains: {', '.join(str(d) for d in domains[:6])}", style="#a78bfa")
|
||||
if rationale:
|
||||
text.append(f"\n {truncate_text(str(rationale), 120)}", style="dim italic")
|
||||
|
||||
existing = proposal.get("existing_employee")
|
||||
if existing and isinstance(existing, dict):
|
||||
emp_name = existing.get("employee_name") or "?"
|
||||
emp_id = existing.get("employee_id") or ""
|
||||
score_raw = existing.get("experience_score")
|
||||
score = float(score_raw) if score_raw is not None else 0.0
|
||||
domains = existing.get("domains") or []
|
||||
rationale = existing.get("rationale") or ""
|
||||
text.append(f"\n {emp_name}", style="bold white")
|
||||
if emp_id:
|
||||
text.append(f" ({emp_id})", style="dim")
|
||||
text.append(f"\n score: {_progress_bar(score)} {int(score * 100)}%", style="#22c55e")
|
||||
if domains:
|
||||
text.append(f"\n domains: {', '.join(str(d) for d in domains[:6])}", style="#a78bfa")
|
||||
if rationale:
|
||||
text.append(f"\n {truncate_text(str(rationale), 120)}", style="dim italic")
|
||||
|
||||
top_rationale = proposal.get("rationale") or ""
|
||||
if top_rationale and not candidate and not existing:
|
||||
text.append(f"\n {truncate_text(str(top_rationale), 120)}", style="dim italic")
|
||||
|
||||
return text
|
||||
|
||||
# ----- Reorg -----
|
||||
|
||||
def _render_reorg_checkpoint(self, checkpoint: PendingCheckpointView) -> RenderableType:
|
||||
payload = checkpoint.payload
|
||||
title = payload.get("title", "") or "Company Reorg"
|
||||
scope = payload.get("scope", "org_mutation")
|
||||
risk = payload.get("risk_level", "medium")
|
||||
summary = payload.get("summary", "") or checkpoint.summary
|
||||
rationale = payload.get("rationale", "")
|
||||
role_changes = payload.get("role_changes", [])
|
||||
impact = payload.get("impact_summary", {})
|
||||
|
||||
header = Text()
|
||||
header.append("REORG", style="bold #fbbf24")
|
||||
header.append(" ")
|
||||
scope_label = _SCOPE_LABELS.get(scope, scope)
|
||||
header += badge(scope_label, "bold black on #64748b")
|
||||
header.append(" Risk: ", style="dim")
|
||||
risk_style = _RISK_STYLES.get(risk, "bold #f59e0b")
|
||||
header.append(f"\u25a0 {risk.upper()}", style=risk_style)
|
||||
|
||||
parts: list[RenderableType] = [header]
|
||||
|
||||
if summary:
|
||||
parts.append(Text(f"\n{truncate_text(summary, 280)}", style="white"))
|
||||
if rationale:
|
||||
parts.append(Text(f"\n{truncate_text(rationale, 200)}", style="dim italic"))
|
||||
|
||||
if role_changes:
|
||||
changes_text = Text("\nRole Changes:", style="bold #cbd5e1")
|
||||
for rc in role_changes[:8]:
|
||||
action = rc.get("action", "?")
|
||||
prefix, style = _CHANGE_ACTION_STYLES.get(action, ("? ", "bold white"))
|
||||
changes_text.append(f"\n {prefix}", style=style)
|
||||
changes_text.append(rc.get("role_id", "?"), style="bold white")
|
||||
replacement = rc.get("replacement_role_id", "")
|
||||
if replacement:
|
||||
changes_text.append(f" \u2192 {replacement}", style="dim")
|
||||
reason = rc.get("reason", "")
|
||||
if reason:
|
||||
changes_text.append(f" {truncate_text(reason, 50)}", style="dim italic")
|
||||
parts.append(changes_text)
|
||||
|
||||
if impact:
|
||||
impact_text = Text("\nImpact: ", style="dim")
|
||||
impact_parts = []
|
||||
if impact.get("affected_roles") is not None:
|
||||
impact_parts.append(f"{impact['affected_roles']} roles")
|
||||
if impact.get("affected_tasks") is not None:
|
||||
impact_parts.append(f"{impact['affected_tasks']} tasks")
|
||||
if impact.get("migration_count") is not None:
|
||||
impact_parts.append(f"{impact['migration_count']} migrations")
|
||||
if impact_parts:
|
||||
impact_text.append(", ".join(impact_parts), style="white")
|
||||
parts.append(impact_text)
|
||||
|
||||
parts.append(Text("\n[a] Approve [d] Deny [e] Feedback", style="dim italic"))
|
||||
|
||||
return Panel(Group(*parts), title=f"Checkpoint: {title}", border_style="#fbbf24")
|
||||
|
||||
# ----- Escalation -----
|
||||
|
||||
def _render_escalation_checkpoint(self, checkpoint: PendingCheckpointView) -> RenderableType:
|
||||
payload = checkpoint.payload
|
||||
prompt = payload.get("prompt", "") or payload.get("summary", "") or checkpoint.prompt
|
||||
escalation_type = payload.get("escalation_type", "decision_needed")
|
||||
options = payload.get("options", [])
|
||||
default_action = payload.get("default_action", "")
|
||||
|
||||
lines = [line.strip() for line in prompt.split("\n") if line.strip()]
|
||||
title = (lines[0].lstrip("[").split("]", 1)[-1].strip() if lines else "Action Required") or "Action Required"
|
||||
detail_lines = lines[1:] if len(lines) > 1 else []
|
||||
|
||||
header = Text()
|
||||
header.append("ESCALATION", style="bold #fbbf24")
|
||||
header.append(" ")
|
||||
type_label = escalation_type.replace("_", " ")
|
||||
header += badge(type_label, "bold black on #64748b")
|
||||
|
||||
parts: list[RenderableType] = [header]
|
||||
|
||||
if detail_lines:
|
||||
body = Text()
|
||||
for line in detail_lines[:10]:
|
||||
body.append(f"\n{truncate_text(line, 200)}", style="white")
|
||||
parts.append(body)
|
||||
elif not detail_lines and title != "Action Required":
|
||||
parts.append(Text(f"\n{truncate_text(title, 200)}", style="white"))
|
||||
|
||||
if options:
|
||||
opts_text = Text("\nOptions:", style="bold #cbd5e1")
|
||||
for opt in options:
|
||||
opt_id = opt.get("id", "") if isinstance(opt, dict) else str(opt)
|
||||
opt_label = opt.get("label", opt_id) if isinstance(opt, dict) else str(opt)
|
||||
opts_text.append(f"\n \u25c6 {opt_label}", style="white")
|
||||
parts.append(opts_text)
|
||||
|
||||
if default_action:
|
||||
parts.append(Text(f"\nDefault on timeout: {default_action}", style="dim italic"))
|
||||
|
||||
parts.append(Text("\n[a] Approve [d] Deny [e] Feedback", style="dim italic"))
|
||||
|
||||
return Panel(Group(*parts), title=f"Checkpoint: {title[:40]}", border_style="#fbbf24")
|
||||
|
||||
# ----- Generic fallback -----
|
||||
|
||||
def _render_generic_checkpoint(self, checkpoint: PendingCheckpointView) -> RenderableType:
|
||||
section = Text()
|
||||
section.append(checkpoint.checkpoint_type.upper(), style="bold #fbbf24")
|
||||
section.append(" ")
|
||||
section += badge("PENDING", status_style("warn"))
|
||||
if checkpoint.summary:
|
||||
section.append(f"\n{truncate_text(checkpoint.summary, 120)}", style="white")
|
||||
section.append(f"\n{truncate_text(checkpoint.prompt, 420)}", style="dim")
|
||||
section.append("\n[a] Approve [d] Deny [e] Feedback", style="dim italic")
|
||||
return Panel(section, title="Checkpoint", border_style="#fbbf24")
|
||||
|
||||
# ----- Shared renderers -----
|
||||
|
||||
def _render_linked(self) -> Text:
|
||||
title = "Linked Execution Turns" if self.state.snapshot.mode == "company" else "Linked Executions"
|
||||
linked_text = Text(f"\n{title}\n", style="bold #cbd5e1")
|
||||
for linked in self.detail.linked_executions[:8]:
|
||||
linked_text.append(f"\u2022 {truncate_text(linked.title, 40)}", style="white")
|
||||
linked_text.append(f" {linked.status}", style=status_style(linked.status))
|
||||
linked_text.append(f" {humanize_age(linked.updated_at)}", style="dim")
|
||||
if linked.assigned_to:
|
||||
linked_text.append(f" {truncate_text(linked.assigned_to, 16)}", style="dim")
|
||||
linked_text.append("\n")
|
||||
return linked_text
|
||||
|
||||
@staticmethod
|
||||
def _render_section(title: str, body: str, limit: int) -> Text:
|
||||
section = Text(f"\n{title}\n", style="bold #cbd5e1")
|
||||
section.append(truncate_text(body, limit), style="white")
|
||||
return section
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Chat-focused task view — full conversation flow with action hints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from ..state.models import TaskDetailView
|
||||
from ..state.store import BoardStateStore
|
||||
from .render_utils import badge, format_clock, humanize_age, status_style, truncate_text
|
||||
|
||||
|
||||
class FocusTaskWidget(Static):
|
||||
"""Render a chat-centric view for the selected task."""
|
||||
|
||||
def __init__(self, state: BoardStateStore) -> None:
|
||||
super().__init__(id="focus-view")
|
||||
self.state = state
|
||||
self.detail: TaskDetailView | None = None
|
||||
|
||||
def set_detail(self, detail: TaskDetailView | None) -> None:
|
||||
self.detail = detail
|
||||
self.refresh()
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
focused = self.state.pane_focus == "main" and self.state.view_mode == "focus"
|
||||
item_label = "work item" if self.state.snapshot.mode == "company" else "task"
|
||||
if self.detail is None:
|
||||
guide = Text()
|
||||
guide.append(f"Select a {item_label} and press ", style="dim")
|
||||
guide.append("3", style="bold cyan")
|
||||
guide.append(" to open chat view.\n\n", style="dim")
|
||||
guide.append("Or press ", style="dim")
|
||||
guide.append("n", style="bold cyan")
|
||||
guide.append(f" to create a new {item_label}.", style="dim")
|
||||
return Panel(guide, title="Chat View", border_style="cyan" if focused else "white")
|
||||
|
||||
task = self.detail.task
|
||||
runtime = self.state.runtime_for(task.task_id)
|
||||
blocks: list[RenderableType] = []
|
||||
|
||||
# ── Header: title + status + action hints (always visible) ──
|
||||
blocks.append(self._render_header(task, runtime))
|
||||
blocks.append(self._render_action_hints(task))
|
||||
|
||||
# ── Conversation flow (main content) ──
|
||||
blocks.append(self._render_conversation())
|
||||
|
||||
# ── Live progress tail ──
|
||||
blocks.append(self._render_progress(runtime))
|
||||
|
||||
# ── Panel title includes task info ──
|
||||
status_sym = {
|
||||
"done": "\u2713", "running": "\u25cf", "idle": "\u25cf",
|
||||
"pending": "\u25cb", "failed": "\u2717", "cancelled": "\u2717",
|
||||
"blocked": "\u25a0",
|
||||
}.get(task.status, "\u25cb")
|
||||
title_text = f"Chat: {truncate_text(task.title, 36)} {status_sym} {task.status}"
|
||||
if runtime and runtime.current_tool:
|
||||
title_text += f" \u2699{truncate_text(runtime.current_tool, 16)}"
|
||||
|
||||
return Panel(
|
||||
Group(*blocks),
|
||||
title=title_text,
|
||||
border_style="cyan" if focused else "white",
|
||||
)
|
||||
|
||||
def _render_header(self, task: object, runtime: object) -> Text:
|
||||
text = Text()
|
||||
text.append(f"{getattr(task, 'display_id', '') or getattr(task, 'task_id', '')[:8]}", style="bold #38bdf8")
|
||||
text.append(f" {getattr(task, 'title', '')}", style="bold white")
|
||||
if getattr(task, 'assigned_to', ''):
|
||||
text.append(f" \u2022 {task.assigned_to}", style="dim")
|
||||
text.append(f" {humanize_age(getattr(task, 'updated_at', 0))}", style="dim")
|
||||
|
||||
# Badges on second line
|
||||
text.append("\n")
|
||||
text += badge(getattr(task, 'status', '').upper(), status_style(getattr(task, 'status', '')))
|
||||
if getattr(task, 'pending_checkpoint', None):
|
||||
text.append(" ")
|
||||
text += badge("REVIEW", status_style("warn"))
|
||||
if runtime and getattr(runtime, 'iteration', 0) > 0:
|
||||
text.append(f" iter:{runtime.iteration}", style="dim")
|
||||
text.append("\n")
|
||||
return text
|
||||
|
||||
def _render_conversation(self) -> Text:
|
||||
text = Text()
|
||||
transcript = self.detail.transcript if self.detail else []
|
||||
|
||||
if not transcript:
|
||||
text.append("\nNo conversation yet.\n", style="dim")
|
||||
text.append("Press ", style="dim")
|
||||
text.append("g", style="bold cyan")
|
||||
text.append(" to run this work item, or " if self.state.snapshot.mode == "company" else " to run this task, or ", style="dim")
|
||||
text.append("s", style="bold cyan")
|
||||
text.append(" to send a message.\n", style="dim")
|
||||
return text
|
||||
|
||||
text.append("\n")
|
||||
for msg in transcript[-20:]:
|
||||
timestamp = format_clock(msg.created_at)
|
||||
role = msg.role
|
||||
sender = msg.sender_name
|
||||
|
||||
# Role-based styling
|
||||
if role == "user":
|
||||
name_style = "bold #38bdf8"
|
||||
elif role in {"assistant", "subagent"}:
|
||||
name_style = "bold #22c55e"
|
||||
elif role == "system":
|
||||
name_style = "bold #f59e0b"
|
||||
else:
|
||||
name_style = "bold white"
|
||||
|
||||
text.append(f" [{timestamp}] ", style="dim")
|
||||
text.append(sender, style=name_style)
|
||||
text.append("\n")
|
||||
|
||||
# Message content — show full text, let terminal wrap naturally
|
||||
content = msg.content.strip()
|
||||
lines = content.split("\n")
|
||||
for line in lines[:30]:
|
||||
text.append(f" {line}\n", style="white")
|
||||
if len(lines) > 30:
|
||||
text.append(f" \u2026 ({len(lines) - 30} more lines)\n", style="dim")
|
||||
text.append("\n")
|
||||
|
||||
return text
|
||||
|
||||
def _render_progress(self, runtime: object) -> Text:
|
||||
text = Text()
|
||||
entries = list(self.detail.progress_entries) if self.detail else []
|
||||
if runtime and getattr(runtime, "progress_entries", None):
|
||||
entries.extend(runtime.progress_entries[-6:])
|
||||
|
||||
if not entries:
|
||||
return text
|
||||
|
||||
text.append("\u2500" * 50 + "\n", style="dim")
|
||||
for entry in entries[-6:]:
|
||||
text.append(f" {truncate_text(entry, 90)}\n", style="dim italic")
|
||||
|
||||
return text
|
||||
|
||||
def _render_action_hints(self, task: object) -> Text:
|
||||
text = Text("\n")
|
||||
has_checkpoint = getattr(task, "pending_checkpoint", None) is not None
|
||||
is_terminal = getattr(task, "status", "") in {"done", "failed", "cancelled"}
|
||||
|
||||
text.append("s", style="bold cyan")
|
||||
text.append(" reply", style="dim")
|
||||
|
||||
if has_checkpoint:
|
||||
text.append(" \u2502 ", style="dim")
|
||||
text.append("\u26a1 ", style="bold #f59e0b")
|
||||
text.append("a", style="bold #22c55e")
|
||||
text.append(" approve ", style="dim")
|
||||
text.append("d", style="bold #ef4444")
|
||||
text.append(" deny ", style="dim")
|
||||
text.append("e", style="bold cyan")
|
||||
text.append(" feedback", style="dim")
|
||||
|
||||
if not is_terminal:
|
||||
text.append(" \u2502 ", style="dim")
|
||||
text.append("g", style="bold cyan")
|
||||
text.append(" run", style="dim")
|
||||
else:
|
||||
text.append(" \u2502 ", style="dim")
|
||||
text.append("t", style="bold cyan")
|
||||
text.append(" retry", style="dim")
|
||||
|
||||
text.append(" \u2502 ", style="dim")
|
||||
text.append("1", style="bold cyan")
|
||||
text.append(" board", style="dim")
|
||||
|
||||
return text
|
||||
@@ -0,0 +1,171 @@
|
||||
"""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
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Top metrics bar for the CLI board."""
|
||||
|
||||
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 ..state.store import BoardStateStore
|
||||
from .render_utils import badge, format_clock, humanize_age, priority_style, status_style, truncate_text
|
||||
|
||||
|
||||
class MetricsBarWidget(Static):
|
||||
"""Show board-wide health, activity, and viewport state."""
|
||||
|
||||
def __init__(self, state: BoardStateStore, *, exec_mode: str = "task", company_profile: str = "corporate") -> None:
|
||||
super().__init__(id="metrics-bar")
|
||||
self.state = state
|
||||
self.exec_mode = exec_mode
|
||||
self.company_profile = company_profile
|
||||
self.pipeline_done: int = 0
|
||||
self.pipeline_total: int = 0
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
metrics = self.state.metrics()
|
||||
selected = self.state.selected_task()
|
||||
alerts = self.state.alerts()[:3]
|
||||
item_label = "work item" if self.state.snapshot.mode == "company" else "task"
|
||||
|
||||
board_text = Text()
|
||||
board_text.append(f"{self.state.snapshot.project_id}\n", style="bold white")
|
||||
board_text.append("view ", style="dim")
|
||||
board_text.append(self.state.view_mode.upper(), style="bold cyan")
|
||||
board_text.append(" focus ", style="dim")
|
||||
board_text.append(self.state.pane_focus, style="bold white")
|
||||
board_text.append(" density ", style="dim")
|
||||
board_text.append(self.state.density_mode, style="bold magenta")
|
||||
board_text.append("\nmode ", style="dim")
|
||||
board_text.append(self.exec_mode, style="bold #22c55e")
|
||||
board_text.append("/", style="dim")
|
||||
board_text.append(self.company_profile, style="bold #22c55e")
|
||||
if self.state.search_query:
|
||||
board_text.append("\nfilter ", style="dim")
|
||||
board_text.append(truncate_text(self.state.search_query, 24), style="yellow")
|
||||
|
||||
flow_text = Text()
|
||||
flow_text.append("todo ", style="dim")
|
||||
flow_text.append(str(metrics.todo_count), style="bold white")
|
||||
flow_text.append(" active ", style="dim")
|
||||
flow_text.append(str(metrics.in_progress_count), style="bold #22c55e")
|
||||
flow_text.append(" done ", style="dim")
|
||||
flow_text.append(str(metrics.done_count), style="bold #10b981")
|
||||
flow_text.append("\nrun ", style="dim")
|
||||
flow_text.append(str(metrics.running_count), style="bold #38bdf8")
|
||||
flow_text.append(" chk ", style="dim")
|
||||
flow_text.append(str(metrics.pending_checkpoint_count), style="bold #f59e0b")
|
||||
if self.pipeline_total > 0:
|
||||
ratio = min(1.0, self.pipeline_done / self.pipeline_total)
|
||||
bar_w = 8
|
||||
filled = int(ratio * bar_w)
|
||||
flow_text.append("\nproj ", style="dim")
|
||||
flow_text.append("\u2588" * filled, style="bold #22c55e")
|
||||
flow_text.append("\u2591" * (bar_w - filled), style="dim")
|
||||
flow_text.append(f" {self.pipeline_done}/{self.pipeline_total}", style="white")
|
||||
|
||||
health_text = Text()
|
||||
health_text.append("last refresh ", style="dim")
|
||||
health_text.append(format_clock(metrics.last_refreshed_at), style="bold white")
|
||||
health_text.append(" stale ", style="dim")
|
||||
health_text.append(str(metrics.stale_task_count), style="bold yellow")
|
||||
if metrics.last_runtime_update:
|
||||
health_text.append("\nruntime heartbeat ", style="dim")
|
||||
health_text.append(humanize_age(metrics.last_runtime_update), style="bold #22c55e")
|
||||
else:
|
||||
health_text.append("\nruntime heartbeat ", style="dim")
|
||||
health_text.append("idle", style="bold #64748b")
|
||||
|
||||
if alerts:
|
||||
alert_lines: list[Text] = []
|
||||
for alert in alerts:
|
||||
line = Text()
|
||||
line += badge(alert.level.upper(), status_style(alert.level))
|
||||
line.append(f" {truncate_text(alert.title, 18)}", style="bold white")
|
||||
line.append(f"\n{truncate_text(alert.message, 42)}", style="dim")
|
||||
alert_lines.append(line)
|
||||
attention_renderable: RenderableType = Group(*alert_lines)
|
||||
else:
|
||||
attention_renderable = Text("No active alerts.\nBoard health looks stable.", style="dim")
|
||||
|
||||
selection_text = Text()
|
||||
if selected is None:
|
||||
selection_text.append(
|
||||
f"No {item_label} selected.\nUse the session rail or board to pick a {item_label}.",
|
||||
style="dim",
|
||||
)
|
||||
else:
|
||||
selection_text.append(f"{truncate_text(selected.title, 28)}\n", style="bold white")
|
||||
selection_text += badge(selected.status.upper(), status_style(selected.status))
|
||||
if selected.priority:
|
||||
selection_text.append(" ")
|
||||
selection_text += badge(selected.priority.upper(), priority_style(selected.priority))
|
||||
if selected.pending_checkpoint:
|
||||
selection_text.append(" ")
|
||||
selection_text += badge("REVIEW", status_style("warn"))
|
||||
runtime = self.state.runtime_for(selected.task_id)
|
||||
if runtime and runtime.current_tool:
|
||||
selection_text.append(f"\n\u2699{truncate_text(runtime.current_tool, 16)}", style="dim")
|
||||
selection_text.append(f" {humanize_age(selected.updated_at)}", style="dim")
|
||||
|
||||
panels = [
|
||||
Panel(board_text, title="Board", border_style="cyan"),
|
||||
Panel(flow_text, title="Projection", border_style="green"),
|
||||
Panel(health_text, title="Health", border_style="magenta"),
|
||||
Panel(attention_renderable, title="Attention", border_style="yellow"),
|
||||
Panel(selection_text, title="Selection", border_style="blue"),
|
||||
]
|
||||
return Columns(panels, expand=True, equal=True)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Read-only organisation viewer for the CLI board."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from ..state.models import OrgEmployeeView, OrgRoleView, OrgSnapshotView
|
||||
from ..state.store import BoardStateStore
|
||||
from .render_utils import truncate_text
|
||||
|
||||
|
||||
class OrgViewerWidget(Static):
|
||||
"""Render a read-only org structure: role tree, employees, and work-item projection."""
|
||||
|
||||
def __init__(self, state: BoardStateStore) -> None:
|
||||
super().__init__(id="org-viewer")
|
||||
self.state = state
|
||||
self.org: OrgSnapshotView | None = None
|
||||
|
||||
def set_org(self, org: OrgSnapshotView | None) -> None:
|
||||
self.org = org
|
||||
self.refresh()
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
focused = self.state.pane_focus == "main" and self.state.view_mode == "org"
|
||||
if self.org is None:
|
||||
return Panel(
|
||||
Text("No org data. Press 5 to load organisation view.", style="dim"),
|
||||
title="Organisation [Focused]" if focused else "Organisation",
|
||||
border_style="cyan" if focused else "white",
|
||||
)
|
||||
|
||||
parts: list[RenderableType] = []
|
||||
parts.append(self._render_roles())
|
||||
parts.append(self._render_employees())
|
||||
|
||||
title = "Organisation [Focused]" if focused else "Organisation"
|
||||
return Panel(Group(*parts), title=title, border_style="cyan" if focused else "white")
|
||||
|
||||
# ── Roles ──
|
||||
|
||||
def _render_roles(self) -> Text:
|
||||
org = self.org
|
||||
text = Text()
|
||||
text.append(f"Roles ({org.role_count})\n", style="bold #cbd5e1")
|
||||
|
||||
if not org.role_tree:
|
||||
text.append(" No roles configured.\n", style="dim")
|
||||
return text
|
||||
|
||||
for node in org.role_tree:
|
||||
self._render_role_node(text, node, depth=0)
|
||||
|
||||
return text
|
||||
|
||||
def _render_role_node(self, text: Text, role: OrgRoleView, depth: int) -> None:
|
||||
indent = " " * (depth + 1)
|
||||
connector = ""
|
||||
if depth > 0:
|
||||
connector = "\u251c\u2500 " if True else "\u2514\u2500 " # ├─
|
||||
|
||||
text.append(f"{indent}{connector}", style="dim")
|
||||
text.append(role.role_id, style="bold #38bdf8")
|
||||
resp = truncate_text(role.responsibility, 30)
|
||||
if resp:
|
||||
text.append(f" \"{resp}\"", style="dim")
|
||||
text.append(f" {role.employee_count} emp", style="dim italic")
|
||||
text.append("\n")
|
||||
|
||||
for child in role.children:
|
||||
self._render_role_node(text, child, depth=depth + 1)
|
||||
|
||||
# ── Employees ──
|
||||
|
||||
def _render_employees(self) -> Text:
|
||||
org = self.org
|
||||
text = Text("\n")
|
||||
text.append(f"Employees ({org.employee_count})\n", style="bold #cbd5e1")
|
||||
|
||||
if not org.employees:
|
||||
text.append(" No employees registered.\n", style="dim")
|
||||
return text
|
||||
|
||||
for emp in org.employees[:20]:
|
||||
text.append(f" {truncate_text(emp.name, 14):<14s}", style="bold white")
|
||||
text.append(f" {truncate_text(emp.role_id, 14):<14s}", style="#38bdf8")
|
||||
text.append(f" {emp.seniority:<8s}", style="dim")
|
||||
if emp.domains:
|
||||
text.append(f" [{', '.join(emp.domains[:4])}]", style="#a78bfa")
|
||||
text.append("\n")
|
||||
|
||||
if len(org.employees) > 20:
|
||||
text.append(f" \u2026 +{len(org.employees) - 20} more\n", style="dim")
|
||||
|
||||
return text
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Read-only work-item projection visualisation for company-mode runs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from ..state.models import PipelineSnapshot, PipelineWorkItemView
|
||||
from ..state.store import BoardStateStore
|
||||
from .render_utils import status_style, truncate_text
|
||||
|
||||
_STATUS_SYMBOL = {
|
||||
"done": ("\u2713", "bold #22c55e"), # ✓ green
|
||||
"running": ("\u25cf", "bold #38bdf8"), # ● blue
|
||||
"idle": ("\u25cf", "bold #38bdf8"), # ● blue
|
||||
"pending": ("\u25cb", "dim"), # ○ gray
|
||||
"failed": ("\u2717", "bold #ef4444"), # ✗ red
|
||||
"cancelled": ("\u2717", "bold #9333ea"), # ✗ purple
|
||||
"blocked": ("\u25a0", "bold #f59e0b"), # ■ yellow
|
||||
"awaiting_peer": ("\u25a0", "bold #f59e0b"),
|
||||
"awaiting_review": ("\u25a0", "bold #f59e0b"),
|
||||
}
|
||||
|
||||
|
||||
def _fmt_elapsed(sec: float) -> str:
|
||||
if sec <= 0:
|
||||
return "--"
|
||||
if sec < 60:
|
||||
return f"{int(sec)}s"
|
||||
if sec < 3600:
|
||||
return f"{int(sec // 60)}m"
|
||||
return f"{int(sec // 3600)}h{int((sec % 3600) // 60)}m"
|
||||
|
||||
|
||||
class PipelineViewWidget(Static):
|
||||
"""Render the work-item projection as an ASCII dependency view."""
|
||||
|
||||
def __init__(self, state: BoardStateStore) -> None:
|
||||
super().__init__(id="pipeline-view")
|
||||
self.state = state
|
||||
self.pipeline: PipelineSnapshot | None = None
|
||||
|
||||
def set_pipeline(self, pipeline: PipelineSnapshot | None) -> None:
|
||||
self.pipeline = pipeline
|
||||
self.refresh()
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
focused = self.state.pane_focus == "main" and self.state.view_mode == "pipeline"
|
||||
if self.pipeline is None or not self.pipeline.work_items:
|
||||
return Panel(
|
||||
Text("No work-item projection data. Select a company-mode task and press 4.", style="dim"),
|
||||
title="Projection [Focused]" if focused else "Projection",
|
||||
border_style="cyan" if focused else "white",
|
||||
)
|
||||
|
||||
pipe = self.pipeline
|
||||
parts: list[RenderableType] = []
|
||||
|
||||
# Header
|
||||
header = Text()
|
||||
header.append(truncate_text(pipe.parent_title, 50), style="bold white")
|
||||
if pipe.profile:
|
||||
header.append(f" {pipe.profile}", style="dim italic")
|
||||
parts.append(header)
|
||||
|
||||
# Work-item rows — linear chain rendering
|
||||
parts.append(self._render_work_item_chain(pipe.work_items))
|
||||
|
||||
# Progress footer
|
||||
footer = self._render_footer(pipe)
|
||||
parts.append(footer)
|
||||
|
||||
title = "Projection [Focused]" if focused else "Projection"
|
||||
return Panel(Group(*parts), title=title, border_style="cyan" if focused else "white")
|
||||
|
||||
def _render_work_item_chain(self, work_items: list[PipelineWorkItemView]) -> Text:
|
||||
"""Render work items as a vertical list with dependency arrows.
|
||||
|
||||
Using vertical layout for reliable terminal rendering — horizontal
|
||||
box-drawing DAGs break on narrow terminals.
|
||||
"""
|
||||
text = Text()
|
||||
# Build dependency lookup for rendering connectors
|
||||
projection_ids = {item.projection_id for item in work_items}
|
||||
|
||||
for i, item in enumerate(work_items):
|
||||
sym, sym_style = _STATUS_SYMBOL.get(item.status, ("\u25cb", "dim"))
|
||||
is_selected = (
|
||||
self.state.selected_task_id is not None
|
||||
and item.task_id == self.state.selected_task_id
|
||||
)
|
||||
|
||||
# Connector line from previous work item
|
||||
if i > 0:
|
||||
if item.dependencies:
|
||||
# Show which projections this depends on
|
||||
dep_labels = [d for d in item.dependencies if d in projection_ids]
|
||||
if dep_labels:
|
||||
text.append(f"\n \u2502 after: {', '.join(dep_labels)}", style="dim")
|
||||
text.append("\n \u2502\n \u25bc\n", style="dim")
|
||||
|
||||
# Work-item box — single-line compact rendering
|
||||
border_style = "bold cyan" if is_selected else "dim"
|
||||
text.append(" \u250c\u2500 ", style=border_style)
|
||||
text.append(f"{item.projection_id}", style="bold #38bdf8" if is_selected else "bold white")
|
||||
|
||||
# Parallel group indicator
|
||||
if item.parallel_group:
|
||||
text.append(f" \u2261{item.parallel_group}", style="dim") # ≡
|
||||
|
||||
# Gate indicator
|
||||
if item.has_gate:
|
||||
gate_label = item.gate_type or "gate"
|
||||
text.append(f" \u229e {gate_label}", style="bold #f59e0b") # ⊞
|
||||
|
||||
text.append(" \u2500\u2510\n", style=border_style)
|
||||
|
||||
# Status line
|
||||
text.append(" \u2502 ", style=border_style)
|
||||
text.append(f"{sym} {item.status}", style=sym_style)
|
||||
text.append(f" {truncate_text(item.title, 30)}", style="white")
|
||||
text.append("\n", style="")
|
||||
|
||||
# Detail line: assignee, elapsed, tool
|
||||
detail_parts: list[str] = []
|
||||
if item.assigned_to:
|
||||
detail_parts.append(item.assigned_to)
|
||||
if item.elapsed_sec > 0 and item.status != "pending":
|
||||
detail_parts.append(_fmt_elapsed(item.elapsed_sec))
|
||||
if item.current_tool:
|
||||
detail_parts.append(f"\u2699{item.current_tool}") # ⚙
|
||||
if item.tool_elapsed_ms > 0:
|
||||
detail_parts.append(f"{item.tool_elapsed_ms}ms")
|
||||
if item.context_remaining_pct > 0:
|
||||
detail_parts.append(f"ctx {item.context_remaining_pct}%")
|
||||
if item.turn_cost_usd > 0:
|
||||
detail_parts.append(f"${item.turn_cost_usd:.4f}")
|
||||
|
||||
if detail_parts:
|
||||
text.append(" \u2502 ", style=border_style)
|
||||
text.append(" ".join(detail_parts), style="dim")
|
||||
text.append("\n", style="")
|
||||
|
||||
if item.last_tool_summary:
|
||||
text.append(" \u2502 ", style=border_style)
|
||||
text.append(truncate_text(item.last_tool_summary, 44), style="dim")
|
||||
text.append("\n", style="")
|
||||
|
||||
text.append(" \u2514", style=border_style)
|
||||
text.append("\u2500" * 40, style=border_style)
|
||||
text.append("\u2518\n", style=border_style)
|
||||
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def _render_footer(pipe: PipelineSnapshot) -> Text:
|
||||
text = Text("\n")
|
||||
# Progress bar
|
||||
ratio = min(1.0, pipe.done_count / pipe.total_count) if pipe.total_count > 0 else 0.0
|
||||
bar_width = 20
|
||||
filled = int(ratio * bar_width)
|
||||
text.append("Progress: ", style="dim")
|
||||
text.append("\u2588" * filled, style="bold #22c55e")
|
||||
text.append("\u2591" * (bar_width - filled), style="dim")
|
||||
text.append(f" {pipe.done_count}/{pipe.total_count} projected steps", style="white")
|
||||
text.append(f" Elapsed: {_fmt_elapsed(pipe.elapsed_sec)}", style="dim")
|
||||
return text
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Shared render helpers for the CLI board widgets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from rich.text import Text
|
||||
|
||||
STATUS_STYLES = {
|
||||
"pending": "bold black on #64748b",
|
||||
"running": "bold black on #22c55e",
|
||||
"idle": "bold black on #38bdf8",
|
||||
"blocked": "bold black on #f59e0b",
|
||||
"awaiting_peer": "bold black on #f59e0b",
|
||||
"awaiting_review": "bold black on #f59e0b",
|
||||
"done": "bold black on #10b981",
|
||||
"failed": "bold white on #ef4444",
|
||||
"cancelled": "bold white on #9333ea",
|
||||
"reflecting": "bold black on #a78bfa",
|
||||
"tool_active": "bold black on #fb7185",
|
||||
"info": "bold black on #38bdf8",
|
||||
"warn": "bold black on #f59e0b",
|
||||
"error": "bold white on #ef4444",
|
||||
}
|
||||
|
||||
PRIORITY_STYLES = {
|
||||
"urgent": "bold white on #dc2626",
|
||||
"high": "bold black on #fb7185",
|
||||
"medium": "bold black on #fbbf24",
|
||||
"low": "bold black on #60a5fa",
|
||||
}
|
||||
|
||||
ROLE_STYLES = {
|
||||
"user": "bold #38bdf8",
|
||||
"assistant": "bold #22c55e",
|
||||
"system": "bold #f59e0b",
|
||||
"subagent": "bold #c084fc",
|
||||
}
|
||||
|
||||
|
||||
def truncate_text(value: str | None, limit: int) -> str:
|
||||
text = " ".join(str(value or "").split())
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return f"{text[: max(0, limit - 1)].rstrip()}…"
|
||||
|
||||
|
||||
def humanize_age(timestamp: float | None, *, now: float | None = None) -> str:
|
||||
if not timestamp:
|
||||
return "n/a"
|
||||
current = float(now if now is not None else datetime.now().timestamp())
|
||||
seconds = max(0, int(current - float(timestamp)))
|
||||
if seconds < 60:
|
||||
return f"{seconds}s"
|
||||
if seconds < 3600:
|
||||
return f"{seconds // 60}m"
|
||||
if seconds < 86400:
|
||||
return f"{seconds // 3600}h"
|
||||
return f"{seconds // 86400}d"
|
||||
|
||||
|
||||
def format_clock(timestamp: float | None) -> str:
|
||||
if not timestamp:
|
||||
return "--:--"
|
||||
return datetime.fromtimestamp(float(timestamp)).strftime("%H:%M")
|
||||
|
||||
|
||||
def badge(label: str, style: str, *, prefix: str = "") -> Text:
|
||||
text = Text()
|
||||
if prefix:
|
||||
text.append(prefix, style="dim")
|
||||
text.append(f" {label} ", style=style)
|
||||
return text
|
||||
|
||||
|
||||
def status_style(status: str | None) -> str:
|
||||
return STATUS_STYLES.get(str(status or "").strip().lower(), "bold black on #475569")
|
||||
|
||||
|
||||
def priority_style(priority: str | None) -> str:
|
||||
return PRIORITY_STYLES.get(str(priority or "").strip().lower(), "bold black on #475569")
|
||||
|
||||
|
||||
def role_style(role: str | None) -> str:
|
||||
return ROLE_STYLES.get(str(role or "").strip().lower(), "bold white")
|
||||
|
||||
|
||||
def adaptive_summary(metadata: dict[str, Any] | None) -> dict[str, Any]:
|
||||
meta = dict(metadata or {})
|
||||
adaptive = dict(meta.get("adaptive", {}) or {})
|
||||
if not adaptive:
|
||||
return {
|
||||
"state": "",
|
||||
"blocked_reason": "",
|
||||
"gate_owner": "",
|
||||
"missing_signals": [],
|
||||
"confidence_label": "",
|
||||
"invalidated": False,
|
||||
}
|
||||
work_item_profile = dict(adaptive.get("work_item_profile", {}) or {})
|
||||
missing_signals = [
|
||||
str(item.get("name", "") or "").strip()
|
||||
for item in list(adaptive.get("signals", []) or [])
|
||||
if isinstance(item, dict)
|
||||
and bool(item.get("required", True))
|
||||
and not bool(item.get("satisfied", False))
|
||||
and str(item.get("name", "") or "").strip()
|
||||
]
|
||||
confidence = adaptive.get("confidence")
|
||||
try:
|
||||
confidence_value = float(confidence)
|
||||
except (TypeError, ValueError):
|
||||
confidence_value = None
|
||||
return {
|
||||
"state": str(adaptive.get("normalized_state", "") or "").strip(),
|
||||
"blocked_reason": str(adaptive.get("blocked_reason", "") or "").strip(),
|
||||
"gate_owner": str(work_item_profile.get("gate_owner_role_id", "") or "").strip(),
|
||||
"missing_signals": missing_signals,
|
||||
"confidence_label": (
|
||||
f"{round(confidence_value * 100)}%"
|
||||
if confidence_value is not None
|
||||
else ""
|
||||
),
|
||||
"invalidated": str(adaptive.get("normalized_state", "") or "").strip().lower() == "invalidated",
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Session transcript pane."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from ..state.models import TaskDetailView
|
||||
from ..state.store import BoardStateStore
|
||||
from .render_utils import format_clock, role_style, status_style, truncate_text
|
||||
|
||||
|
||||
class SessionPaneWidget(Static):
|
||||
"""Render transcript and live progress for the selected task."""
|
||||
|
||||
def __init__(self, state: BoardStateStore) -> None:
|
||||
super().__init__(id="session-pane")
|
||||
self.state = state
|
||||
self.detail: TaskDetailView | None = None
|
||||
|
||||
def set_detail(self, detail: TaskDetailView | None) -> None:
|
||||
self.detail = detail
|
||||
self.refresh()
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
focused = self.state.pane_focus == "context" and self.state.context_tab == "session"
|
||||
company_mode = self.state.snapshot.mode == "company"
|
||||
title = "Runtime Session" if company_mode else "Session"
|
||||
if self.detail is None:
|
||||
return Panel(
|
||||
Text("No Runtime Session selected." if company_mode else "No session selected.", style="dim"),
|
||||
title=f"{title} [Focused]" if focused else title,
|
||||
border_style="cyan" if focused else "white",
|
||||
)
|
||||
|
||||
messages: list[RenderableType] = []
|
||||
for message in self.detail.transcript[-14:]:
|
||||
block = Text()
|
||||
block.append(f"[{format_clock(message.created_at)}] ", style="dim")
|
||||
block.append(message.sender_name, style=role_style(message.role))
|
||||
block.append("\n")
|
||||
# Show content with natural line breaks — let the panel wrap
|
||||
content = message.content.strip()
|
||||
lines = content.split("\n")
|
||||
for line in lines[:20]:
|
||||
block.append(f"{line}\n", style="white")
|
||||
if len(lines) > 20:
|
||||
block.append(f"\u2026 ({len(lines) - 20} more lines)\n", style="dim")
|
||||
messages.append(block)
|
||||
|
||||
progress_entries = list(self.detail.progress_entries)
|
||||
runtime = self.state.runtime_for(self.detail.task.task_id)
|
||||
if runtime and runtime.progress_entries:
|
||||
progress_entries.extend(runtime.progress_entries[-10:])
|
||||
|
||||
if progress_entries:
|
||||
progress_title = "Execution Progress Timeline" if company_mode else "Progress Timeline"
|
||||
progress = Text(f"\n{progress_title}\n", style="bold #cbd5e1")
|
||||
for entry in progress_entries[-10:]:
|
||||
progress.append(f"• {truncate_text(entry, 90)}\n", style="dim")
|
||||
messages.append(progress)
|
||||
|
||||
if runtime and (
|
||||
runtime.current_tool
|
||||
or runtime.context_window > 0
|
||||
or runtime.turn_cost_usd > 0
|
||||
or runtime.pending_permission_count > 0
|
||||
):
|
||||
tail = Text("\nRuntime\n", style="bold #cbd5e1")
|
||||
tail.append(f"status {runtime.status}", style=status_style(runtime.status))
|
||||
if runtime.current_tool:
|
||||
tail.append(f" tool {runtime.current_tool}", style="dim")
|
||||
if runtime.iteration:
|
||||
tail.append(f" iter {runtime.iteration}", style="dim")
|
||||
if runtime.tool_elapsed_ms > 0:
|
||||
tail.append(f" {runtime.tool_elapsed_ms}ms", style="dim")
|
||||
if runtime.last_tool_summary:
|
||||
tail.append(f"\nsummary {truncate_text(runtime.last_tool_summary, 90)}", style="dim")
|
||||
if runtime.context_window > 0:
|
||||
tail.append(
|
||||
f"\ncontext {runtime.context_tokens}/{runtime.context_window} ({runtime.context_remaining_pct}% left)",
|
||||
style="dim",
|
||||
)
|
||||
if runtime.turn_cost_usd > 0 or runtime.session_cost_usd > 0:
|
||||
tail.append(
|
||||
f"\ncost turn=${runtime.turn_cost_usd:.4f} session=${runtime.session_cost_usd:.4f}",
|
||||
style="dim",
|
||||
)
|
||||
if runtime.pending_permission_count > 0:
|
||||
tail.append(f"\napprovals pending {runtime.pending_permission_count}", style="bold yellow")
|
||||
messages.append(tail)
|
||||
|
||||
if not messages:
|
||||
messages.append(Text("No transcript recorded yet.", style="dim"))
|
||||
|
||||
return Panel(
|
||||
Group(*messages),
|
||||
title=f"{title} [Focused]" if focused else title,
|
||||
border_style="cyan" if focused else "white",
|
||||
)
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Session tree widget for the CLI board.
|
||||
|
||||
Displays tasks in a tree structure where parent tasks are expandable nodes
|
||||
and child work items appear as leaves beneath them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from ..state.models import BoardTaskView, SessionSummaryView
|
||||
from ..state.store import BoardStateStore
|
||||
from .render_utils import badge, humanize_age, priority_style, status_style, truncate_text
|
||||
|
||||
_STATUS_SYMBOL = {
|
||||
"done": ("\u2713", "bold #22c55e"),
|
||||
"running": ("\u25cf", "bold #38bdf8"),
|
||||
"idle": ("\u25cf", "bold #38bdf8"),
|
||||
"pending": ("\u25cb", "dim"),
|
||||
"failed": ("\u2717", "bold #ef4444"),
|
||||
"cancelled": ("\u2717", "bold #9333ea"),
|
||||
"blocked": ("\u25a0", "bold #f59e0b"),
|
||||
"awaiting_peer": ("\u25a0", "bold #f59e0b"),
|
||||
"awaiting_review": ("\u25a0", "bold #f59e0b"),
|
||||
}
|
||||
|
||||
|
||||
class SessionSidebarWidget(Static):
|
||||
"""Render a session tree with parent-child relationships."""
|
||||
|
||||
def __init__(self, state: BoardStateStore) -> None:
|
||||
super().__init__(id="session-sidebar")
|
||||
self.state = state
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
company_mode = self.state.snapshot.mode == "company"
|
||||
title = "Runtime Sessions" if company_mode else "Sessions"
|
||||
if self.state.pane_focus == "session-rail":
|
||||
title += " [Focused]"
|
||||
|
||||
tree = self._build_tree()
|
||||
if not tree:
|
||||
return Panel(
|
||||
Text("No runtime sessions available." if company_mode else "No sessions available.", style="dim"),
|
||||
title=title,
|
||||
border_style="cyan" if self.state.pane_focus == "session-rail" else "white",
|
||||
)
|
||||
|
||||
return Panel(
|
||||
Group(*tree),
|
||||
title=title,
|
||||
border_style="cyan" if self.state.pane_focus == "session-rail" else "white",
|
||||
)
|
||||
|
||||
def _build_tree(self) -> list[RenderableType]:
|
||||
"""Build a tree from tasks, grouping children under parents."""
|
||||
all_tasks = self.state.filtered_tasks()
|
||||
if not all_tasks:
|
||||
return []
|
||||
|
||||
# Separate parent (visible/top-level) tasks from child tasks
|
||||
# Child tasks have origin_task_id pointing to their parent
|
||||
parent_tasks: list[BoardTaskView] = []
|
||||
children_by_parent: dict[str, list[BoardTaskView]] = {}
|
||||
|
||||
# First pass: identify parents and children from the visible task list
|
||||
# Also look at linked executions from the snapshot data
|
||||
visible_ids = {t.task_id for t in all_tasks}
|
||||
|
||||
for task in all_tasks:
|
||||
if task.origin_task_id and task.origin_task_id in visible_ids:
|
||||
children_by_parent.setdefault(task.origin_task_id, []).append(task)
|
||||
else:
|
||||
parent_tasks.append(task)
|
||||
|
||||
# Also pull in hidden linked tasks from snapshot metadata
|
||||
for task in self.state.all_tasks():
|
||||
if task.task_id in visible_ids:
|
||||
continue
|
||||
if task.origin_task_id and task.origin_task_id in visible_ids:
|
||||
children_by_parent.setdefault(task.origin_task_id, []).append(task)
|
||||
|
||||
# Sort parents: live first, then queue, then archive
|
||||
parent_tasks.sort(key=lambda t: (
|
||||
0 if self._is_live(t) else 1 if t.column_id != "done" else 2,
|
||||
-float(t.updated_at),
|
||||
))
|
||||
|
||||
lines: list[RenderableType] = []
|
||||
for parent in parent_tasks:
|
||||
children = children_by_parent.get(parent.task_id, [])
|
||||
children.sort(key=lambda t: (float(t.created_at), t.title))
|
||||
lines.append(self._render_parent(parent, children))
|
||||
|
||||
return lines
|
||||
|
||||
def _render_parent(self, task: BoardTaskView, children: list[BoardTaskView]) -> Text:
|
||||
selected = task.task_id == self.state.selected_task_id
|
||||
runtime = self.state.runtime_for(task.task_id)
|
||||
has_children = bool(children)
|
||||
sym, sym_style = _STATUS_SYMBOL.get(task.status, ("\u25cb", "dim"))
|
||||
|
||||
text = Text()
|
||||
# Expand/collapse indicator
|
||||
if has_children:
|
||||
text.append("\u25bc ", style="bold white") # ▼
|
||||
else:
|
||||
text.append(" ", style="")
|
||||
|
||||
# Selection marker
|
||||
if selected:
|
||||
text.append("\u25c6 ", style="bold cyan") # ◆
|
||||
else:
|
||||
text.append(" ", style="")
|
||||
|
||||
# Status + title
|
||||
text.append(f"{sym} ", style=sym_style)
|
||||
title_style = "bold white" if selected else "white"
|
||||
text.append(truncate_text(task.title, 22), style=title_style)
|
||||
text.append(f" {humanize_age(task.updated_at)}", style="dim")
|
||||
|
||||
# Runtime info
|
||||
if runtime and runtime.current_tool:
|
||||
text.append(f" \u2699{runtime.current_tool}", style="dim")
|
||||
|
||||
# Badges on next line if relevant
|
||||
if task.pending_checkpoint or task.priority:
|
||||
text.append("\n ")
|
||||
if task.priority:
|
||||
text += badge(task.priority.upper(), priority_style(task.priority))
|
||||
text.append(" ")
|
||||
if task.pending_checkpoint:
|
||||
text += badge("REVIEW", status_style("warn"))
|
||||
|
||||
# Children
|
||||
for i, child in enumerate(children[:10]):
|
||||
is_last = i == len(children) - 1 or i == 9
|
||||
text.append("\n")
|
||||
text.append(self._render_child(child, is_last=is_last))
|
||||
|
||||
if len(children) > 10:
|
||||
text.append(f"\n \u2026 +{len(children) - 10} more", style="dim")
|
||||
|
||||
text.append("\n")
|
||||
return text
|
||||
|
||||
def _render_child(self, task: BoardTaskView, *, is_last: bool) -> Text:
|
||||
selected = task.task_id == self.state.selected_task_id
|
||||
runtime = self.state.runtime_for(task.task_id)
|
||||
sym, sym_style = _STATUS_SYMBOL.get(task.status, ("\u25cb", "dim"))
|
||||
|
||||
connector = " \u2514\u2500 " if is_last else " \u251c\u2500 " # └─ or ├─
|
||||
text = Text()
|
||||
text.append(connector, style="dim")
|
||||
|
||||
if selected:
|
||||
text.append(f"{sym} ", style=sym_style)
|
||||
text.append(truncate_text(task.title, 18), style="bold cyan")
|
||||
else:
|
||||
text.append(f"{sym} ", style=sym_style)
|
||||
text.append(truncate_text(task.title, 18), style="white")
|
||||
|
||||
# Assignee
|
||||
if task.assigned_to:
|
||||
text.append(f" {truncate_text(task.assigned_to, 10)}", style="dim")
|
||||
|
||||
# Elapsed
|
||||
text.append(f" {humanize_age(task.updated_at)}", style="dim")
|
||||
|
||||
# Current tool
|
||||
if runtime and runtime.current_tool:
|
||||
text.append(f" \u2699{truncate_text(runtime.current_tool, 12)}", style="dim")
|
||||
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def _is_live(task: BoardTaskView) -> bool:
|
||||
return task.status in {"running", "idle", "blocked", "awaiting_peer", "awaiting_review"} or task.pending_checkpoint is not None
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Bottom status bar — context-aware action guide."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from ..state.store import BoardStateStore
|
||||
from .render_utils import truncate_text
|
||||
|
||||
|
||||
class StatusBarWidget(Static):
|
||||
"""Show available actions based on current state, plus key status info."""
|
||||
|
||||
def __init__(self, state: BoardStateStore) -> None:
|
||||
super().__init__(id="status-bar")
|
||||
self.state = state
|
||||
self.message = ""
|
||||
self.exec_mode = "task"
|
||||
self.company_profile = "corporate"
|
||||
|
||||
def set_message(self, message: str) -> None:
|
||||
self.message = str(message or "").strip()
|
||||
self.refresh()
|
||||
|
||||
def render(self) -> Text:
|
||||
selected = self.state.selected_task()
|
||||
metrics = self.state.metrics()
|
||||
text = Text()
|
||||
|
||||
# Flash message (temporary status, shown prominently)
|
||||
if self.message:
|
||||
text.append(f"{self.message} ", style="bold white")
|
||||
text.append("\u2502 ", style="dim")
|
||||
|
||||
# Context-aware action hints
|
||||
actions = self._build_action_hints(selected)
|
||||
text.append(actions)
|
||||
|
||||
# Separator + key status
|
||||
text.append(" \u2502 ", style="dim")
|
||||
text.append(self.state.view_mode, style="bold cyan")
|
||||
text.append(" ", style="dim")
|
||||
text.append(f"{self.exec_mode}/{self.company_profile}", style="bold #22c55e")
|
||||
|
||||
if metrics.visible_tasks > 0:
|
||||
item_label = "work items" if self.state.snapshot.mode == "company" else "tasks"
|
||||
text.append(f" {metrics.visible_tasks} {item_label}", style="dim")
|
||||
|
||||
if selected:
|
||||
text.append(" \u2502 ", style="dim")
|
||||
text.append(truncate_text(selected.title, 24), style="bold #22c55e")
|
||||
runtime = self.state.runtime_for(selected.task_id)
|
||||
if runtime:
|
||||
if runtime.current_tool:
|
||||
text.append(" \u2502 ", style="dim")
|
||||
text.append(f"\u2699 {truncate_text(runtime.current_tool, 18)}", style="bold #f59e0b")
|
||||
if runtime.context_window > 0:
|
||||
text.append(" \u2502 ", style="dim")
|
||||
text.append(f"ctx {runtime.context_remaining_pct}%", style="bold #38bdf8")
|
||||
if runtime.turn_cost_usd > 0 or runtime.session_cost_usd > 0:
|
||||
text.append(" \u2502 ", style="dim")
|
||||
text.append(
|
||||
f"${runtime.turn_cost_usd:.4f}/${runtime.session_cost_usd:.4f}",
|
||||
style="bold #22c55e",
|
||||
)
|
||||
if runtime.pending_permission_count > 0:
|
||||
text.append(" \u2502 ", style="dim")
|
||||
text.append(f"approvals {runtime.pending_permission_count}", style="bold #f59e0b")
|
||||
|
||||
if self.state.search_query:
|
||||
text.append(" \u2502 ", style="dim")
|
||||
text.append(f"\u2315 {truncate_text(self.state.search_query, 16)}", style="yellow")
|
||||
|
||||
return text
|
||||
|
||||
def _build_action_hints(self, selected: object | None) -> Text:
|
||||
hints = Text()
|
||||
|
||||
if selected is None:
|
||||
# No task selected
|
||||
hints.append("n", style="bold cyan")
|
||||
hints.append(" new ", style="dim")
|
||||
hints.append("/", style="bold cyan")
|
||||
hints.append(" search ", style="dim")
|
||||
hints.append("?", style="bold cyan")
|
||||
hints.append(" help", style="dim")
|
||||
return hints
|
||||
|
||||
# Task selected — show relevant actions
|
||||
task = selected
|
||||
has_checkpoint = getattr(task, "pending_checkpoint", None) is not None
|
||||
status = getattr(task, "status", "")
|
||||
is_terminal = status in {"done", "failed", "cancelled"}
|
||||
|
||||
hints.append("n", style="bold cyan")
|
||||
hints.append(" new ", style="dim")
|
||||
|
||||
if not is_terminal:
|
||||
hints.append("g", style="bold cyan")
|
||||
hints.append(" run ", style="dim")
|
||||
hints.append("s", style="bold cyan")
|
||||
hints.append(" chat ", style="dim")
|
||||
|
||||
if has_checkpoint:
|
||||
hints.append("\u26a1", style="bold #f59e0b")
|
||||
hints.append(" ", style="")
|
||||
hints.append("a", style="bold #22c55e")
|
||||
hints.append("/", style="dim")
|
||||
hints.append("d", style="bold #ef4444")
|
||||
hints.append(" approve/deny ", style="dim")
|
||||
hints.append("e", style="bold cyan")
|
||||
hints.append(" feedback ", style="dim")
|
||||
|
||||
if is_terminal:
|
||||
hints.append("t", style="bold cyan")
|
||||
hints.append(" retry ", style="dim")
|
||||
|
||||
hints.append("3", style="bold cyan")
|
||||
hints.append(" chat view ", style="dim")
|
||||
hints.append("?", style="bold cyan")
|
||||
hints.append(" help", style="dim")
|
||||
|
||||
return hints
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Dense task list view."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from ..state.store import BoardStateStore
|
||||
from .render_utils import humanize_age, priority_style, status_style, truncate_text
|
||||
|
||||
|
||||
class TaskListWidget(Static):
|
||||
"""Render a dense linear task view."""
|
||||
|
||||
def __init__(self, state: BoardStateStore) -> None:
|
||||
super().__init__(id="task-list")
|
||||
self.state = state
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
tasks = self.state.linear_tasks()
|
||||
rows: list[RenderableType] = [self._header_row()]
|
||||
item_label = "work items" if self.state.snapshot.mode == "company" else "tasks"
|
||||
if not tasks:
|
||||
rows.append(Text(f"No {item_label} match the current filter.", style="dim"))
|
||||
for task in tasks:
|
||||
rows.append(self._task_row(task))
|
||||
title = "Work Item List" if self.state.snapshot.mode == "company" else "Task List"
|
||||
if self.state.pane_focus == "main":
|
||||
title += " [Focused]"
|
||||
return Panel(Group(*rows), title=title, border_style="cyan" if self.state.pane_focus == "main" else "white")
|
||||
|
||||
def _header_row(self) -> Text:
|
||||
row = Text(style="bold #94a3b8")
|
||||
row.append(("WORK ITEM" if self.state.snapshot.mode == "company" else "TASK").ljust(10))
|
||||
row.append("TITLE".ljust(30))
|
||||
row.append("STATUS".ljust(16))
|
||||
row.append("OWNER".ljust(16))
|
||||
row.append("AGE")
|
||||
return row
|
||||
|
||||
def _task_row(self, task) -> Text:
|
||||
selected = task.task_id == self.state.selected_task_id
|
||||
row = Text(style="black on #22d3ee" if selected else "white")
|
||||
row.append((task.display_id or task.task_id[:8]).ljust(10), style="bold")
|
||||
row.append(truncate_text(task.title, 28).ljust(30))
|
||||
row.append(task.status[:14].upper().ljust(16), style=status_style(task.status))
|
||||
row.append(truncate_text(task.assigned_to or "-", 14).ljust(16), style="dim" if not selected else "black on #22d3ee")
|
||||
row.append(humanize_age(task.updated_at), style="dim" if not selected else "black on #22d3ee")
|
||||
if task.priority:
|
||||
row.append(" ")
|
||||
row.append(task.priority[0].upper(), style=priority_style(task.priority))
|
||||
if task.pending_checkpoint:
|
||||
row.append(" !", style=status_style("warn"))
|
||||
return row
|
||||
Reference in New Issue
Block a user