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
+68
View File
@@ -0,0 +1,68 @@
# OpenOPC CLI Board
`opc board` provides a full-screen terminal command center for OpenOPC.
## Install
The board uses the optional `textual` dependency.
```bash
pip install "opc[cli-board]"
```
## Run
```bash
opc board
opc board --project demo
opc board --project demo --refresh-interval 1.5
```
## Interaction Model
- The board is a session-first TUI: one visible card maps to one user-facing task/session.
- The layout is split into `Session Rail | Main Viewport | Context Dock`, with a top metrics bar and a bottom status bar.
- The main viewport supports `kanban`, `list`, and `focus` modes so you can switch between orchestration, scanning, and deep inspection.
- Engine-created internal tasks that point back to an origin task via `metadata.origin_task_id` are hidden from the main board and surfaced under the selected task's "linked executions" detail section.
- In-process updates come from `EventBus` plus `engine.on_progress`.
- Cross-process consistency comes from a periodic reconcile loop that reloads the authoritative SQLite task/session data.
## Main UI Regions
- `Metrics Bar`: board health, pipeline counts, alerts, active selection, filter state.
- `Session Rail`: recent live sessions, queued work, archived sessions, checkpoint badges.
- `Main Viewport`: kanban board, dense task list, or focused task console.
- `Context Dock`: `Detail`, `Session`, and `Activity` tabs.
- `Status Bar`: current mode, pane focus, selection, and quick key reminders.
## Key Bindings
- Arrow keys / `h` `j` `k` `l`: move selection
- `Tab` / `Shift+Tab`: cycle pane focus
- `Enter`: open focus view or advance the context dock
- `1`: Kanban view
- `2`: List view
- `3`: Focus view
- `Space`: toggle density
- `Ctrl+K` or `:`: open command palette
- `n`: create task
- `g`: run selected task prompt
- `s`: send session reply
- `m`: move task between columns
- `a`: approve pending checkpoint
- `d`: deny pending checkpoint
- `c`: mark task done
- `x`: cancel task
- `t`: rerun task
- `/`: set search filter
- `f`: toggle done visibility
- `r`: refresh
- `?`: help
- `q`: quit
## Notes
- `focus` mode collapses the left session rail and turns the selected task into a larger console-style view.
- The context dock keeps three perspectives on the same task: structured metadata, transcript/progress, and board alerts/live runtime.
- The command palette is useful when you forget a key or want a single launcher for common actions.
+42
View File
@@ -0,0 +1,42 @@
"""CLI board plugin entrypoint."""
from __future__ import annotations
from typing import Optional
def register_cli(parent_app) -> None:
"""Register the `opc board` command on the parent Typer app."""
import typer as _typer
@parent_app.command("board")
def board(
project: Optional[str] = _typer.Option(None, "--project", "-p", help="Project ID"),
session: Optional[str] = _typer.Option(None, "--session", help="Initial session id to inspect"),
view: str = _typer.Option("kanban", "--view", help="Initial view: kanban, list, focus, pipeline, org, work-item, role, logs"),
work_item: Optional[str] = _typer.Option(None, "--work-item", help="Initial work item id to inspect"),
role: Optional[str] = _typer.Option(None, "--role", help="Initial role id to inspect"),
target: Optional[str] = _typer.Option(None, "--target", help="Initial log/runtime target to inspect"),
attach: bool = _typer.Option(False, "--attach", help="Run as a temporary inspector attached from opc chat"),
readonly: bool = _typer.Option(False, "--readonly", help="Disable mutating board actions"),
refresh_interval: float = _typer.Option(
2.0,
"--refresh-interval",
min=0.5,
help="Cross-process reconcile interval in seconds",
),
) -> None:
"""Launch the OpenOPC terminal Kanban board."""
from opc.plugins.cli_board.entry import launch_board
launch_board(
project_id=project,
refresh_interval=refresh_interval,
attach=attach,
readonly=readonly,
initial_view=view,
initial_session_id=session,
initial_work_item_id=work_item,
initial_role_id=role,
initial_target=target,
)
+53
View File
@@ -0,0 +1,53 @@
"""Command-line entrypoint for the OpenOPC CLI board."""
from __future__ import annotations
import importlib
from typing import Any
from rich.console import Console
_console = Console()
def _require_textual() -> None:
try:
importlib.import_module("textual")
except ImportError as exc: # pragma: no cover - depends on local environment
_console.print(
"[red]CLI board requires the optional Textual dependency.[/red]\n"
"Install it with one of these commands:\n"
" [bold]pip install 'opc[cli-board]'[/bold]\n"
" [bold]pip install textual>=8.1.1[/bold]"
)
raise SystemExit(1) from exc
def launch_board(
*,
project_id: str | None = None,
refresh_interval: float = 2.0,
attach: bool = False,
readonly: bool = False,
initial_view: str = "kanban",
initial_session_id: str | None = None,
initial_work_item_id: str | None = None,
initial_role_id: str | None = None,
initial_target: str | None = None,
) -> Any:
"""Launch the interactive Textual board."""
_require_textual()
from opc.plugins.cli_board.tui.app import CliBoardApp
app = CliBoardApp(
project_id=project_id,
refresh_interval=refresh_interval,
attach=attach,
readonly=readonly,
initial_view=initial_view,
initial_session_id=initial_session_id,
initial_work_item_id=initial_work_item_id,
initial_role_id=initial_role_id,
initial_target=initial_target,
)
return app.run()
@@ -0,0 +1,2 @@
"""Service layer for the CLI board plugin."""
+287
View File
@@ -0,0 +1,287 @@
"""Interactive task actions for the CLI board."""
from __future__ import annotations
import asyncio
import uuid
from typing import TYPE_CHECKING, Any
from opc.core.models import Task, TaskStatus
from opc.layer2_organization.work_item_transition import apply_task_status_transition
from opc.presentation.kanban import column_to_task_status
from opc.plugins.office_ui.services.factory import OfficeServiceFactory
if TYPE_CHECKING:
from .engine_facade import EngineFacade
class BoardActions:
"""Mutating operations exposed to the TUI."""
def __init__(self, facade: "EngineFacade", project_id: str | None = None) -> None:
self.facade = facade
self.project_id = project_id
self._task_locks: dict[str, asyncio.Lock] = {}
self._task_bg_map: dict[str, asyncio.Task[Any]] = {}
@property
def _project_id(self) -> str:
return self.project_id or self.facade.project_id or "default"
async def _run_office_service(self, operation):
engine = await self.facade.ensure_ready()
async with OfficeServiceFactory(
config=getattr(engine, "config", None),
project_id=self._project_id,
on_progress=getattr(self.facade, "_progress_callback", None),
on_runtime_event=getattr(self.facade, "_event_callback", None),
) as services:
return await operation(services)
async def create_task(
self,
*,
title: str,
description: str = "",
auto_run: bool = False,
initial_message: str | None = None,
mode: str = "task",
company_profile: str | None = None,
) -> Task:
engine = await self.facade.ensure_ready()
if not engine.store:
raise RuntimeError("OPC store is not available.")
normalized_title = str(title or "").strip() or "New Chat"
normalized_description = str(description or "").strip()
result = await self._run_office_service(
lambda svc: svc.session.create(
project_id=self._project_id,
title=normalized_title,
description=normalized_description,
exec_mode=mode,
company_profile=company_profile,
interface="cli_board",
)
)
task_id = str(result.payload.get("task_id", "") or "")
task = await self._get_task(task_id)
if auto_run:
prompt = (initial_message or f"{normalized_title}\n{normalized_description}").strip()
if prompt:
await self.send_session_message(
task.id,
prompt,
mode=mode,
company_profile=company_profile,
allow_terminal=True,
)
return task
async def run_task(
self,
task_id: str,
*,
mode: str = "task",
company_profile: str | None = None,
) -> str:
task = await self._get_task(task_id)
prompt = f"{task.title}\n{task.description}".strip()
if not prompt:
raise ValueError("Selected task has no title or description to run.")
return await self.send_session_message(
task_id,
prompt,
mode=mode,
company_profile=company_profile,
allow_terminal=True,
)
async def retry_task(
self,
task_id: str,
*,
mode: str = "task",
company_profile: str | None = None,
) -> str:
return await self.run_task(task_id, mode=mode, company_profile=company_profile)
async def send_session_message(
self,
task_id: str,
content: str,
*,
mode: str = "task",
company_profile: str | None = None,
allow_terminal: bool = False,
) -> str:
engine = await self.facade.ensure_ready()
if not engine.store:
raise RuntimeError("OPC store is not available.")
async with self._get_task_lock(task_id):
if existing := self._task_bg_map.get(task_id):
if not existing.done() and existing is not asyncio.current_task():
raise RuntimeError("This task is already running.")
task = await self._get_task(task_id)
if not allow_terminal and task.status in {TaskStatus.CANCELLED, TaskStatus.DONE}:
raise ValueError("Cannot send messages to a completed or cancelled task.")
if not task.session_id:
task.session_id = str(uuid.uuid4())
if engine.memory:
await engine.memory.ensure_session(
task.session_id,
project_id=self._project_id,
title=task.title,
mode="primary",
metadata={"source": "cli_board"},
)
current_task = asyncio.current_task()
if current_task is not None:
self._task_bg_map[task_id] = current_task
try:
normalized_mode = str(mode or "task").strip().lower()
send_mode = "company" if normalized_mode in {"company", "custom", "org"} else "task"
send_profile = "custom" if normalized_mode in {"custom", "org"} else company_profile
result = await self._run_office_service(
lambda svc: svc.session.send(
project_id=self._project_id,
task_id=task_id,
content=str(content or "").strip(),
mode=send_mode,
company_profile=send_profile,
)
)
return str(result.payload.get("response", "") or "")
except asyncio.CancelledError:
await self._mark_related_tasks(task_id, TaskStatus.CANCELLED)
await self._resolve_related_checkpoints(task_id, status="cancelled")
raise
finally:
if self._task_bg_map.get(task_id) is current_task:
self._task_bg_map.pop(task_id, None)
async def move_task(self, task_id: str, column_id: str) -> Task:
engine = await self.facade.ensure_ready()
if not engine.store:
raise RuntimeError("OPC store is not available.")
task = await self._get_task(task_id)
target_status = column_to_task_status(column_id)
if target_status is None:
raise ValueError(f"Unsupported target column: {column_id}")
if task.status in {TaskStatus.DONE, TaskStatus.FAILED, TaskStatus.CANCELLED} and target_status not in {
TaskStatus.DONE,
TaskStatus.FAILED,
TaskStatus.CANCELLED,
}:
raise ValueError(f"Cannot move terminal task {task.status.value} back to {column_id}.")
await self._run_office_service(lambda svc: svc.kanban.move_task(project_id=self._project_id, task_id=task_id, column_id=column_id))
return await self._get_task(task_id)
async def complete_task(self, task_id: str) -> Task:
engine = await self.facade.ensure_ready()
if not engine.store:
raise RuntimeError("OPC store is not available.")
await self._run_office_service(lambda svc: svc.session.complete(project_id=self._project_id, task_id=task_id))
return await self._get_task(task_id)
async def cancel_task(self, task_id: str) -> None:
engine = await self.facade.ensure_ready()
if not engine.store:
raise RuntimeError("OPC store is not available.")
background = self._task_bg_map.get(task_id)
if background and not background.done() and background is not asyncio.current_task():
background.cancel()
await asyncio.sleep(0)
await self._run_office_service(lambda svc: svc.session.stop(project_id=self._project_id, task_id=task_id))
await self._resolve_related_checkpoints(task_id, status="cancelled")
async def approve_checkpoint(self, task_id: str, *, approved: bool = True, reply: str | None = None) -> str:
if reply is not None:
message = reply
else:
message = "approve" if approved else "deny"
return await self.send_session_message(
task_id,
message,
allow_terminal=True,
)
def is_running(self, task_id: str) -> bool:
task = self._task_bg_map.get(task_id)
return bool(task and not task.done())
def _get_task_lock(self, task_id: str) -> asyncio.Lock:
lock = self._task_locks.get(task_id)
if lock is None:
lock = asyncio.Lock()
self._task_locks[task_id] = lock
return lock
async def _get_task(self, task_id: str) -> Task:
engine = await self.facade.ensure_ready()
if not engine.store:
raise RuntimeError("OPC store is not available.")
task = await engine.store.get_task(task_id)
if task is None:
raise ValueError(f"Unknown task: {task_id}")
return task
async def _collect_related_tasks(self, task_id: str) -> list[Task]:
engine = await self.facade.ensure_ready()
if not engine.store:
return []
root_task = await self._get_task(task_id)
tasks = await engine.store.get_tasks(project_id=self._project_id)
related: list[Task] = []
root_session_id = str(root_task.session_id or "").strip()
for task in tasks:
metadata = task.metadata if isinstance(task.metadata, dict) else {}
origin_task_id = str(metadata.get("origin_task_id", "") or "").strip()
if task.id == root_task.id:
related.append(task)
continue
if origin_task_id and origin_task_id == root_task.id:
related.append(task)
continue
parent_session_id = str(task.parent_session_id or "").strip()
if root_session_id and parent_session_id and parent_session_id == root_session_id:
related.append(task)
return related
async def _mark_related_tasks(self, task_id: str, status: TaskStatus) -> None:
engine = await self.facade.ensure_ready()
if not engine.store:
return
for task in await self._collect_related_tasks(task_id):
await apply_task_status_transition(
engine.store,
task,
target_status_or_phase=status,
reason="cli_board_mark_related_tasks",
release_claim=status == TaskStatus.CANCELLED,
)
async def _resolve_related_checkpoints(self, task_id: str, *, status: str) -> None:
engine = await self.facade.ensure_ready()
if not engine.store:
return
related = await self._collect_related_tasks(task_id)
if not related:
return
task_ids = {task.id for task in related}
session_ids = {task.session_id for task in related if task.session_id}
checkpoints = await engine.store.get_pending_checkpoints(project_id=self._project_id)
for checkpoint in checkpoints:
if checkpoint.task_id in task_ids or checkpoint.session_id in session_ids:
await engine.store.resolve_execution_checkpoint(checkpoint.checkpoint_id, status=status)
@@ -0,0 +1,885 @@
"""Read-model builders for the CLI board."""
from __future__ import annotations
import json
import time as _time
from collections import defaultdict
from typing import TYPE_CHECKING, Any
from opc.layer2_organization.phase import (
Phase,
coerce_phase,
kanban_column,
should_hide_work_item_from_company_kanban,
)
from opc.layer2_organization.work_item_context_view import WorkItemContextView
from opc.layer2_organization.work_item_links import task_by_linked_work_item_id
from opc.presentation.kanban import (
COMPANY_KANBAN_COLUMNS,
build_base_task_payload,
datetime_to_timestamp,
)
from ..state.models import (
BoardAlert,
BoardMetrics,
BoardSnapshot,
BoardTaskView,
LinkedExecutionView,
OrgEmployeeView,
OrgRoleView,
OrgSnapshotView,
PendingCheckpointView,
PipelineSnapshot,
PipelineWorkItemView,
SessionMessageView,
SessionSummaryView,
TaskDetailView,
)
if TYPE_CHECKING:
from .engine_facade import EngineFacade
class BoardRepository:
"""Builds CLI-specific board and detail snapshots from the main OPC store."""
def __init__(self, facade: "EngineFacade", project_id: str | None = None) -> None:
self.facade = facade
self.project_id = project_id
async def load_snapshot(self) -> BoardSnapshot:
engine = await self.facade.ensure_ready()
if not engine.store:
return BoardSnapshot(project_id=self._project_id)
tasks = await engine.store.get_tasks(project_id=self._project_id)
checkpoints = await engine.store.get_pending_checkpoints(project_id=self._project_id)
checkpoint_by_session = self._checkpoint_by_session(checkpoints)
await self._enrich_checkpoint_payloads(checkpoint_by_session)
company_snapshot = await self._maybe_build_company_snapshot(
engine,
tasks=tasks,
checkpoint_by_session=checkpoint_by_session,
checkpoint_count=len(checkpoints),
)
if company_snapshot is not None:
return company_snapshot
visible_tasks, hidden_by_origin, hidden_count = self._split_visible_tasks(tasks)
task_views: list[BoardTaskView] = []
for display_num, task in enumerate(visible_tasks, start=1):
task_views.append(
self._task_to_view(
task,
checkpoint=checkpoint_by_session.get(getattr(task, "session_id", None)),
linked_tasks=hidden_by_origin.get(getattr(task, "id", ""), []),
display_num=display_num,
)
)
session_summaries = [self._session_summary_view(task) for task in task_views]
alerts = self._alerts_from_tasks(task_views)
return BoardSnapshot(
project_id=self._project_id,
tasks=task_views,
hidden_task_count=hidden_count,
pending_checkpoint_count=len(checkpoints),
session_summaries=session_summaries,
alerts=alerts,
metrics=self._build_metrics(task_views, hidden_count=hidden_count, pending_checkpoint_count=len(checkpoints)),
)
async def _maybe_build_company_snapshot(
self,
engine: Any,
*,
tasks: list[Any],
checkpoint_by_session: dict[str | None, PendingCheckpointView],
checkpoint_count: int,
) -> BoardSnapshot | None:
"""Return a company-mode snapshot if an active delegation run exists.
Cards are sourced from `DelegationWorkItem`. Runtime `Task` objects are
kept only as audit references (`runtime_task_id` / `session_id`).
"""
store = engine.store
list_runs = getattr(store, "list_open_delegation_runs", None)
list_items = getattr(store, "list_delegation_work_items", None)
if list_runs is None or list_items is None:
return None
try:
open_runs = await list_runs(project_id=self._project_id)
except TypeError:
open_runs = await list_runs()
if not open_runs:
return None
active_run = open_runs[0]
run_id = str(getattr(active_run, "run_id", "") or "").strip()
if not run_id:
return None
work_items = await list_items(run_id)
if not work_items:
return None
hydrate_links = getattr(store, "hydrate_task_work_item_links", None)
if callable(hydrate_links):
await hydrate_links(tasks)
task_by_work_item_id = task_by_linked_work_item_id(tasks)
visible_items: list[Any] = []
hidden_count = 0
for item in work_items:
metadata = dict(getattr(item, "metadata", {}) or {})
if not str(getattr(item, "parent_work_item_id", "") or "").strip():
# Skip the synthetic root work item — the kanban shows leaf delegations.
hidden_count += 1
continue
if bool(metadata.get("attention_work_item", False)):
hidden_count += 1
continue
if should_hide_work_item_from_company_kanban(metadata):
hidden_count += 1
continue
visible_items.append(item)
task_views: list[BoardTaskView] = []
for display_num, item in enumerate(visible_items, start=1):
linked_task = task_by_work_item_id.get(str(getattr(item, "work_item_id", "") or "").strip())
checkpoint = None
linked_session_id = getattr(linked_task, "session_id", None) if linked_task is not None else None
if linked_session_id:
checkpoint = checkpoint_by_session.get(linked_session_id)
task_views.append(
self._work_item_to_view(
item,
linked_task=linked_task,
checkpoint=checkpoint,
display_num=display_num,
)
)
session_summaries = [self._session_summary_view(task) for task in task_views]
alerts = self._alerts_from_tasks(task_views)
column_order = [column.column_id for column in COMPANY_KANBAN_COLUMNS]
return BoardSnapshot(
project_id=self._project_id,
tasks=task_views,
hidden_task_count=hidden_count,
pending_checkpoint_count=checkpoint_count,
session_summaries=session_summaries,
alerts=alerts,
metrics=self._build_metrics(
task_views,
hidden_count=hidden_count,
pending_checkpoint_count=checkpoint_count,
),
mode="company",
column_order=column_order,
)
async def load_task_detail(self, task_id: str) -> TaskDetailView | None:
engine = await self.facade.ensure_ready()
if not engine.store:
return None
tasks = await engine.store.get_tasks(project_id=self._project_id)
checkpoints = await engine.store.get_pending_checkpoints(project_id=self._project_id)
checkpoint_by_session = self._checkpoint_by_session(checkpoints)
await self._enrich_checkpoint_payloads(checkpoint_by_session)
# In company mode the kanban card_id is a DelegationWorkItem.work_item_id,
# not a Task.id. Try the work-item path first; fall back to the runtime
# Task path so standard mode (and detail links by Task.id) still works.
work_item_detail = await self._load_work_item_detail(
engine,
work_item_id=task_id,
tasks=tasks,
checkpoint_by_session=checkpoint_by_session,
)
if work_item_detail is not None:
return work_item_detail
visible_tasks, _, _ = self._split_visible_tasks(tasks)
target = next((task for task in tasks if getattr(task, "id", "") == task_id), None)
if target is None:
return None
pending = checkpoint_by_session.get(getattr(target, "session_id", None))
linked: list[Any] = []
seen_ids: set[str] = set()
for task in tasks:
origin_task_id = self._origin_task_id(task)
if origin_task_id == task_id and getattr(task, "id", "") != task_id:
if task.id not in seen_ids:
linked.append(task)
seen_ids.add(task.id)
continue
if getattr(task, "parent_session_id", None) and getattr(task, "parent_session_id", None) == getattr(target, "session_id", None):
if task.id not in seen_ids and task.id != task_id:
linked.append(task)
seen_ids.add(task.id)
transcript: list[SessionMessageView] = []
if getattr(target, "session_id", None):
raw_transcript = await engine.store.get_session_transcript(target.session_id)
transcript = [msg for msg in (self._transcript_item_to_view(item) for item in raw_transcript) if msg is not None]
display_num = next(
(index for index, task in enumerate(visible_tasks, start=1) if getattr(task, "id", "") == task_id),
0,
)
task_view = self._task_to_view(target, checkpoint=pending, linked_tasks=linked, display_num=display_num)
linked_views = [self._linked_execution_view(task) for task in linked]
result = getattr(target, "result", None) or {}
result_content = result.get("content") if isinstance(result, dict) else None
artifacts = result.get("artifacts", []) if isinstance(result, dict) else []
metadata = getattr(target, "metadata", {}) if isinstance(getattr(target, "metadata", {}), dict) else {}
context_preview = (
metadata.get("handoff_context")
or metadata.get("context_preview")
or metadata.get("secretary_context")
or None
)
return TaskDetailView(
task=task_view,
transcript=transcript,
linked_executions=linked_views,
progress_entries=list(metadata.get("progress_log", []) or []),
pending_checkpoint=pending,
result_content=result_content,
artifacts=artifacts if isinstance(artifacts, list) else [artifacts],
context_preview=str(context_preview).strip() if context_preview else None,
)
async def _load_work_item_detail(
self,
engine: Any,
*,
work_item_id: str,
tasks: list[Any],
checkpoint_by_session: dict[str | None, PendingCheckpointView],
) -> TaskDetailView | None:
"""Build a detail view keyed by DelegationWorkItem.work_item_id.
Returns None when the id is not a known work item (caller falls back
to the Task-id path).
"""
store = engine.store
list_runs = getattr(store, "list_open_delegation_runs", None)
list_items = getattr(store, "list_delegation_work_items", None)
if list_runs is None or list_items is None or not work_item_id:
return None
try:
open_runs = await list_runs(project_id=self._project_id)
except TypeError:
open_runs = await list_runs()
if not open_runs:
return None
target_item: Any | None = None
for run in open_runs:
run_id = str(getattr(run, "run_id", "") or "").strip()
if not run_id:
continue
for item in await list_items(run_id):
if str(getattr(item, "work_item_id", "") or "").strip() == work_item_id:
target_item = item
break
if target_item is not None:
break
if target_item is None:
return None
task_by_work_item_id = task_by_linked_work_item_id(tasks)
linked_task = task_by_work_item_id.get(work_item_id)
linked_session_id = str(getattr(linked_task, "session_id", "") or "").strip() if linked_task is not None else ""
checkpoint = checkpoint_by_session.get(linked_session_id) if linked_session_id else None
transcript: list[SessionMessageView] = []
if linked_session_id:
raw_transcript = await engine.store.get_session_transcript(linked_session_id)
transcript = [
msg
for msg in (self._transcript_item_to_view(item) for item in raw_transcript)
if msg is not None
]
task_view = self._work_item_to_view(
target_item,
linked_task=linked_task,
checkpoint=checkpoint,
display_num=0,
)
result = getattr(linked_task, "result", None) or {} if linked_task is not None else {}
result_content = result.get("content") if isinstance(result, dict) else None
artifacts = result.get("artifacts", []) if isinstance(result, dict) else []
# Prefer work-item handoff context; fall back to runtime Task metadata.
linked_metadata = (
getattr(linked_task, "metadata", None) or {}
if linked_task is not None
else {}
)
view = WorkItemContextView(target_item, linked_task)
context_preview = (
view.get("handoff_context")
or view.get("context_preview")
or (linked_metadata.get("secretary_context") if isinstance(linked_metadata, dict) else None)
or None
)
progress_log = view.get_list("progress_log")
return TaskDetailView(
task=task_view,
transcript=transcript,
linked_executions=[self._linked_execution_view(linked_task)] if linked_task is not None else [],
progress_entries=progress_log,
pending_checkpoint=checkpoint,
result_content=result_content,
artifacts=artifacts if isinstance(artifacts, list) else [artifacts],
context_preview=str(context_preview).strip() if context_preview else None,
)
async def load_pipeline_state(
self,
parent_task_id: str,
runtime_lookup: dict[str, Any] | None = None,
) -> PipelineSnapshot | None:
"""Build the terminal Office-style work-item pipeline for a run."""
engine = await self.facade.ensure_ready()
store = getattr(engine, "store", None)
if not store:
return None
list_runs = getattr(store, "list_open_delegation_runs", None)
list_items = getattr(store, "list_delegation_work_items", None)
if list_runs is None or list_items is None:
return None
try:
runs = list(await list_runs(project_id=self._project_id))
except TypeError:
runs = list(await list_runs())
if not runs:
return None
tasks = await store.get_tasks(project_id=self._project_id) if hasattr(store, "get_tasks") else []
hydrate_links = getattr(store, "hydrate_task_work_item_links", None)
if callable(hydrate_links):
await hydrate_links(tasks)
linked_tasks = task_by_linked_work_item_id(tasks)
selected_run = None
selected_items: list[Any] = []
target = str(parent_task_id or "").strip()
for run in runs:
run_id = str(getattr(run, "run_id", "") or "").strip()
items = list(await list_items(run_id)) if run_id else []
run_keys = {
run_id,
str(getattr(run, "parent_task_id", "") or "").strip(),
str(getattr(run, "task_id", "") or "").strip(),
str(getattr(run, "parent_session_id", "") or "").strip(),
}
item_keys = {str(getattr(item, "work_item_id", "") or "").strip() for item in items}
if not target or target in run_keys or target in item_keys:
selected_run = run
selected_items = items
break
if selected_run is None:
selected_run = runs[0]
run_id = str(getattr(selected_run, "run_id", "") or "").strip()
selected_items = list(await list_items(run_id)) if run_id else []
if not selected_items:
return None
work_items: list[PipelineWorkItemView] = []
start_ts = 0.0
end_ts = 0.0
for item in selected_items:
work_item_id = str(getattr(item, "work_item_id", "") or "").strip()
linked_task = linked_tasks.get(work_item_id)
linked_task_id = str(getattr(linked_task, "id", "") or "").strip() if linked_task is not None else ""
metadata = dict(getattr(item, "metadata", {}) or {})
linked_metadata = dict(getattr(linked_task, "metadata", {}) or {}) if linked_task is not None else {}
phase = coerce_phase(getattr(item, "phase", Phase.READY))
created_at = datetime_to_timestamp(getattr(item, "created_at", None))
updated_at = datetime_to_timestamp(getattr(item, "updated_at", None) or getattr(item, "created_at", None))
if created_at and (not start_ts or created_at < start_ts):
start_ts = created_at
if updated_at and updated_at > end_ts:
end_ts = updated_at
runtime = None
if runtime_lookup:
runtime = runtime_lookup.get(linked_task_id) or runtime_lookup.get(work_item_id)
work_items.append(PipelineWorkItemView(
projection_id=work_item_id,
title=str(getattr(item, "title", "") or work_item_id),
role_id=str(getattr(item, "role_id", "") or ""),
status=phase.value,
assigned_to=str(getattr(linked_task, "assigned_to", "") or getattr(item, "role_id", "") or ""),
task_id=linked_task_id or None,
runtime_task_id=linked_task_id or None,
execution_turn_id=linked_task_id or None,
session_id=str(getattr(linked_task, "session_id", "") or "") or None,
elapsed_sec=max(0.0, (updated_at or _time.time()) - created_at) if created_at else 0.0,
current_tool=getattr(runtime, "current_tool", None) if runtime is not None else None,
tool_elapsed_ms=int(getattr(runtime, "tool_elapsed_ms", 0) or 0) if runtime is not None else 0,
last_tool_summary=str(getattr(runtime, "last_tool_summary", "") or "") if runtime is not None else "",
context_remaining_pct=int(getattr(runtime, "context_remaining_pct", 0) or 0) if runtime is not None else 0,
turn_cost_usd=float(getattr(runtime, "turn_cost_usd", 0.0) or 0.0) if runtime is not None else 0.0,
has_gate=bool(metadata.get("gate_type") or linked_metadata.get("checkpoint_hint") or (getattr(runtime, "pending_permission_count", 0) if runtime is not None else 0)),
gate_type=str(metadata.get("gate_type") or linked_metadata.get("checkpoint_type") or "") or None,
dependencies=[
str(dep).strip()
for dep in list(metadata.get("dependency_work_item_ids", []) or [])
if str(dep).strip()
],
parallel_group=str(metadata.get("parallel_group", "") or "") or None,
))
done_count = sum(1 for item in work_items if item.status in {"done", "reviewed", "delivered"})
parent_title = (
str(getattr(selected_run, "title", "") or "")
or str(getattr(selected_run, "summary", "") or "")
or str(getattr(selected_run, "run_id", "") or "")
)
return PipelineSnapshot(
parent_task_id=str(getattr(selected_run, "parent_task_id", "") or getattr(selected_run, "task_id", "") or target),
parent_title=parent_title,
profile=str(getattr(selected_run, "profile", "") or getattr(selected_run, "company_profile", "") or ""),
work_items=work_items,
done_count=done_count,
total_count=len(work_items),
elapsed_sec=max(0.0, (end_ts or _time.time()) - start_ts) if start_ts else 0.0,
)
async def load_org_snapshot(self) -> OrgSnapshotView | None:
"""Build a read-only view of the current org structure."""
engine = await self.facade.ensure_ready()
if not engine.org_engine:
return None
org = engine.org_engine
# Roles as tree
all_agents = org.list_agents()
all_employees = org.list_employees()
emp_count_by_role: dict[str, int] = {}
for emp in all_employees:
emp_count_by_role[emp.role_id] = emp_count_by_role.get(emp.role_id, 0) + 1
try:
tree_raw = org.get_org_tree()
except Exception:
tree_raw = []
def _build_role_tree(nodes: list[dict]) -> list[OrgRoleView]:
result: list[OrgRoleView] = []
for node in nodes:
agent = node.get("agent")
if agent is None:
continue
role_id = getattr(agent, "role_id", "")
result.append(OrgRoleView(
role_id=role_id,
name=getattr(agent, "name", role_id),
responsibility=getattr(agent, "responsibility", ""),
reports_to=getattr(agent, "reports_to", "owner"),
employee_count=emp_count_by_role.get(role_id, 0),
children=_build_role_tree(node.get("reports", [])),
))
return result
role_tree = _build_role_tree(tree_raw)
# Employees
employee_views = [
OrgEmployeeView(
employee_id=emp.employee_id,
name=emp.name,
role_id=emp.role_id,
category=emp.category,
domains=list(emp.domains),
seniority=emp.seniority,
)
for emp in all_employees
]
profile = org.get_company_profile()
return OrgSnapshotView(
role_tree=role_tree,
employees=employee_views,
company_profile=profile,
role_count=len(all_agents),
employee_count=len(all_employees),
)
@property
def _project_id(self) -> str:
return self.project_id or self.facade.project_id or "default"
@staticmethod
def _origin_task_id(task: Any) -> str | None:
metadata = getattr(task, "metadata", None)
if not isinstance(metadata, dict):
return None
origin_task_id = str(metadata.get("origin_task_id", "") or "").strip()
return origin_task_id or None
def _split_visible_tasks(self, tasks: list[Any]) -> tuple[list[Any], dict[str, list[Any]], int]:
visible_tasks: list[Any] = []
hidden_by_origin: dict[str, list[Any]] = defaultdict(list)
hidden_count = 0
for task in tasks:
origin_task_id = self._origin_task_id(task)
if origin_task_id and origin_task_id != getattr(task, "id", ""):
hidden_by_origin[origin_task_id].append(task)
hidden_count += 1
continue
visible_tasks.append(task)
return visible_tasks, hidden_by_origin, hidden_count
@staticmethod
def _checkpoint_prompt(checkpoint: Any) -> tuple[str, str]:
payload = getattr(checkpoint, "payload", {}) or {}
prompt = (
payload.get("prompt")
or payload.get("message")
or payload.get("summary")
or payload.get("original_message")
or ""
)
prompt_text = str(prompt).strip()
if not prompt_text:
prompt_text = json.dumps(payload, ensure_ascii=False, indent=2)[:600]
summary = (
payload.get("feedback_scope")
or payload.get("work_item_projection_title")
or payload.get("summary")
or payload.get("title")
or getattr(checkpoint, "checkpoint_type", "pending")
)
return str(summary).strip(), prompt_text
def _checkpoint_view(self, checkpoint: Any) -> PendingCheckpointView:
summary, prompt = self._checkpoint_prompt(checkpoint)
return PendingCheckpointView(
checkpoint_id=str(getattr(checkpoint, "checkpoint_id", "") or ""),
checkpoint_type=str(getattr(checkpoint, "checkpoint_type", "") or ""),
status=str(getattr(checkpoint, "status", "") or ""),
session_id=getattr(checkpoint, "session_id", None),
task_id=getattr(checkpoint, "task_id", None),
summary=summary,
prompt=prompt,
payload=dict(getattr(checkpoint, "payload", {}) or {}),
)
def _checkpoint_by_session(self, checkpoints: list[Any]) -> dict[str | None, PendingCheckpointView]:
result: dict[str | None, PendingCheckpointView] = {}
for checkpoint in checkpoints:
session_id = getattr(checkpoint, "session_id", None)
if session_id not in result:
result[session_id] = self._checkpoint_view(checkpoint)
return result
async def _enrich_checkpoint_payloads(
self,
checkpoint_map: dict[str | None, PendingCheckpointView],
) -> None:
"""Enrich reorg checkpoint payloads with full proposal data from store."""
engine = await self.facade.ensure_ready()
if not engine.store:
return
for view in checkpoint_map.values():
if view.checkpoint_type != "company_reorg_pending":
continue
proposal_id = str(view.payload.get("proposal_id", "") or "").strip()
if not proposal_id:
continue
try:
proposal = await engine.store.get_reorg_proposal(proposal_id)
except Exception:
continue
if proposal is None:
continue
view.payload["title"] = proposal.title
view.payload["scope"] = proposal.scope.value if hasattr(proposal.scope, "value") else str(proposal.scope)
view.payload["risk_level"] = proposal.risk_level.value if hasattr(proposal.risk_level, "value") else str(proposal.risk_level)
view.payload["summary"] = proposal.summary
view.payload["rationale"] = proposal.rationale
view.payload["impact_summary"] = dict(proposal.impact_summary) if proposal.impact_summary else {}
changeset = proposal.changeset
if changeset:
view.payload["role_changes"] = [
{"action": rc.action, "role_id": rc.role_id, "replacement_role_id": getattr(rc, "replacement_role_id", ""), "reason": getattr(rc, "reason", "")}
for rc in (changeset.role_changes if hasattr(changeset, "role_changes") else [])
]
def _work_item_to_view(
self,
item: Any,
*,
linked_task: Any | None,
checkpoint: PendingCheckpointView | None,
display_num: int = 0,
) -> BoardTaskView:
"""Build a BoardTaskView from a DelegationWorkItem (company-mode card).
The card identity is the work_item_id; runtime Task / session are
kept only as audit references via runtime_task_id / session_id.
"""
normalized_metadata = dict(getattr(item, "metadata", {}) or {})
phase = coerce_phase(getattr(item, "phase", Phase.READY))
column_id = kanban_column(phase).replace("_", "-")
canonical_status = phase.value
work_item_id = str(getattr(item, "work_item_id", "") or "").strip()
role_id = str(getattr(item, "role_id", "") or "").strip()
title = str(getattr(item, "title", "") or "").strip()
summary = str(getattr(item, "summary", "") or "").strip()
kind = str(getattr(item, "kind", "") or "").strip()
dependencies = [
str(dep).strip()
for dep in list(normalized_metadata.get("dependency_work_item_ids", []) or [])
if str(dep).strip()
]
created_at = datetime_to_timestamp(getattr(item, "created_at", None))
updated_at = datetime_to_timestamp(getattr(item, "updated_at", None) or getattr(item, "created_at", None))
# Audit/back-references (NOT used to drive lifecycle on the card).
linked_task_id = str(getattr(linked_task, "id", "") or "").strip() if linked_task is not None else ""
linked_session_id = str(getattr(linked_task, "session_id", "") or "").strip() if linked_task is not None else ""
result = getattr(linked_task, "result", None) or {} if linked_task is not None else {}
result_content = result.get("content") if isinstance(result, dict) else None
artifacts = result.get("artifacts", []) if isinstance(result, dict) else []
# Merge work-item metadata first, then linked-task metadata so the card
# surfaces work-item-truth (status/dependencies) plus runtime telemetry.
merged_metadata: dict[str, Any] = dict(normalized_metadata)
if linked_task is not None:
linked_meta = getattr(linked_task, "metadata", None) or {}
if isinstance(linked_meta, dict):
for key, value in linked_meta.items():
merged_metadata.setdefault(key, value)
return BoardTaskView(
task_id=work_item_id,
title=title,
description=summary,
status=canonical_status,
column_id=column_id,
priority=None,
assignee_ids=[role_id] if role_id else [],
assigned_to=role_id,
tags=[kind] if kind else [],
session_id=linked_session_id or None,
created_at=created_at,
updated_at=updated_at,
metadata=merged_metadata,
pending_checkpoint=checkpoint,
linked_task_count=1 if linked_task is not None else 0,
result_content=result_content,
artifacts=artifacts if isinstance(artifacts, list) else [artifacts],
origin_task_id=None,
display_id=f"OPC-{display_num}" if display_num else "",
dependencies=dependencies,
work_item_id=work_item_id,
runtime_task_id=linked_task_id or None,
execution_turn_id=linked_task_id or None,
)
def _task_to_view(
self,
task: Any,
*,
checkpoint: PendingCheckpointView | None,
linked_tasks: list[Any],
display_num: int = 0,
) -> BoardTaskView:
payload = build_base_task_payload(task, display_num)
result = getattr(task, "result", None) or {}
result_content = result.get("content") if isinstance(result, dict) else None
artifacts = result.get("artifacts", []) if isinstance(result, dict) else []
metadata = getattr(task, "metadata", {}) if isinstance(getattr(task, "metadata", {}), dict) else {}
return BoardTaskView(
task_id=payload["task_id"],
title=payload["title"],
description=payload["description"],
status=payload["status"],
column_id=payload["column_id"],
priority=payload["priority"],
assignee_ids=payload["assignee_ids"],
assigned_to=str(getattr(task, "assigned_to", "") or ""),
tags=payload["tags"],
session_id=payload["session_id"],
created_at=float(payload["created_at"]),
updated_at=float(payload["updated_at"]),
metadata=metadata,
pending_checkpoint=checkpoint,
linked_task_count=len(linked_tasks),
result_content=result_content,
artifacts=artifacts if isinstance(artifacts, list) else [artifacts],
origin_task_id=self._origin_task_id(task),
display_id=str(payload.get("display_id", "") or ""),
dependencies=list(payload.get("dependencies", []) or []),
runtime_task_id=payload["task_id"],
execution_turn_id=payload["task_id"],
)
@staticmethod
def _session_summary_view(task: BoardTaskView) -> SessionSummaryView:
return SessionSummaryView(
task_id=task.task_id,
title=task.title,
status=task.status,
column_id=task.column_id,
session_id=task.session_id,
updated_at=float(task.updated_at),
created_at=float(task.created_at),
assigned_to=task.assigned_to,
priority=task.priority,
pending_checkpoint=task.pending_checkpoint is not None,
linked_task_count=task.linked_task_count,
tags=list(task.tags),
runtime_task_id=task.runtime_task_id,
execution_turn_id=task.execution_turn_id or task.runtime_task_id,
)
@staticmethod
def _alerts_from_tasks(tasks: list[BoardTaskView]) -> list[BoardAlert]:
alerts: list[BoardAlert] = []
for task in tasks:
item_label = "Work item" if task.work_item_id else "Task"
if task.pending_checkpoint:
alerts.append(
BoardAlert(
alert_id=f"checkpoint:{task.task_id}",
level="warn",
title="Checkpoint pending",
message=f"{task.title} is waiting for human feedback.",
task_id=task.task_id,
created_at=float(task.updated_at or task.created_at),
)
)
if task.status in {"blocked", "awaiting_peer", "awaiting_review"}:
alerts.append(
BoardAlert(
alert_id=f"blocked:{task.task_id}",
level="warn",
title=f"{item_label} paused",
message=f"{task.title} is paused in `{task.status}`.",
task_id=task.task_id,
created_at=float(task.updated_at or task.created_at),
)
)
if task.status in {"failed", "cancelled"}:
alerts.append(
BoardAlert(
alert_id=f"terminal:{task.task_id}",
level="error",
title=f"{item_label} ended abnormally",
message=f"{task.title} finished with status `{task.status}`.",
task_id=task.task_id,
created_at=float(task.updated_at or task.created_at),
)
)
level_weight = {"error": 0, "warn": 1, "info": 2}
alerts.sort(key=lambda alert: (level_weight.get(alert.level, 99), -float(alert.created_at)))
return alerts[:12]
@staticmethod
def _build_metrics(
tasks: list[BoardTaskView],
*,
hidden_count: int,
pending_checkpoint_count: int,
) -> BoardMetrics:
todo_count = sum(1 for task in tasks if task.column_id == "todo")
in_progress_count = sum(1 for task in tasks if task.column_id == "in-progress")
in_review_count = sum(1 for task in tasks if task.column_id == "in-review")
done_count = sum(1 for task in tasks if task.column_id == "done")
blocked_count = sum(1 for task in tasks if task.status in {"blocked", "awaiting_peer", "awaiting_review"})
failed_count = sum(1 for task in tasks if task.status in {"failed", "cancelled"})
active_session_count = sum(1 for task in tasks if task.session_id)
running_count = sum(
1
for task in tasks
if task.status in {"running", "idle", "blocked", "awaiting_peer", "awaiting_review"}
)
return BoardMetrics(
total_tasks=len(tasks) + hidden_count,
visible_tasks=len(tasks),
filtered_tasks=len(tasks),
hidden_task_count=hidden_count,
todo_count=todo_count,
in_progress_count=in_progress_count,
in_review_count=in_review_count,
done_count=done_count,
running_count=running_count,
blocked_count=blocked_count,
failed_count=failed_count,
pending_checkpoint_count=pending_checkpoint_count,
active_session_count=active_session_count,
alert_count=len(BoardRepository._alerts_from_tasks(tasks)),
)
@staticmethod
def _linked_execution_view(task: Any) -> LinkedExecutionView:
payload = build_base_task_payload(task, 0)
return LinkedExecutionView(
task_id=payload["task_id"],
title=payload["title"],
status=payload["status"],
assigned_to=str(getattr(task, "assigned_to", "") or ""),
session_id=payload["session_id"],
created_at=float(payload["created_at"]),
updated_at=float(payload["updated_at"]),
runtime_task_id=payload["task_id"],
execution_turn_id=payload["task_id"],
metadata=dict(getattr(task, "metadata", {}) or {}),
)
@staticmethod
def _render_parts(parts: list[Any]) -> str:
lines: list[str] = []
for part in parts:
part_type = getattr(part, "part_type", "")
payload = getattr(part, "payload", {}) if isinstance(getattr(part, "payload", {}), dict) else {}
if part_type == "text":
text = payload.get("text", "")
if text:
lines.append(str(text))
elif part_type in {"subtask_result", "task_result"}:
title = payload.get("task_title", "Task")
summary = payload.get("summary", "")
lines.append(f"{title}: {summary}".strip(": "))
return "\n".join(lines).strip()
def _transcript_item_to_view(self, item: dict[str, Any]) -> SessionMessageView | None:
message = item.get("message")
if not message or getattr(message, "summary_flag", False):
return None
content = self._render_parts(item.get("parts", []))
if not content:
return None
role = str(getattr(message, "role", "") or "").strip().lower()
agent_id = str(getattr(message, "agent_id", "") or "").strip()
sender_name = {
"user": "You",
"assistant": "OPC",
"system": "System",
"subagent": agent_id.replace("_", " ").replace("-", " ").title() if agent_id else "Subagent",
}.get(role, agent_id or role.title() or "OPC")
created_at = getattr(message, "created_at", None)
timestamp = created_at.timestamp() if hasattr(created_at, "timestamp") else 0.0
return SessionMessageView(
message_id=str(getattr(message, "message_id", "") or ""),
role=role or "assistant",
sender_name=sender_name,
content=content,
created_at=timestamp,
metadata={"agent_id": agent_id} if agent_id else {},
)
@@ -0,0 +1,77 @@
"""Engine lifecycle management for the CLI board."""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Any, Awaitable, Callable
from opc.core.config import OPCConfig, get_opc_home
from opc.engine import OPCEngine
ProgressCallback = Callable[[str], Awaitable[None] | Awaitable[Any]]
EventCallback = Callable[[Any], Awaitable[None]]
class EngineFacade:
"""Owns a single lazily-initialized OPCEngine for the CLI board process."""
def __init__(self, project_id: str | None = None) -> None:
self.project_id = project_id
self._config: OPCConfig | None = None
self._engine: OPCEngine | None = None
self._progress_callback: Callable[..., Awaitable[None]] | None = None
self._event_callback: EventCallback | None = None
self._init_lock = asyncio.Lock()
@property
def engine(self) -> OPCEngine | None:
return self._engine
@property
def store(self): # noqa: ANN201 - preserve simple property for callers
return self._engine.store if self._engine else None
@property
def opc_home(self) -> Path:
return get_opc_home()
def configure_callbacks(
self,
*,
progress_callback: Callable[..., Awaitable[None]] | None = None,
event_callback: EventCallback | None = None,
) -> None:
self._progress_callback = progress_callback
self._event_callback = event_callback
if self._engine is not None and progress_callback is not None:
self._engine.on_progress = progress_callback
async def ensure_ready(self) -> OPCEngine:
if self._engine is not None:
return self._engine
async with self._init_lock:
if self._engine is not None:
return self._engine
config_dir = get_opc_home() / "config"
self._config = OPCConfig.load(config_dir) if config_dir.exists() else OPCConfig()
engine = OPCEngine(
config=self._config,
project_id=self.project_id,
on_progress=self._progress_callback,
)
await engine.initialize()
if self._event_callback is not None:
engine.event_bus.subscribe_all(self._event_callback)
self._engine = engine
return engine
async def shutdown(self) -> None:
if self._engine is None:
return
engine = self._engine
self._engine = None
await engine.shutdown()
@@ -0,0 +1,137 @@
"""Translate OPC engine callbacks into board-friendly runtime updates."""
from __future__ import annotations
import re
from typing import Any, Awaitable, Callable
from opc.presentation.kanban import STATUS_TO_COLUMN
BoardEventSink = Callable[[dict[str, Any]], Awaitable[None]]
_TOOL_RE = re.compile(r"^\[Tool:\s*([^\]]+)\]")
class CliBoardEventBridge:
"""Bridges in-process engine events to the board state layer."""
def __init__(self, sink: BoardEventSink) -> None:
self._sink = sink
async def handle_event(self, event: Any) -> None:
event_type = str(getattr(event, "event_type", "") or "")
payload = getattr(event, "payload", {}) or {}
if event_type == "task_status_changed":
task_id = str(payload.get("task_id", "") or "")
status = str(payload.get("status", "") or "")
if task_id and status:
await self._sink(
{
"kind": "task_status",
"task_id": task_id,
"status": status,
"column_id": STATUS_TO_COLUMN.get(status, "todo"),
}
)
if status in {"awaiting_review", "awaiting_peer", "blocked", "failed", "cancelled", "done"}:
await self._sink({"kind": "refresh", "reason": f"status:{status}"})
return
if event_type in {"task_created", "child_session_created", "escalation_created"}:
await self._sink({"kind": "refresh", "reason": event_type})
return
if event_type == "agent_status_changed":
task_id = str(payload.get("task_id", "") or "")
status = str(payload.get("status", "") or "")
if task_id and status:
await self._sink(
{
"kind": "runtime",
"task_id": task_id,
"status": status,
"current_tool": None,
"iteration": None,
}
)
return
if event_type == "runtime_event":
task_id = str(payload.get("task_id", "") or "")
runtime_type = str(payload.get("type", "") or "").strip()
if not task_id or not runtime_type:
return
if runtime_type == "status_snapshot":
await self._sink(
{
"kind": "runtime",
"task_id": task_id,
"status": "tool_active" if payload.get("current_tool") else "reflecting",
"current_tool": payload.get("current_tool"),
"iteration": payload.get("iteration"),
"tool_elapsed_ms": payload.get("tool_elapsed_ms"),
"last_tool_summary": payload.get("last_tool_summary"),
"context_tokens": payload.get("context_tokens"),
"context_window": payload.get("context_window"),
"context_remaining_pct": payload.get("context_remaining_pct"),
"turn_cost_usd": payload.get("turn_cost_usd"),
"session_cost_usd": payload.get("session_cost_usd"),
"pending_permission_count": payload.get("pending_permission_count"),
"drain_mode": payload.get("drain_mode"),
}
)
return
if runtime_type in {"tool_started", "tool_progress", "tool_completed", "tool_skipped", "permission_requested", "permission_resolved"}:
await self._sink(
{
"kind": "runtime",
"task_id": task_id,
"status": "tool_active" if runtime_type in {"tool_started", "tool_progress"} else "reflecting",
"current_tool": payload.get("tool_name"),
"iteration": payload.get("iteration"),
"tool_elapsed_ms": payload.get("elapsed_ms"),
"last_tool_summary": payload.get("result_summary") or payload.get("message"),
}
)
return
if event_type == "agent_log":
task_id = str(payload.get("task_id", "") or "")
if not task_id:
return
status = str(payload.get("status", "") or "")
iteration = payload.get("iteration")
current_tool = None
runtime_status = status
if status == "thinking":
runtime_status = "reflecting"
current_tool = "Reflect"
elif status == "executing":
current_tool = str(payload.get("tool", "") or "") or None
runtime_status = "tool_active"
await self._sink(
{
"kind": "runtime",
"task_id": task_id,
"status": runtime_status,
"current_tool": current_tool,
"iteration": int(iteration) if isinstance(iteration, int) else None,
}
)
async def handle_progress(self, text: str, *, task_id: str | None = None, **_: Any) -> None:
if not task_id:
return
current_tool = None
match = _TOOL_RE.match(str(text or "").strip())
if match:
current_tool = match.group(1).strip() or None
await self._sink(
{
"kind": "progress",
"task_id": task_id,
"text": str(text or ""),
"current_tool": current_tool,
}
)
@@ -0,0 +1,27 @@
"""Periodic reconcile loop for cross-process board consistency."""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
class ReconcileLoop:
"""Run a callback periodically until stopped."""
def __init__(self, interval_seconds: float, callback: Callable[[], Awaitable[None]]) -> None:
self.interval_seconds = max(0.5, float(interval_seconds))
self._callback = callback
self._stop_event = asyncio.Event()
async def run(self) -> None:
while not self._stop_event.is_set():
await self._callback()
try:
await asyncio.wait_for(self._stop_event.wait(), timeout=self.interval_seconds)
except asyncio.TimeoutError:
continue
def stop(self) -> None:
self._stop_event.set()
+297
View File
@@ -0,0 +1,297 @@
"""CLI-board-specific company runtime recovery manager.
Independent from office_ui/recovery_manager.py — same Core Engine APIs,
different notification path (TUI event bridge instead of WebSocket broadcast).
"""
from __future__ import annotations
import asyncio
import logging
import time
from dataclasses import dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING, Any
from opc.layer2_organization.work_item_identity import work_item_projection_id_from_metadata
from opc.layer2_organization.work_item_transition import apply_task_status_transition
if TYPE_CHECKING:
from .engine_facade import EngineFacade
logger = logging.getLogger(__name__)
@dataclass
class RecoverableWorkItem:
projection_id: str
title: str
task_id: str
status: str
interrupted: bool
previous_status: str = ""
@dataclass
class InterruptedCompanyRuntime:
parent_session_id: str
parent_task_id: str
project_id: str
title: str
profile: str
interrupted_at: str
work_items: list[RecoverableWorkItem] = field(default_factory=list)
@dataclass
class RecoveryStatus:
interrupted: list[InterruptedCompanyRuntime] = field(default_factory=list)
active_recoveries: list[str] = field(default_factory=list)
scanned_at: float = 0.0
def _is_interrupted(task: Any) -> bool:
from opc.core.models import TaskStatus
if task.status != TaskStatus.FAILED:
return False
meta = getattr(task, "metadata", {}) or {}
if meta.get("interrupted_recovery"):
return True
result = getattr(task, "result", {}) or {}
artifacts = result.get("artifacts", {}) or {}
return bool(artifacts.get("interrupted"))
class CliRecoveryManager:
"""Scan for interrupted company runtimes and provide resume/cancel."""
_CACHE_TTL = 10.0
def __init__(self, facade: EngineFacade) -> None:
self._facade = facade
self._lock = asyncio.Lock()
self._active: dict[str, asyncio.Task[Any]] = {}
self._cached: RecoveryStatus | None = None
self._cache_until: float = 0.0
@property
def _project_id(self) -> str:
return self._facade.project_id or "default"
async def get_status(self) -> RecoveryStatus:
now = time.time()
if self._cached is not None and now < self._cache_until:
self._cached.active_recoveries = list(self._active.keys())
return self._cached
status = await self.scan()
self._cached = status
self._cache_until = now + self._CACHE_TTL
return status
async def scan(self) -> RecoveryStatus:
engine = await self._facade.ensure_ready()
if not engine.store:
return RecoveryStatus()
try:
all_tasks = await engine.store.get_tasks(project_id=self._project_id)
except Exception as exc:
logger.warning("Recovery scan failed: %s", exc)
return RecoveryStatus()
groups: dict[str, list[Any]] = {}
tasks_by_session: dict[str, Any] = {}
for task in all_tasks:
sid = str(getattr(task, "session_id", "") or "").strip()
if sid:
tasks_by_session[sid] = task
parent_sid = str(getattr(task, "parent_session_id", "") or "").strip()
projection_id = work_item_projection_id_from_metadata(getattr(task, "metadata", {}) or {})
if parent_sid and projection_id:
groups.setdefault(parent_sid, []).append(task)
from opc.core.models import TaskStatus
interrupted: list[InterruptedCompanyRuntime] = []
for parent_sid, tasks in groups.items():
if not any(_is_interrupted(t) for t in tasks):
continue
non_terminal = [t for t in tasks if t.status not in (TaskStatus.DONE, TaskStatus.CANCELLED)]
if not non_terminal:
continue
parent_task = tasks_by_session.get(parent_sid)
parent_task_id = parent_task.id if parent_task else parent_sid
title = parent_task.title if parent_task else "Unknown company runtime"
work_items: list[RecoverableWorkItem] = []
earliest = ""
for t in sorted(tasks, key=lambda x: (x.created_at, x.id)):
meta = dict(getattr(t, "metadata", {}) or {})
rmeta = meta.get("interrupted_recovery", {})
is_int = _is_interrupted(t)
if is_int and rmeta.get("detected_at", ""):
det = rmeta["detected_at"]
if not earliest or det < earliest:
earliest = det
work_items.append(RecoverableWorkItem(
projection_id=work_item_projection_id_from_metadata(meta, fallback=t.id),
title=t.title,
task_id=t.id,
status=t.status.value if hasattr(t.status, "value") else str(t.status),
interrupted=is_int,
previous_status=rmeta.get("previous_status", ""),
))
profile = ""
for t in tasks:
p = (getattr(t, "metadata", {}) or {}).get("company_profile", "")
if p:
profile = p
break
interrupted.append(InterruptedCompanyRuntime(
parent_session_id=parent_sid,
parent_task_id=parent_task_id,
project_id=self._project_id,
title=title,
profile=profile,
interrupted_at=earliest or datetime.now().isoformat(),
work_items=work_items,
))
return RecoveryStatus(
interrupted=interrupted,
active_recoveries=list(self._active.keys()),
scanned_at=time.time(),
)
async def resume(self, parent_task_id: str) -> dict[str, Any]:
async with self._lock:
if parent_task_id in self._active:
return {"ok": False, "error": "already_in_progress"}
status = await self.scan()
wf = next((w for w in status.interrupted if w.parent_task_id == parent_task_id), None)
if not wf:
return {"ok": False, "error": "not_found"}
engine = await self._facade.ensure_ready()
snapshot = await engine._load_company_runtime_snapshot(wf.parent_session_id)
if not snapshot:
return {"ok": False, "error": "snapshot_unavailable"}
plan, tasks = snapshot
await self._clean_checkpoints(wf, tasks)
from opc.core.models import TaskStatus
resumed_ids: list[str] = []
for task in tasks:
if task.status == TaskStatus.DONE:
continue
if task.status in (TaskStatus.FAILED, TaskStatus.BLOCKED):
task.result = None
task.execution_lock = False
task.execution_locked_at = None
meta = dict(task.metadata)
meta.pop("interrupted_recovery", None)
progress = list(meta.get("progress_log", []))
progress.append(f"[Recovery] Resumed at {datetime.now().isoformat()}")
meta["progress_log"] = progress[-20:]
task.metadata = meta
try:
await apply_task_status_transition(
engine.store,
task,
target_status_or_phase=TaskStatus.PENDING,
reason="cli_recovery_resume",
release_claim=True,
)
except Exception as exc:
logger.warning("Recovery resume skipped %s: %s", task.id, exc)
continue
if task.status != TaskStatus.PENDING:
logger.warning("Recovery resume preserved non-runnable phase for %s", task.id)
continue
await engine.store.save_task(task)
resumed_ids.append(work_item_projection_id_from_metadata(meta, fallback=task.id))
if not resumed_ids:
return {"ok": False, "error": "no_work_items_to_resume"}
self._cache_until = 0.0
bg = asyncio.create_task(self._execute(parent_task_id, plan, tasks))
self._active[parent_task_id] = bg
return {"ok": True, "resumed_work_item_projection_ids": resumed_ids}
async def cancel(self, parent_task_id: str) -> dict[str, Any]:
async with self._lock:
bg = self._active.pop(parent_task_id, None)
if bg and not bg.done():
bg.cancel()
status = await self.scan()
wf = next((w for w in status.interrupted if w.parent_task_id == parent_task_id), None)
if not wf:
return {"ok": False, "error": "not_found"}
engine = await self._facade.ensure_ready()
snapshot = await engine._load_company_runtime_snapshot(wf.parent_session_id)
if not snapshot:
return {"ok": False, "error": "snapshot_unavailable"}
_, tasks = snapshot
from opc.core.models import TaskStatus
cancelled = 0
for task in tasks:
if task.status not in (TaskStatus.DONE, TaskStatus.CANCELLED):
try:
await apply_task_status_transition(
engine.store,
task,
target_status_or_phase=TaskStatus.CANCELLED,
reason="cli_recovery_cancel",
release_claim=True,
)
except Exception as exc:
logger.warning("Recovery cancel skipped %s: %s", task.id, exc)
continue
if task.status != TaskStatus.CANCELLED:
logger.warning("Recovery cancel preserved non-cancelled phase for %s", task.id)
continue
cancelled += 1
await self._clean_checkpoints(wf, tasks)
self._cache_until = 0.0
return {"ok": True, "cancelled_count": cancelled}
async def _execute(self, parent_task_id: str, plan: Any, tasks: list[Any]) -> None:
try:
engine = await self._facade.ensure_ready()
executor = engine.company_executor
if not executor:
raise RuntimeError("company_executor not available")
await executor.execute(plan, tasks)
except asyncio.CancelledError:
pass
except Exception as exc:
logger.warning("Recovery execution failed for %s: %s", parent_task_id, exc)
finally:
self._active.pop(parent_task_id, None)
self._cache_until = 0.0
async def _clean_checkpoints(self, wf: InterruptedCompanyRuntime, tasks: list[Any]) -> None:
engine = await self._facade.ensure_ready()
if not engine.store:
return
session_ids = {str(getattr(t, "session_id", "") or "").strip() for t in tasks}
session_ids.add(wf.parent_session_id)
session_ids.discard("")
try:
pending = await engine.store.get_pending_checkpoints(project_id=wf.project_id)
for cp in pending:
if str(cp.session_id or "").strip() in session_ids:
await engine.store.resolve_execution_checkpoint(cp.checkpoint_id, status="cancelled")
except Exception as exc:
logger.debug("Checkpoint cleanup error: %s", exc)
+2
View File
@@ -0,0 +1,2 @@
"""State helpers for the CLI board plugin."""
+259
View File
@@ -0,0 +1,259 @@
"""State models used by the OpenOPC CLI board."""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Any, Literal
PaneFocus = Literal["session-rail", "main", "context"]
ViewMode = Literal["kanban", "list", "focus", "pipeline", "org"]
ContextTab = Literal["detail", "session", "activity"]
DensityMode = Literal["compact", "comfortable"]
@dataclass
class PendingCheckpointView:
checkpoint_id: str
checkpoint_type: str
status: str
session_id: str | None
task_id: str | None
summary: str
prompt: str
payload: dict[str, Any] = field(default_factory=dict)
@property
def short_label(self) -> str:
return f"{self.checkpoint_type} ({self.status})"
@dataclass
class LinkedExecutionView:
task_id: str
title: str
status: str
assigned_to: str
session_id: str | None
created_at: float
updated_at: float
runtime_task_id: str | None = None
execution_turn_id: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass
class SessionMessageView:
message_id: str
role: str
sender_name: str
content: str
created_at: float
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass
class RuntimeTaskState:
status: str = "idle"
current_tool: str | None = None
iteration: int = 0
tool_elapsed_ms: int = 0
last_tool_summary: str = ""
context_tokens: int = 0
context_window: int = 0
context_remaining_pct: int = 0
turn_cost_usd: float = 0.0
session_cost_usd: float = 0.0
pending_permission_count: int = 0
drain_mode: str = "idle"
progress_entries: list[str] = field(default_factory=list)
updated_at: float = field(default_factory=time.time)
def push_progress(self, text: str, *, max_entries: int = 50) -> None:
entry = str(text or "").strip()
if not entry:
return
self.progress_entries.append(entry)
if len(self.progress_entries) > max_entries:
self.progress_entries = self.progress_entries[-max_entries:]
self.updated_at = time.time()
@dataclass
class BoardAlert:
alert_id: str
level: str
title: str
message: str
task_id: str | None = None
created_at: float = field(default_factory=time.time)
@dataclass
class SessionSummaryView:
task_id: str
title: str
status: str
column_id: str
session_id: str | None
updated_at: float
created_at: float
assigned_to: str = ""
priority: str | None = None
pending_checkpoint: bool = False
linked_task_count: int = 0
tags: list[str] = field(default_factory=list)
runtime_task_id: str | None = None
execution_turn_id: str | None = None
@dataclass
class BoardMetrics:
total_tasks: int = 0
visible_tasks: int = 0
filtered_tasks: int = 0
hidden_task_count: int = 0
todo_count: int = 0
in_progress_count: int = 0
in_review_count: int = 0
done_count: int = 0
running_count: int = 0
blocked_count: int = 0
failed_count: int = 0
pending_checkpoint_count: int = 0
active_session_count: int = 0
stale_task_count: int = 0
alert_count: int = 0
last_refreshed_at: float = field(default_factory=time.time)
last_runtime_update: float | None = None
@dataclass
class BoardTaskView:
task_id: str
title: str
description: str
status: str
column_id: str
priority: str | None
assignee_ids: list[str] = field(default_factory=list)
assigned_to: str = ""
tags: list[str] = field(default_factory=list)
session_id: str | None = None
created_at: float = 0.0
updated_at: float = 0.0
metadata: dict[str, Any] = field(default_factory=dict)
pending_checkpoint: PendingCheckpointView | None = None
linked_task_count: int = 0
result_content: str | None = None
artifacts: list[Any] = field(default_factory=list)
origin_task_id: str | None = None
display_id: str = ""
dependencies: list[str] = field(default_factory=list)
# Company-mode fields: card identity is the DelegationWorkItem.
# `task_id` carries `work_item_id` in company mode; the runtime Task and its
# session are referenced via runtime_task_id / execution_turn_id aliases.
work_item_id: str | None = None
runtime_task_id: str | None = None
execution_turn_id: str | None = None
@property
def is_terminal(self) -> bool:
return self.status in {"done", "failed", "cancelled"}
@dataclass
class TaskDetailView:
task: BoardTaskView
transcript: list[SessionMessageView] = field(default_factory=list)
linked_executions: list[LinkedExecutionView] = field(default_factory=list)
progress_entries: list[str] = field(default_factory=list)
pending_checkpoint: PendingCheckpointView | None = None
result_content: str | None = None
artifacts: list[Any] = field(default_factory=list)
context_preview: str | None = None
@dataclass
class OrgRoleView:
"""One role in the org tree."""
role_id: str
name: str
responsibility: str
reports_to: str = "owner"
employee_count: int = 0
children: list[OrgRoleView] = field(default_factory=list)
@dataclass
class OrgEmployeeView:
"""One employee summary."""
employee_id: str
name: str
role_id: str
category: str = ""
domains: list[str] = field(default_factory=list)
seniority: str = "junior"
@dataclass
class OrgSnapshotView:
"""Read-only snapshot of org structure."""
role_tree: list[OrgRoleView] = field(default_factory=list)
employees: list[OrgEmployeeView] = field(default_factory=list)
company_profile: str = ""
role_count: int = 0
employee_count: int = 0
@dataclass
class PipelineWorkItemView:
"""One work-item projection in a company-mode runtime pipeline."""
projection_id: str
title: str
role_id: str
status: str = "pending" # pending | running | done | failed | cancelled | blocked
assigned_to: str = ""
task_id: str | None = None
runtime_task_id: str | None = None
execution_turn_id: str | None = None
session_id: str | None = None
elapsed_sec: float = 0.0
current_tool: str | None = None
tool_elapsed_ms: int = 0
last_tool_summary: str = ""
context_remaining_pct: int = 0
turn_cost_usd: float = 0.0
has_gate: bool = False
gate_type: str | None = None # review | approval | human_confirmation
dependencies: list[str] = field(default_factory=list)
parallel_group: str | None = None
@dataclass
class PipelineSnapshot:
"""Full pipeline state for the selected company-mode runtime."""
parent_task_id: str = ""
parent_title: str = ""
profile: str = ""
work_items: list[PipelineWorkItemView] = field(default_factory=list)
done_count: int = 0
total_count: int = 0
elapsed_sec: float = 0.0
@dataclass
class BoardSnapshot:
project_id: str
tasks: list[BoardTaskView] = field(default_factory=list)
hidden_task_count: int = 0
pending_checkpoint_count: int = 0
last_refreshed_at: float = field(default_factory=time.time)
session_summaries: list[SessionSummaryView] = field(default_factory=list)
alerts: list[BoardAlert] = field(default_factory=list)
metrics: BoardMetrics = field(default_factory=BoardMetrics)
# "standard" → cards are runtime Tasks; "company" → cards are DelegationWorkItems.
mode: str = "standard"
# When set, BoardStateStore adopts this column order (lets company mode
# surface the in-review column without hardcoding it client-side).
column_order: list[str] | None = None
+479
View File
@@ -0,0 +1,479 @@
"""In-memory board state for the Textual app."""
from __future__ import annotations
import time
from dataclasses import replace
from opc.presentation.kanban import DEFAULT_KANBAN_COLUMNS
from .models import (
BoardAlert,
BoardMetrics,
BoardSnapshot,
BoardTaskView,
ContextTab,
DensityMode,
PaneFocus,
RuntimeTaskState,
SessionSummaryView,
ViewMode,
)
_RUNTIME_ACTIVE = {"running", "reflecting", "tool_active"}
_STATUS_ACTIVE = {"running", "idle", "blocked", "awaiting_peer", "awaiting_review"}
_STATUS_BLOCKED = {"blocked", "awaiting_peer", "awaiting_review"}
class BoardStateStore:
"""Keeps a filtered, runtime-enriched view of board state."""
def __init__(self) -> None:
self.snapshot = BoardSnapshot(project_id="default")
self.runtime_by_task: dict[str, RuntimeTaskState] = {}
self.search_query: str = ""
self.show_done: bool = True
self.selected_task_id: str | None = None
self.column_order = [column.column_id for column in DEFAULT_KANBAN_COLUMNS]
self.view_mode: ViewMode = "kanban"
self.pane_focus: PaneFocus = "main"
self.context_tab: ContextTab = "detail"
self.density_mode: DensityMode = "compact"
def replace_snapshot(self, snapshot: BoardSnapshot) -> None:
self.snapshot = snapshot
if snapshot.column_order:
self.column_order = list(snapshot.column_order)
self._ensure_selection()
def set_search_query(self, query: str) -> None:
self.search_query = str(query or "").strip()
self._ensure_selection()
def toggle_show_done(self) -> bool:
self.show_done = not self.show_done
self._ensure_selection()
return self.show_done
def toggle_density(self) -> DensityMode:
self.density_mode = "comfortable" if self.density_mode == "compact" else "compact"
return self.density_mode
def set_view_mode(self, mode: ViewMode) -> ViewMode:
self.view_mode = mode
if mode in {"focus", "pipeline", "org"}:
self.pane_focus = "main"
self._ensure_selection()
return self.view_mode
def cycle_view_mode(self, delta: int = 1) -> ViewMode:
order: tuple[ViewMode, ...] = ("kanban", "list", "focus", "pipeline", "org")
idx = order.index(self.view_mode) if self.view_mode in order else 0
self.view_mode = order[(idx + delta) % len(order)]
if self.view_mode in {"focus", "pipeline", "org"}:
self.pane_focus = "main"
return self.view_mode
def set_pane_focus(self, focus: PaneFocus) -> PaneFocus:
self.pane_focus = focus
return self.pane_focus
def cycle_pane_focus(self, delta: int = 1) -> PaneFocus:
order: list[PaneFocus] = ["session-rail", "main", "context"]
if self.view_mode in {"focus", "pipeline", "org"}:
order = ["main", "context"]
current = self.pane_focus if self.pane_focus in order else "main"
idx = order.index(current)
self.pane_focus = order[(idx + delta) % len(order)]
return self.pane_focus
def set_context_tab(self, tab: ContextTab) -> ContextTab:
self.context_tab = tab
return self.context_tab
def cycle_context_tab(self, delta: int = 1) -> ContextTab:
order: tuple[ContextTab, ...] = ("detail", "session", "activity")
idx = order.index(self.context_tab)
self.context_tab = order[(idx + delta) % len(order)]
return self.context_tab
def clear_runtime(self) -> None:
self.runtime_by_task.clear()
def apply_task_status(self, task_id: str, status: str, *, column_id: str | None = None) -> None:
for index, task in enumerate(self.snapshot.tasks):
if task.task_id != task_id:
continue
self.snapshot.tasks[index] = replace(
task,
status=status,
column_id=column_id or task.column_id,
updated_at=time.time(),
)
break
self._ensure_selection()
def apply_runtime_update(
self,
task_id: str,
*,
status: str,
current_tool: str | None = None,
iteration: int | None = None,
tool_elapsed_ms: int | None = None,
last_tool_summary: str | None = None,
context_tokens: int | None = None,
context_window: int | None = None,
context_remaining_pct: int | None = None,
turn_cost_usd: float | None = None,
session_cost_usd: float | None = None,
pending_permission_count: int | None = None,
drain_mode: str | None = None,
) -> None:
runtime = self.runtime_by_task.setdefault(task_id, RuntimeTaskState())
runtime.status = status
runtime.current_tool = current_tool
if iteration is not None:
runtime.iteration = iteration
if tool_elapsed_ms is not None:
runtime.tool_elapsed_ms = int(tool_elapsed_ms)
if last_tool_summary is not None:
runtime.last_tool_summary = str(last_tool_summary)
if context_tokens is not None:
runtime.context_tokens = int(context_tokens)
if context_window is not None:
runtime.context_window = int(context_window)
if context_remaining_pct is not None:
runtime.context_remaining_pct = int(context_remaining_pct)
if turn_cost_usd is not None:
runtime.turn_cost_usd = float(turn_cost_usd)
if session_cost_usd is not None:
runtime.session_cost_usd = float(session_cost_usd)
if pending_permission_count is not None:
runtime.pending_permission_count = int(pending_permission_count)
if drain_mode is not None:
runtime.drain_mode = str(drain_mode)
runtime.updated_at = time.time()
def append_progress(self, task_id: str, text: str) -> None:
runtime = self.runtime_by_task.setdefault(task_id, RuntimeTaskState())
runtime.push_progress(text)
def runtime_for(self, task_id: str) -> RuntimeTaskState | None:
return self.runtime_by_task.get(task_id)
def all_tasks(self) -> list[BoardTaskView]:
return list(self.snapshot.tasks)
def filtered_tasks(self) -> list[BoardTaskView]:
results: list[BoardTaskView] = []
for task in self.snapshot.tasks:
if not self.show_done and task.column_id == "done":
continue
if self.search_query and not self._matches_query(
task.title,
task.description,
task.status,
task.assigned_to,
" ".join(task.assignee_ids),
" ".join(task.tags),
):
continue
results.append(task)
return results
def linear_tasks(self) -> list[BoardTaskView]:
return sorted(
self.filtered_tasks(),
key=lambda task: (
self.column_order.index(task.column_id) if task.column_id in self.column_order else len(self.column_order),
-float(task.updated_at),
task.title.casefold(),
),
)
def tasks_by_column(self) -> dict[str, list[BoardTaskView]]:
grouped = {column_id: [] for column_id in self.column_order}
for task in self.filtered_tasks():
grouped.setdefault(task.column_id, []).append(task)
for tasks in grouped.values():
tasks.sort(key=lambda task: (-float(task.updated_at), task.title.casefold()))
return grouped
def board_counts(self) -> dict[str, int]:
grouped = self.tasks_by_column()
return {column_id: len(grouped.get(column_id, [])) for column_id in self.column_order}
def filtered_session_summaries(self) -> list[SessionSummaryView]:
task_ids = {task.task_id for task in self.filtered_tasks()}
source = self.snapshot.session_summaries or [self._summary_from_task(task) for task in self.snapshot.tasks]
results: list[SessionSummaryView] = []
for summary in source:
if summary.task_id not in task_ids:
continue
if self.search_query and not self._matches_query(
summary.title,
summary.status,
summary.assigned_to,
" ".join(summary.tags),
summary.priority or "",
):
continue
results.append(summary)
results.sort(
key=lambda item: (
0 if self._is_live_summary(item) else 1 if item.column_id != "done" else 2,
-float(item.updated_at),
item.title.casefold(),
)
)
return results
def session_groups(self) -> dict[str, list[SessionSummaryView]]:
groups = {
"Live": [],
"Queue": [],
"Archive": [],
}
for summary in self.filtered_session_summaries():
if self._is_live_summary(summary):
groups["Live"].append(summary)
elif summary.column_id == "done":
groups["Archive"].append(summary)
else:
groups["Queue"].append(summary)
return groups
def selected_task(self) -> BoardTaskView | None:
if not self.selected_task_id:
return None
for task in self.filtered_tasks():
if task.task_id == self.selected_task_id:
return task
return None
def selected_summary(self) -> SessionSummaryView | None:
if not self.selected_task_id:
return None
for summary in self.filtered_session_summaries():
if summary.task_id == self.selected_task_id:
return summary
return None
def selected_runtime(self) -> RuntimeTaskState | None:
if not self.selected_task_id:
return None
return self.runtime_by_task.get(self.selected_task_id)
def select_task(self, task_id: str | None) -> BoardTaskView | None:
self.selected_task_id = task_id
return self._ensure_selection()
def move_selection(self, *, column_delta: int = 0, row_delta: int = 0) -> BoardTaskView | None:
grouped = self.tasks_by_column()
if not any(grouped.values()):
self.selected_task_id = None
return None
selected = self.selected_task()
if selected is None:
return self._ensure_selection()
current_column_id = selected.column_id if selected.column_id in self.column_order else self.column_order[0]
current_column_index = self.column_order.index(current_column_id)
current_column_tasks = grouped.get(current_column_id, [])
current_row_index = next(
(index for index, item in enumerate(current_column_tasks) if item.task_id == selected.task_id),
0,
)
target_column_index = current_column_index
if column_delta:
step = 1 if column_delta > 0 else -1
candidate_index = current_column_index
while 0 <= candidate_index + step < len(self.column_order):
candidate_index += step
candidate_tasks = grouped.get(self.column_order[candidate_index], [])
if candidate_tasks:
target_column_index = candidate_index
break
target_column_id = self.column_order[target_column_index]
target_tasks = grouped.get(target_column_id, [])
if not target_tasks:
return selected
if row_delta:
target_row_index = max(0, min(len(target_tasks) - 1, current_row_index + row_delta))
else:
target_row_index = min(current_row_index, len(target_tasks) - 1)
self.selected_task_id = target_tasks[target_row_index].task_id
return target_tasks[target_row_index]
def move_linear_selection(self, delta: int) -> BoardTaskView | None:
tasks = self.linear_tasks()
if not tasks:
self.selected_task_id = None
return None
if not self.selected_task_id:
self.selected_task_id = tasks[0].task_id
return tasks[0]
current_index = next((idx for idx, task in enumerate(tasks) if task.task_id == self.selected_task_id), 0)
target = max(0, min(len(tasks) - 1, current_index + delta))
self.selected_task_id = tasks[target].task_id
return tasks[target]
def move_session_selection(self, delta: int) -> SessionSummaryView | None:
summaries = self.filtered_session_summaries()
if not summaries:
self.selected_task_id = None
return None
if not self.selected_task_id:
self.selected_task_id = summaries[0].task_id
return summaries[0]
current_index = next((idx for idx, item in enumerate(summaries) if item.task_id == self.selected_task_id), 0)
target = max(0, min(len(summaries) - 1, current_index + delta))
self.selected_task_id = summaries[target].task_id
return summaries[target]
def metrics(self) -> BoardMetrics:
now = time.time()
filtered_tasks = self.filtered_tasks()
all_tasks = self.snapshot.tasks
runtime_updates = [runtime.updated_at for runtime in self.runtime_by_task.values()]
stale_count = 0
running_count = 0
blocked_count = 0
failed_count = 0
active_session_count = 0
for task in all_tasks:
runtime = self.runtime_for(task.task_id)
if task.session_id:
active_session_count += 1
if task.status in _STATUS_BLOCKED:
blocked_count += 1
if task.status in {"failed", "cancelled"}:
failed_count += 1
if task.status in _STATUS_ACTIVE or (runtime and runtime.status in _RUNTIME_ACTIVE):
running_count += 1
freshness = max(float(task.updated_at or 0.0), float(runtime.updated_at if runtime else 0.0))
if freshness and not task.is_terminal and now - freshness > 600:
stale_count += 1
counts = self.board_counts()
metrics = BoardMetrics(
total_tasks=len(all_tasks) + int(self.snapshot.hidden_task_count),
visible_tasks=len(all_tasks),
filtered_tasks=len(filtered_tasks),
hidden_task_count=int(self.snapshot.hidden_task_count),
todo_count=counts.get("todo", 0),
in_progress_count=counts.get("in-progress", 0),
done_count=counts.get("done", 0),
running_count=running_count,
blocked_count=blocked_count,
failed_count=failed_count,
pending_checkpoint_count=int(self.snapshot.pending_checkpoint_count),
active_session_count=active_session_count,
stale_task_count=stale_count,
last_refreshed_at=float(self.snapshot.last_refreshed_at),
last_runtime_update=max(runtime_updates) if runtime_updates else None,
)
metrics.alert_count = len(self.alerts())
return metrics
def alerts(self) -> list[BoardAlert]:
alerts: dict[str, BoardAlert] = {alert.alert_id: alert for alert in self.snapshot.alerts}
now = time.time()
for task in self.snapshot.tasks:
runtime = self.runtime_for(task.task_id)
if task.pending_checkpoint is not None:
alerts[f"checkpoint:{task.task_id}"] = BoardAlert(
alert_id=f"checkpoint:{task.task_id}",
level="warn",
title="Checkpoint pending",
message=f"{task.title} is waiting for human feedback.",
task_id=task.task_id,
created_at=float(task.updated_at or task.created_at),
)
if task.status in _STATUS_BLOCKED:
alerts[f"blocked:{task.task_id}"] = BoardAlert(
alert_id=f"blocked:{task.task_id}",
level="warn",
title="Task paused",
message=f"{task.title} is paused in `{task.status}`.",
task_id=task.task_id,
created_at=float(task.updated_at or task.created_at),
)
if task.status in {"failed", "cancelled"}:
alerts[f"terminal:{task.task_id}"] = BoardAlert(
alert_id=f"terminal:{task.task_id}",
level="error",
title="Task ended abnormally",
message=f"{task.title} finished with status `{task.status}`.",
task_id=task.task_id,
created_at=float(task.updated_at or task.created_at),
)
freshness = max(float(task.updated_at or 0.0), float(runtime.updated_at if runtime else 0.0))
if freshness and not task.is_terminal and now - freshness > 600:
alerts[f"stale:{task.task_id}"] = BoardAlert(
alert_id=f"stale:{task.task_id}",
level="warn",
title="Stale task",
message=f"{task.title} has been quiet for more than 10 minutes.",
task_id=task.task_id,
created_at=freshness,
)
level_weight = {"error": 0, "warn": 1, "info": 2}
return sorted(
alerts.values(),
key=lambda alert: (level_weight.get(alert.level, 99), -float(alert.created_at)),
)
def _summary_from_task(self, task: BoardTaskView) -> SessionSummaryView:
return SessionSummaryView(
task_id=task.task_id,
title=task.title,
status=task.status,
column_id=task.column_id,
session_id=task.session_id,
updated_at=float(task.updated_at),
created_at=float(task.created_at),
assigned_to=task.assigned_to,
priority=task.priority,
pending_checkpoint=task.pending_checkpoint is not None,
linked_task_count=task.linked_task_count,
tags=list(task.tags),
runtime_task_id=task.runtime_task_id,
execution_turn_id=task.execution_turn_id or task.runtime_task_id,
)
def _is_live_summary(self, summary: SessionSummaryView) -> bool:
runtime = self.runtime_for(summary.task_id)
return (
summary.pending_checkpoint
or summary.column_id == "in-progress"
or summary.status in _STATUS_ACTIVE
or (runtime is not None and runtime.status in _RUNTIME_ACTIVE)
)
def _matches_query(self, *parts: str) -> bool:
if not self.search_query:
return True
haystack = " ".join(part for part in parts if part).casefold()
return self.search_query.casefold() in haystack
def _ensure_selection(self) -> BoardTaskView | None:
selected = self.selected_task()
if selected is not None:
return selected
grouped = self.tasks_by_column()
for column_id in self.column_order:
tasks = grouped.get(column_id, [])
if tasks:
self.selected_task_id = tasks[0].task_id
return tasks[0]
self.selected_task_id = None
return None
+2
View File
@@ -0,0 +1,2 @@
"""Textual application package for the CLI board."""
File diff suppressed because it is too large Load Diff
+79
View File
@@ -0,0 +1,79 @@
Screen {
layout: vertical;
background: #020617;
color: #e2e8f0;
}
Header {
background: #0f172a;
color: #e2e8f0;
}
Footer {
background: #0f172a;
color: #94a3b8;
}
#metrics-bar {
height: auto;
padding: 0 1 1 1;
}
#body {
height: 1fr;
padding: 0 1 0 1;
}
#session-shell,
#main-shell,
#context-shell {
height: 1fr;
min-height: 12;
border: round #1e293b;
background: #020617;
padding: 1;
}
#session-shell {
width: 30;
min-width: 26;
max-width: 34;
}
#main-shell {
width: 2fr;
min-width: 48;
margin: 0 1;
}
#context-shell {
width: 42;
min-width: 34;
max-width: 56;
}
#session-scroll,
#main-scroll,
#context-scroll {
height: 1fr;
}
#context-tabs {
height: auto;
margin-bottom: 1;
}
#status-bar {
height: auto;
padding: 0 1;
background: #0f172a;
color: #e2e8f0;
}
.hidden {
display: none;
}
.pane-focused {
border: round #38bdf8;
}
@@ -0,0 +1,2 @@
"""Modal screens for the CLI board Textual app."""
+67
View File
@@ -0,0 +1,67 @@
"""Help modal for the CLI board."""
from __future__ import annotations
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.screen import ModalScreen
from textual.widgets import Static
class HelpScreen(ModalScreen[None]):
"""Display keyboard shortcuts."""
DEFAULT_CSS = """
HelpScreen {
align: center middle;
}
.help-dialog {
width: 96;
max-width: 95%;
height: auto;
border: solid $primary;
background: $surface;
padding: 1 2;
}
.help-copy {
margin-top: 1;
}
"""
BINDINGS = [("escape", "close", "Close"), ("enter", "close", "Close")]
def compose(self) -> ComposeResult:
help_text = (
"Navigation\n"
" Arrow keys / h j k l: move selection\n"
" Tab / Shift+Tab: cycle pane focus\n"
" Enter: open focus view or advance context tab\n"
" Space: toggle density\n"
"\n"
"Views\n"
" 1: kanban 2: list 3: focus 4: projection 5: org\n"
" E: switch execution mode (task / company / custom)\n"
"\n"
"Task Actions\n"
" n: create task g: run selected task\n"
" s: reply in session m: move between columns\n"
" a / d: approve / deny checkpoint\n"
" e: checkpoint feedback (approve/deny with message)\n"
" c: done x: cancel t: retry w: runtime recovery\n"
"\n"
"Session Management\n"
" R: rename session D: delete session\n"
"\n"
"Search and Tools\n"
" /: search filter f: toggle done visibility\n"
" r: refresh board Ctrl+K or :: command palette\n"
" ?: this help q: quit"
)
with Vertical(classes="help-dialog"):
yield Static("OpenOPC CLI Board Help", id="help-title")
yield Static(help_text, classes="help-copy")
def action_close(self) -> None:
self.dismiss(None)
@@ -0,0 +1,143 @@
"""Command palette for the CLI board."""
from __future__ import annotations
from dataclasses import dataclass
from rich.console import Group, RenderableType
from rich.text import Text
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.screen import ModalScreen
from textual.widgets import Input, Static
@dataclass(frozen=True)
class PaletteCommand:
command_id: str
label: str
description: str = ""
keys: str = ""
class CommandPaletteScreen(ModalScreen[str | None]):
"""Small command palette with filtering and keyboard navigation."""
DEFAULT_CSS = """
CommandPaletteScreen {
align: center middle;
}
.palette-dialog {
width: 96;
max-width: 95%;
height: auto;
border: solid $primary;
background: $surface;
padding: 1 2;
}
#palette-filter {
margin-top: 1;
}
#palette-list {
margin-top: 1;
max-height: 18;
}
.palette-help {
margin-top: 1;
color: $text-muted;
}
"""
BINDINGS = [
("escape", "close", "Close"),
("down", "cursor_down", "Down"),
("up", "cursor_up", "Up"),
("enter", "submit", "Run"),
]
def __init__(self, *, title: str, commands: list[PaletteCommand]) -> None:
super().__init__()
self.title_text = title
self.commands = list(commands)
self.cursor = 0
def compose(self) -> ComposeResult:
with Vertical(classes="palette-dialog"):
yield Static(self.title_text, id="palette-title")
yield Input(placeholder="Type to filter commands", id="palette-filter")
yield Static(id="palette-list")
yield Static("Enter to run, Esc to close, Up/Down to navigate.", classes="palette-help")
def on_mount(self) -> None:
self.set_focus(self.query_one("#palette-filter", Input))
self._refresh_list()
def on_input_changed(self, event: Input.Changed) -> None:
if event.input.id != "palette-filter":
return
self.cursor = 0
self._refresh_list()
def on_input_submitted(self, event: Input.Submitted) -> None:
if event.input.id == "palette-filter":
self.action_submit()
def action_cursor_down(self) -> None:
commands = self._filtered_commands()
if not commands:
return
self.cursor = min(len(commands) - 1, self.cursor + 1)
self._refresh_list()
def action_cursor_up(self) -> None:
commands = self._filtered_commands()
if not commands:
return
self.cursor = max(0, self.cursor - 1)
self._refresh_list()
def action_submit(self) -> None:
commands = self._filtered_commands()
if not commands:
self.dismiss(None)
return
self.dismiss(commands[self.cursor].command_id)
def action_close(self) -> None:
self.dismiss(None)
def _refresh_list(self) -> None:
self.query_one("#palette-list", Static).update(self._render_list())
def _render_list(self) -> RenderableType:
commands = self._filtered_commands()
if not commands:
return Text("No commands match the current query.", style="dim")
rows: list[Text] = []
for index, command in enumerate(commands):
selected = index == self.cursor
row = Text(style="black on #22d3ee" if selected else "white")
row.append(command.label, style="bold" if not selected else "bold black on #22d3ee")
if command.keys:
row.append(f" {command.keys}", style="dim" if not selected else "black on #22d3ee")
if command.description:
row.append(f"\n{command.description}", style="dim" if not selected else "black on #22d3ee")
rows.append(row)
return Group(*rows)
def _filtered_commands(self) -> list[PaletteCommand]:
query = self.query_one("#palette-filter", Input).value.strip().casefold()
if not query:
return self.commands
filtered = [
command
for command in self.commands
if query in " ".join([command.label, command.description, command.keys]).casefold()
]
if self.cursor >= len(filtered):
self.cursor = max(0, len(filtered) - 1)
return filtered
+152
View File
@@ -0,0 +1,152 @@
"""Reusable modal prompt screen."""
from __future__ import annotations
from dataclasses import dataclass
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import Button, Input, Label, Static, TextArea
@dataclass(frozen=True)
class PromptField:
key: str
label: str
value: str = ""
placeholder: str = ""
password: bool = False
multiline: bool = False
class PromptScreen(ModalScreen[dict[str, str] | None]):
"""Simple form dialog used by the CLI board."""
DEFAULT_CSS = """
PromptScreen {
align: center middle;
}
.prompt-dialog {
width: 88;
max-width: 90%;
height: auto;
border: solid $primary;
background: $surface;
padding: 1 2;
}
.prompt-actions {
align-horizontal: right;
height: auto;
margin-top: 1;
}
.prompt-field {
margin-top: 1;
}
.prompt-title {
text-style: bold;
}
.prompt-help {
color: $text-muted;
margin-top: 1;
}
.prompt-textarea {
height: 6;
margin-top: 1;
}
"""
BINDINGS = [("escape", "cancel", "Cancel")]
def __init__(
self,
*,
title: str,
fields: list[PromptField],
help_text: str = "",
confirm_label: str = "Confirm",
) -> None:
super().__init__()
self.title_text = title
self.fields = fields
self.help_text = help_text
self.confirm_label = confirm_label
def compose(self) -> ComposeResult:
with Vertical(classes="prompt-dialog"):
yield Static(self.title_text, id="prompt-title", classes="prompt-title")
for field in self.fields:
yield Label(field.label, classes="prompt-field")
if field.multiline:
yield TextArea(
field.value,
id=f"field-{field.key}",
classes="prompt-textarea",
tab_behavior="indent",
)
else:
yield Input(
value=field.value,
placeholder=field.placeholder,
password=field.password,
id=f"field-{field.key}",
)
if self.help_text:
yield Static(self.help_text, classes="prompt-help")
with Horizontal(classes="prompt-actions"):
yield Button("Cancel", id="cancel")
yield Button(self.confirm_label, id="confirm", variant="primary")
def on_mount(self) -> None:
if self.fields:
first_id = f"field-{self.fields[0].key}"
try:
widget = self.query_one(f"#{first_id}")
self.set_focus(widget)
except Exception:
pass
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "cancel":
self.dismiss(None)
return
if event.button.id == "confirm":
self.dismiss(self._collect_values())
def on_input_submitted(self, event: Input.Submitted) -> None:
input_field_ids = [f"field-{f.key}" for f in self.fields if not f.multiline]
if event.input.id not in input_field_ids:
return
# Find position among ALL fields (not just Input fields)
all_field_ids = [f"field-{f.key}" for f in self.fields]
idx = all_field_ids.index(event.input.id)
if idx == len(all_field_ids) - 1:
self.dismiss(self._collect_values())
return
next_id = all_field_ids[idx + 1]
try:
next_widget = self.query_one(f"#{next_id}")
self.set_focus(next_widget)
except Exception:
self.dismiss(self._collect_values())
def action_cancel(self) -> None:
self.dismiss(None)
def _collect_values(self) -> dict[str, str]:
result: dict[str, str] = {}
for field in self.fields:
widget_id = f"field-{field.key}"
if field.multiline:
widget = self.query_one(f"#{widget_id}", TextArea)
result[field.key] = widget.text
else:
widget = self.query_one(f"#{widget_id}", Input)
result[field.key] = widget.value
return result
@@ -0,0 +1,126 @@
"""Recovery modal screen for the CLI board."""
from __future__ import annotations
from dataclasses import dataclass
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.screen import ModalScreen
from textual.widgets import Button, Label, Static
from opc.plugins.cli_board.services.recovery import RecoveryStatus
@dataclass
class RecoveryAction:
action: str # "resume" | "cancel" | "dismiss"
parent_task_id: str = ""
class RecoveryScreen(ModalScreen[RecoveryAction | None]):
"""Show interrupted company runtimes with resume/cancel options."""
DEFAULT_CSS = """
RecoveryScreen {
align: center middle;
}
.recovery-dialog {
width: 88;
max-width: 90%;
height: auto;
max-height: 80%;
border: solid $primary;
background: $surface;
padding: 1 2;
}
.recovery-title {
text-style: bold;
margin-bottom: 1;
}
.recovery-runtime {
margin-bottom: 1;
padding: 1;
border: round $secondary;
}
.recovery-actions {
align-horizontal: right;
height: auto;
margin-top: 1;
}
.recovery-empty {
color: $text-muted;
margin: 1;
}
"""
BINDINGS = [("escape", "dismiss_screen", "Close")]
def __init__(self, status: RecoveryStatus) -> None:
super().__init__()
self.status = status
def compose(self) -> ComposeResult:
with Vertical(classes="recovery-dialog"):
yield Static("Interrupted Runtimes", classes="recovery-title")
if not self.status.interrupted:
yield Static("No interrupted runtimes found.", classes="recovery-empty")
else:
with VerticalScroll():
for wf in self.status.interrupted:
with Vertical(classes="recovery-runtime"):
# Runtime header
active = wf.parent_task_id in set(self.status.active_recoveries)
status_label = " (recovering...)" if active else ""
yield Label(f"{wf.title}{status_label}")
yield Static(
f" Profile: {wf.profile or 'unknown'} "
f"Interrupted: {wf.interrupted_at[:19] if wf.interrupted_at else '?'}"
)
# Work-item summary
done = sum(1 for s in wf.work_items if s.status == "done")
total = len(wf.work_items)
failed = sum(1 for s in wf.work_items if s.interrupted)
yield Static(
f" Work items: {done}/{total} done, {failed} interrupted"
)
if not active:
with Horizontal():
yield Button(
"Resume",
id=f"resume-{wf.parent_task_id}",
variant="primary",
)
yield Button(
"Cancel",
id=f"cancel-{wf.parent_task_id}",
variant="error",
)
with Horizontal(classes="recovery-actions"):
yield Button("Close", id="close-recovery")
def on_button_pressed(self, event: Button.Pressed) -> None:
btn_id = event.button.id or ""
if btn_id == "close-recovery":
self.dismiss(None)
return
if btn_id.startswith("resume-"):
task_id = btn_id[len("resume-"):]
self.dismiss(RecoveryAction(action="resume", parent_task_id=task_id))
return
if btn_id.startswith("cancel-"):
task_id = btn_id[len("cancel-"):]
self.dismiss(RecoveryAction(action="cancel", parent_task_id=task_id))
return
def action_dismiss_screen(self) -> None:
self.dismiss(None)
@@ -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