Initial commit

This commit is contained in:
LZH-YS1998
2026-07-01 17:56:31 +08:00
commit d78931979d
731 changed files with 311088 additions and 0 deletions
@@ -0,0 +1,2 @@
"""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
+179
View File
@@ -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
+124
View File
@@ -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