Initial commit
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,2 @@
|
||||
"""State helpers for the CLI board plugin."""
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Textual application package for the CLI board."""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Chat-focused task view — full conversation flow with action hints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from ..state.models import TaskDetailView
|
||||
from ..state.store import BoardStateStore
|
||||
from .render_utils import badge, format_clock, humanize_age, status_style, truncate_text
|
||||
|
||||
|
||||
class FocusTaskWidget(Static):
|
||||
"""Render a chat-centric view for the selected task."""
|
||||
|
||||
def __init__(self, state: BoardStateStore) -> None:
|
||||
super().__init__(id="focus-view")
|
||||
self.state = state
|
||||
self.detail: TaskDetailView | None = None
|
||||
|
||||
def set_detail(self, detail: TaskDetailView | None) -> None:
|
||||
self.detail = detail
|
||||
self.refresh()
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
focused = self.state.pane_focus == "main" and self.state.view_mode == "focus"
|
||||
item_label = "work item" if self.state.snapshot.mode == "company" else "task"
|
||||
if self.detail is None:
|
||||
guide = Text()
|
||||
guide.append(f"Select a {item_label} and press ", style="dim")
|
||||
guide.append("3", style="bold cyan")
|
||||
guide.append(" to open chat view.\n\n", style="dim")
|
||||
guide.append("Or press ", style="dim")
|
||||
guide.append("n", style="bold cyan")
|
||||
guide.append(f" to create a new {item_label}.", style="dim")
|
||||
return Panel(guide, title="Chat View", border_style="cyan" if focused else "white")
|
||||
|
||||
task = self.detail.task
|
||||
runtime = self.state.runtime_for(task.task_id)
|
||||
blocks: list[RenderableType] = []
|
||||
|
||||
# ── Header: title + status + action hints (always visible) ──
|
||||
blocks.append(self._render_header(task, runtime))
|
||||
blocks.append(self._render_action_hints(task))
|
||||
|
||||
# ── Conversation flow (main content) ──
|
||||
blocks.append(self._render_conversation())
|
||||
|
||||
# ── Live progress tail ──
|
||||
blocks.append(self._render_progress(runtime))
|
||||
|
||||
# ── Panel title includes task info ──
|
||||
status_sym = {
|
||||
"done": "\u2713", "running": "\u25cf", "idle": "\u25cf",
|
||||
"pending": "\u25cb", "failed": "\u2717", "cancelled": "\u2717",
|
||||
"blocked": "\u25a0",
|
||||
}.get(task.status, "\u25cb")
|
||||
title_text = f"Chat: {truncate_text(task.title, 36)} {status_sym} {task.status}"
|
||||
if runtime and runtime.current_tool:
|
||||
title_text += f" \u2699{truncate_text(runtime.current_tool, 16)}"
|
||||
|
||||
return Panel(
|
||||
Group(*blocks),
|
||||
title=title_text,
|
||||
border_style="cyan" if focused else "white",
|
||||
)
|
||||
|
||||
def _render_header(self, task: object, runtime: object) -> Text:
|
||||
text = Text()
|
||||
text.append(f"{getattr(task, 'display_id', '') or getattr(task, 'task_id', '')[:8]}", style="bold #38bdf8")
|
||||
text.append(f" {getattr(task, 'title', '')}", style="bold white")
|
||||
if getattr(task, 'assigned_to', ''):
|
||||
text.append(f" \u2022 {task.assigned_to}", style="dim")
|
||||
text.append(f" {humanize_age(getattr(task, 'updated_at', 0))}", style="dim")
|
||||
|
||||
# Badges on second line
|
||||
text.append("\n")
|
||||
text += badge(getattr(task, 'status', '').upper(), status_style(getattr(task, 'status', '')))
|
||||
if getattr(task, 'pending_checkpoint', None):
|
||||
text.append(" ")
|
||||
text += badge("REVIEW", status_style("warn"))
|
||||
if runtime and getattr(runtime, 'iteration', 0) > 0:
|
||||
text.append(f" iter:{runtime.iteration}", style="dim")
|
||||
text.append("\n")
|
||||
return text
|
||||
|
||||
def _render_conversation(self) -> Text:
|
||||
text = Text()
|
||||
transcript = self.detail.transcript if self.detail else []
|
||||
|
||||
if not transcript:
|
||||
text.append("\nNo conversation yet.\n", style="dim")
|
||||
text.append("Press ", style="dim")
|
||||
text.append("g", style="bold cyan")
|
||||
text.append(" to run this work item, or " if self.state.snapshot.mode == "company" else " to run this task, or ", style="dim")
|
||||
text.append("s", style="bold cyan")
|
||||
text.append(" to send a message.\n", style="dim")
|
||||
return text
|
||||
|
||||
text.append("\n")
|
||||
for msg in transcript[-20:]:
|
||||
timestamp = format_clock(msg.created_at)
|
||||
role = msg.role
|
||||
sender = msg.sender_name
|
||||
|
||||
# Role-based styling
|
||||
if role == "user":
|
||||
name_style = "bold #38bdf8"
|
||||
elif role in {"assistant", "subagent"}:
|
||||
name_style = "bold #22c55e"
|
||||
elif role == "system":
|
||||
name_style = "bold #f59e0b"
|
||||
else:
|
||||
name_style = "bold white"
|
||||
|
||||
text.append(f" [{timestamp}] ", style="dim")
|
||||
text.append(sender, style=name_style)
|
||||
text.append("\n")
|
||||
|
||||
# Message content — show full text, let terminal wrap naturally
|
||||
content = msg.content.strip()
|
||||
lines = content.split("\n")
|
||||
for line in lines[:30]:
|
||||
text.append(f" {line}\n", style="white")
|
||||
if len(lines) > 30:
|
||||
text.append(f" \u2026 ({len(lines) - 30} more lines)\n", style="dim")
|
||||
text.append("\n")
|
||||
|
||||
return text
|
||||
|
||||
def _render_progress(self, runtime: object) -> Text:
|
||||
text = Text()
|
||||
entries = list(self.detail.progress_entries) if self.detail else []
|
||||
if runtime and getattr(runtime, "progress_entries", None):
|
||||
entries.extend(runtime.progress_entries[-6:])
|
||||
|
||||
if not entries:
|
||||
return text
|
||||
|
||||
text.append("\u2500" * 50 + "\n", style="dim")
|
||||
for entry in entries[-6:]:
|
||||
text.append(f" {truncate_text(entry, 90)}\n", style="dim italic")
|
||||
|
||||
return text
|
||||
|
||||
def _render_action_hints(self, task: object) -> Text:
|
||||
text = Text("\n")
|
||||
has_checkpoint = getattr(task, "pending_checkpoint", None) is not None
|
||||
is_terminal = getattr(task, "status", "") in {"done", "failed", "cancelled"}
|
||||
|
||||
text.append("s", style="bold cyan")
|
||||
text.append(" reply", style="dim")
|
||||
|
||||
if has_checkpoint:
|
||||
text.append(" \u2502 ", style="dim")
|
||||
text.append("\u26a1 ", style="bold #f59e0b")
|
||||
text.append("a", style="bold #22c55e")
|
||||
text.append(" approve ", style="dim")
|
||||
text.append("d", style="bold #ef4444")
|
||||
text.append(" deny ", style="dim")
|
||||
text.append("e", style="bold cyan")
|
||||
text.append(" feedback", style="dim")
|
||||
|
||||
if not is_terminal:
|
||||
text.append(" \u2502 ", style="dim")
|
||||
text.append("g", style="bold cyan")
|
||||
text.append(" run", style="dim")
|
||||
else:
|
||||
text.append(" \u2502 ", style="dim")
|
||||
text.append("t", style="bold cyan")
|
||||
text.append(" retry", style="dim")
|
||||
|
||||
text.append(" \u2502 ", style="dim")
|
||||
text.append("1", style="bold cyan")
|
||||
text.append(" board", style="dim")
|
||||
|
||||
return text
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Main Kanban board render widget."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.columns import Columns
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from opc.presentation.kanban import DEFAULT_KANBAN_COLUMNS
|
||||
|
||||
from ..state.store import BoardStateStore
|
||||
from .render_utils import adaptive_summary, badge, humanize_age, priority_style, status_style, truncate_text
|
||||
|
||||
|
||||
class KanbanBoardWidget(Static):
|
||||
"""Render the board columns from the current state store."""
|
||||
|
||||
def __init__(self, state: BoardStateStore) -> None:
|
||||
super().__init__(id="kanban-board")
|
||||
self.state = state
|
||||
|
||||
_TASK_COLUMN_HINTS = {
|
||||
"todo": "Press n to create a task",
|
||||
"in-progress": "Select a task, press g to run",
|
||||
"done": "Completed tasks appear here",
|
||||
}
|
||||
_WORK_ITEM_COLUMN_HINTS = {
|
||||
"todo": "Press n to create a work item",
|
||||
"in-progress": "Select a work item, press g to run",
|
||||
"done": "Completed work items appear here",
|
||||
}
|
||||
|
||||
def _company_mode(self) -> bool:
|
||||
return self.state.snapshot.mode == "company"
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
grouped = self.state.tasks_by_column()
|
||||
total_tasks = sum(len(v) for v in grouped.values())
|
||||
focused = self.state.pane_focus == "main" and self.state.view_mode == "kanban"
|
||||
|
||||
# Empty board: show welcome guide instead of empty columns
|
||||
if total_tasks == 0:
|
||||
return self._render_welcome(focused)
|
||||
|
||||
panels: list[Panel] = []
|
||||
for column in DEFAULT_KANBAN_COLUMNS:
|
||||
tasks = grouped.get(column.column_id, [])
|
||||
is_selected_column = any(task.task_id == self.state.selected_task_id for task in tasks)
|
||||
active_count = sum(1 for task in tasks if task.status not in {"done", "failed", "cancelled"})
|
||||
checkpoint_count = sum(1 for task in tasks if task.pending_checkpoint is not None)
|
||||
title = f"{column.name} {len(tasks)}"
|
||||
subtitle_bits = []
|
||||
if active_count:
|
||||
subtitle_bits.append(f"live {active_count}")
|
||||
if checkpoint_count:
|
||||
subtitle_bits.append(f"review {checkpoint_count}")
|
||||
if tasks:
|
||||
renderables: list[RenderableType] = [self._render_task(task) for task in tasks]
|
||||
else:
|
||||
hints = self._WORK_ITEM_COLUMN_HINTS if self._company_mode() else self._TASK_COLUMN_HINTS
|
||||
renderables = [Text(hints.get(column.column_id, ""), style="dim")]
|
||||
panels.append(
|
||||
Panel(
|
||||
Group(*renderables),
|
||||
title=title,
|
||||
subtitle=" | ".join(subtitle_bits) if subtitle_bits else "",
|
||||
border_style="cyan" if focused and is_selected_column else "white",
|
||||
padding=(0, 1),
|
||||
)
|
||||
)
|
||||
return Columns(panels, expand=True, equal=True)
|
||||
|
||||
def _render_welcome(self, focused: bool) -> RenderableType:
|
||||
item_label = "work item" if self._company_mode() else "task"
|
||||
text = Text()
|
||||
text.append("OpenOPC CLI Board\n\n", style="bold #38bdf8")
|
||||
text.append("Get started:\n", style="bold white")
|
||||
text.append(" n", style="bold cyan")
|
||||
text.append(f" Create a {item_label} - describe what you need\n", style="white")
|
||||
text.append(" E", style="bold cyan")
|
||||
text.append(" Switch mode \u2014 choose task or company\n", style="white")
|
||||
text.append(" ?", style="bold cyan")
|
||||
text.append(" Help \u2014 see all keyboard shortcuts\n\n", style="white")
|
||||
text.append("Quick start:\n", style="bold white")
|
||||
text.append(" 1. Press ", style="dim")
|
||||
text.append("n", style="bold cyan")
|
||||
text.append(f", type your {item_label} description\n", style="dim")
|
||||
text.append(" 2. Press ", style="dim")
|
||||
text.append("g", style="bold cyan")
|
||||
text.append(" to run it with an agent\n", style="dim")
|
||||
text.append(" 3. Press ", style="dim")
|
||||
text.append("s", style="bold cyan")
|
||||
text.append(" to chat with the agent\n", style="dim")
|
||||
text.append(" 4. Press ", style="dim")
|
||||
text.append("3", style="bold cyan")
|
||||
text.append(" to see full conversation\n", style="dim")
|
||||
return Panel(text, title="Welcome [Focused]" if focused else "Welcome",
|
||||
border_style="cyan" if focused else "white")
|
||||
|
||||
def _render_task(self, task) -> Text:
|
||||
runtime = self.state.runtime_for(task.task_id)
|
||||
adaptive = adaptive_summary(task.metadata)
|
||||
selected = task.task_id == self.state.selected_task_id
|
||||
compact = self.state.density_mode == "compact"
|
||||
|
||||
text = Text()
|
||||
title_style = "bold black on #22d3ee" if selected else "bold white"
|
||||
meta_style = "black on #22d3ee" if selected else "dim"
|
||||
marker = "◆" if selected else "•"
|
||||
title = truncate_text(task.title, 28 if compact else 34)
|
||||
prefix = f"{task.display_id or task.task_id[:8]} "
|
||||
text.append(f"{marker} {prefix}{title}\n", style=title_style)
|
||||
|
||||
badges = Text(style=meta_style)
|
||||
badges += badge(task.status.upper(), status_style(task.status))
|
||||
if task.priority:
|
||||
badges.append(" ")
|
||||
badges += badge(task.priority.upper(), priority_style(task.priority))
|
||||
if task.pending_checkpoint:
|
||||
badges.append(" ")
|
||||
badges += badge("REVIEW", status_style("warn"))
|
||||
if runtime and runtime.status not in {"idle", ""}:
|
||||
badges.append(" ")
|
||||
badges += badge(runtime.status.upper(), status_style(runtime.status))
|
||||
if task.linked_task_count:
|
||||
badges.append(f" linked:{task.linked_task_count}", style=meta_style)
|
||||
text.append_text(badges)
|
||||
|
||||
meta = Text("\n", style=meta_style)
|
||||
owner = truncate_text(task.assigned_to or "unassigned", 18 if compact else 20)
|
||||
meta.append(f"{owner}", style=meta_style)
|
||||
if runtime and runtime.current_tool:
|
||||
meta.append(f" \u2699{truncate_text(runtime.current_tool, 14)}", style=meta_style)
|
||||
if runtime and runtime.iteration > 0:
|
||||
meta.append(f" iter:{runtime.iteration}", style=meta_style)
|
||||
meta.append(f" {humanize_age(task.updated_at)}", style=meta_style)
|
||||
text.append_text(meta)
|
||||
|
||||
if not compact:
|
||||
# Latest progress message
|
||||
if runtime and runtime.progress_entries:
|
||||
last_progress = truncate_text(runtime.progress_entries[-1], 48)
|
||||
text.append(f"\n> {last_progress}", style="italic " + meta_style)
|
||||
elif adaptive["blocked_reason"]:
|
||||
text.append(
|
||||
f"\n! {truncate_text(adaptive['blocked_reason'], 48)}",
|
||||
style="italic " + meta_style,
|
||||
)
|
||||
elif task.description:
|
||||
text.append(f"\n{truncate_text(task.description, 52)}", style="white" if not selected else "black on #22d3ee")
|
||||
adaptive_bits = []
|
||||
if adaptive["invalidated"]:
|
||||
adaptive_bits.append("invalidated")
|
||||
if adaptive["gate_owner"]:
|
||||
adaptive_bits.append(f"gate:{truncate_text(adaptive['gate_owner'], 12)}")
|
||||
if adaptive["missing_signals"]:
|
||||
adaptive_bits.append(
|
||||
f"signals:{truncate_text(', '.join(adaptive['missing_signals']), 18)}"
|
||||
)
|
||||
if adaptive["confidence_label"]:
|
||||
adaptive_bits.append(f"confidence:{adaptive['confidence_label']}")
|
||||
if adaptive_bits:
|
||||
text.append(
|
||||
f"\n{truncate_text(' | '.join(adaptive_bits), 52)}",
|
||||
style="italic " + meta_style,
|
||||
)
|
||||
|
||||
text.append("\n")
|
||||
return text
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Top metrics bar for the CLI board."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.columns import Columns
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from ..state.store import BoardStateStore
|
||||
from .render_utils import badge, format_clock, humanize_age, priority_style, status_style, truncate_text
|
||||
|
||||
|
||||
class MetricsBarWidget(Static):
|
||||
"""Show board-wide health, activity, and viewport state."""
|
||||
|
||||
def __init__(self, state: BoardStateStore, *, exec_mode: str = "task", company_profile: str = "corporate") -> None:
|
||||
super().__init__(id="metrics-bar")
|
||||
self.state = state
|
||||
self.exec_mode = exec_mode
|
||||
self.company_profile = company_profile
|
||||
self.pipeline_done: int = 0
|
||||
self.pipeline_total: int = 0
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
metrics = self.state.metrics()
|
||||
selected = self.state.selected_task()
|
||||
alerts = self.state.alerts()[:3]
|
||||
item_label = "work item" if self.state.snapshot.mode == "company" else "task"
|
||||
|
||||
board_text = Text()
|
||||
board_text.append(f"{self.state.snapshot.project_id}\n", style="bold white")
|
||||
board_text.append("view ", style="dim")
|
||||
board_text.append(self.state.view_mode.upper(), style="bold cyan")
|
||||
board_text.append(" focus ", style="dim")
|
||||
board_text.append(self.state.pane_focus, style="bold white")
|
||||
board_text.append(" density ", style="dim")
|
||||
board_text.append(self.state.density_mode, style="bold magenta")
|
||||
board_text.append("\nmode ", style="dim")
|
||||
board_text.append(self.exec_mode, style="bold #22c55e")
|
||||
board_text.append("/", style="dim")
|
||||
board_text.append(self.company_profile, style="bold #22c55e")
|
||||
if self.state.search_query:
|
||||
board_text.append("\nfilter ", style="dim")
|
||||
board_text.append(truncate_text(self.state.search_query, 24), style="yellow")
|
||||
|
||||
flow_text = Text()
|
||||
flow_text.append("todo ", style="dim")
|
||||
flow_text.append(str(metrics.todo_count), style="bold white")
|
||||
flow_text.append(" active ", style="dim")
|
||||
flow_text.append(str(metrics.in_progress_count), style="bold #22c55e")
|
||||
flow_text.append(" done ", style="dim")
|
||||
flow_text.append(str(metrics.done_count), style="bold #10b981")
|
||||
flow_text.append("\nrun ", style="dim")
|
||||
flow_text.append(str(metrics.running_count), style="bold #38bdf8")
|
||||
flow_text.append(" chk ", style="dim")
|
||||
flow_text.append(str(metrics.pending_checkpoint_count), style="bold #f59e0b")
|
||||
if self.pipeline_total > 0:
|
||||
ratio = min(1.0, self.pipeline_done / self.pipeline_total)
|
||||
bar_w = 8
|
||||
filled = int(ratio * bar_w)
|
||||
flow_text.append("\nproj ", style="dim")
|
||||
flow_text.append("\u2588" * filled, style="bold #22c55e")
|
||||
flow_text.append("\u2591" * (bar_w - filled), style="dim")
|
||||
flow_text.append(f" {self.pipeline_done}/{self.pipeline_total}", style="white")
|
||||
|
||||
health_text = Text()
|
||||
health_text.append("last refresh ", style="dim")
|
||||
health_text.append(format_clock(metrics.last_refreshed_at), style="bold white")
|
||||
health_text.append(" stale ", style="dim")
|
||||
health_text.append(str(metrics.stale_task_count), style="bold yellow")
|
||||
if metrics.last_runtime_update:
|
||||
health_text.append("\nruntime heartbeat ", style="dim")
|
||||
health_text.append(humanize_age(metrics.last_runtime_update), style="bold #22c55e")
|
||||
else:
|
||||
health_text.append("\nruntime heartbeat ", style="dim")
|
||||
health_text.append("idle", style="bold #64748b")
|
||||
|
||||
if alerts:
|
||||
alert_lines: list[Text] = []
|
||||
for alert in alerts:
|
||||
line = Text()
|
||||
line += badge(alert.level.upper(), status_style(alert.level))
|
||||
line.append(f" {truncate_text(alert.title, 18)}", style="bold white")
|
||||
line.append(f"\n{truncate_text(alert.message, 42)}", style="dim")
|
||||
alert_lines.append(line)
|
||||
attention_renderable: RenderableType = Group(*alert_lines)
|
||||
else:
|
||||
attention_renderable = Text("No active alerts.\nBoard health looks stable.", style="dim")
|
||||
|
||||
selection_text = Text()
|
||||
if selected is None:
|
||||
selection_text.append(
|
||||
f"No {item_label} selected.\nUse the session rail or board to pick a {item_label}.",
|
||||
style="dim",
|
||||
)
|
||||
else:
|
||||
selection_text.append(f"{truncate_text(selected.title, 28)}\n", style="bold white")
|
||||
selection_text += badge(selected.status.upper(), status_style(selected.status))
|
||||
if selected.priority:
|
||||
selection_text.append(" ")
|
||||
selection_text += badge(selected.priority.upper(), priority_style(selected.priority))
|
||||
if selected.pending_checkpoint:
|
||||
selection_text.append(" ")
|
||||
selection_text += badge("REVIEW", status_style("warn"))
|
||||
runtime = self.state.runtime_for(selected.task_id)
|
||||
if runtime and runtime.current_tool:
|
||||
selection_text.append(f"\n\u2699{truncate_text(runtime.current_tool, 16)}", style="dim")
|
||||
selection_text.append(f" {humanize_age(selected.updated_at)}", style="dim")
|
||||
|
||||
panels = [
|
||||
Panel(board_text, title="Board", border_style="cyan"),
|
||||
Panel(flow_text, title="Projection", border_style="green"),
|
||||
Panel(health_text, title="Health", border_style="magenta"),
|
||||
Panel(attention_renderable, title="Attention", border_style="yellow"),
|
||||
Panel(selection_text, title="Selection", border_style="blue"),
|
||||
]
|
||||
return Columns(panels, expand=True, equal=True)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Read-only organisation viewer for the CLI board."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from ..state.models import OrgEmployeeView, OrgRoleView, OrgSnapshotView
|
||||
from ..state.store import BoardStateStore
|
||||
from .render_utils import truncate_text
|
||||
|
||||
|
||||
class OrgViewerWidget(Static):
|
||||
"""Render a read-only org structure: role tree, employees, and work-item projection."""
|
||||
|
||||
def __init__(self, state: BoardStateStore) -> None:
|
||||
super().__init__(id="org-viewer")
|
||||
self.state = state
|
||||
self.org: OrgSnapshotView | None = None
|
||||
|
||||
def set_org(self, org: OrgSnapshotView | None) -> None:
|
||||
self.org = org
|
||||
self.refresh()
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
focused = self.state.pane_focus == "main" and self.state.view_mode == "org"
|
||||
if self.org is None:
|
||||
return Panel(
|
||||
Text("No org data. Press 5 to load organisation view.", style="dim"),
|
||||
title="Organisation [Focused]" if focused else "Organisation",
|
||||
border_style="cyan" if focused else "white",
|
||||
)
|
||||
|
||||
parts: list[RenderableType] = []
|
||||
parts.append(self._render_roles())
|
||||
parts.append(self._render_employees())
|
||||
|
||||
title = "Organisation [Focused]" if focused else "Organisation"
|
||||
return Panel(Group(*parts), title=title, border_style="cyan" if focused else "white")
|
||||
|
||||
# ── Roles ──
|
||||
|
||||
def _render_roles(self) -> Text:
|
||||
org = self.org
|
||||
text = Text()
|
||||
text.append(f"Roles ({org.role_count})\n", style="bold #cbd5e1")
|
||||
|
||||
if not org.role_tree:
|
||||
text.append(" No roles configured.\n", style="dim")
|
||||
return text
|
||||
|
||||
for node in org.role_tree:
|
||||
self._render_role_node(text, node, depth=0)
|
||||
|
||||
return text
|
||||
|
||||
def _render_role_node(self, text: Text, role: OrgRoleView, depth: int) -> None:
|
||||
indent = " " * (depth + 1)
|
||||
connector = ""
|
||||
if depth > 0:
|
||||
connector = "\u251c\u2500 " if True else "\u2514\u2500 " # ├─
|
||||
|
||||
text.append(f"{indent}{connector}", style="dim")
|
||||
text.append(role.role_id, style="bold #38bdf8")
|
||||
resp = truncate_text(role.responsibility, 30)
|
||||
if resp:
|
||||
text.append(f" \"{resp}\"", style="dim")
|
||||
text.append(f" {role.employee_count} emp", style="dim italic")
|
||||
text.append("\n")
|
||||
|
||||
for child in role.children:
|
||||
self._render_role_node(text, child, depth=depth + 1)
|
||||
|
||||
# ── Employees ──
|
||||
|
||||
def _render_employees(self) -> Text:
|
||||
org = self.org
|
||||
text = Text("\n")
|
||||
text.append(f"Employees ({org.employee_count})\n", style="bold #cbd5e1")
|
||||
|
||||
if not org.employees:
|
||||
text.append(" No employees registered.\n", style="dim")
|
||||
return text
|
||||
|
||||
for emp in org.employees[:20]:
|
||||
text.append(f" {truncate_text(emp.name, 14):<14s}", style="bold white")
|
||||
text.append(f" {truncate_text(emp.role_id, 14):<14s}", style="#38bdf8")
|
||||
text.append(f" {emp.seniority:<8s}", style="dim")
|
||||
if emp.domains:
|
||||
text.append(f" [{', '.join(emp.domains[:4])}]", style="#a78bfa")
|
||||
text.append("\n")
|
||||
|
||||
if len(org.employees) > 20:
|
||||
text.append(f" \u2026 +{len(org.employees) - 20} more\n", style="dim")
|
||||
|
||||
return text
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Read-only work-item projection visualisation for company-mode runs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from ..state.models import PipelineSnapshot, PipelineWorkItemView
|
||||
from ..state.store import BoardStateStore
|
||||
from .render_utils import status_style, truncate_text
|
||||
|
||||
_STATUS_SYMBOL = {
|
||||
"done": ("\u2713", "bold #22c55e"), # ✓ green
|
||||
"running": ("\u25cf", "bold #38bdf8"), # ● blue
|
||||
"idle": ("\u25cf", "bold #38bdf8"), # ● blue
|
||||
"pending": ("\u25cb", "dim"), # ○ gray
|
||||
"failed": ("\u2717", "bold #ef4444"), # ✗ red
|
||||
"cancelled": ("\u2717", "bold #9333ea"), # ✗ purple
|
||||
"blocked": ("\u25a0", "bold #f59e0b"), # ■ yellow
|
||||
"awaiting_peer": ("\u25a0", "bold #f59e0b"),
|
||||
"awaiting_review": ("\u25a0", "bold #f59e0b"),
|
||||
}
|
||||
|
||||
|
||||
def _fmt_elapsed(sec: float) -> str:
|
||||
if sec <= 0:
|
||||
return "--"
|
||||
if sec < 60:
|
||||
return f"{int(sec)}s"
|
||||
if sec < 3600:
|
||||
return f"{int(sec // 60)}m"
|
||||
return f"{int(sec // 3600)}h{int((sec % 3600) // 60)}m"
|
||||
|
||||
|
||||
class PipelineViewWidget(Static):
|
||||
"""Render the work-item projection as an ASCII dependency view."""
|
||||
|
||||
def __init__(self, state: BoardStateStore) -> None:
|
||||
super().__init__(id="pipeline-view")
|
||||
self.state = state
|
||||
self.pipeline: PipelineSnapshot | None = None
|
||||
|
||||
def set_pipeline(self, pipeline: PipelineSnapshot | None) -> None:
|
||||
self.pipeline = pipeline
|
||||
self.refresh()
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
focused = self.state.pane_focus == "main" and self.state.view_mode == "pipeline"
|
||||
if self.pipeline is None or not self.pipeline.work_items:
|
||||
return Panel(
|
||||
Text("No work-item projection data. Select a company-mode task and press 4.", style="dim"),
|
||||
title="Projection [Focused]" if focused else "Projection",
|
||||
border_style="cyan" if focused else "white",
|
||||
)
|
||||
|
||||
pipe = self.pipeline
|
||||
parts: list[RenderableType] = []
|
||||
|
||||
# Header
|
||||
header = Text()
|
||||
header.append(truncate_text(pipe.parent_title, 50), style="bold white")
|
||||
if pipe.profile:
|
||||
header.append(f" {pipe.profile}", style="dim italic")
|
||||
parts.append(header)
|
||||
|
||||
# Work-item rows — linear chain rendering
|
||||
parts.append(self._render_work_item_chain(pipe.work_items))
|
||||
|
||||
# Progress footer
|
||||
footer = self._render_footer(pipe)
|
||||
parts.append(footer)
|
||||
|
||||
title = "Projection [Focused]" if focused else "Projection"
|
||||
return Panel(Group(*parts), title=title, border_style="cyan" if focused else "white")
|
||||
|
||||
def _render_work_item_chain(self, work_items: list[PipelineWorkItemView]) -> Text:
|
||||
"""Render work items as a vertical list with dependency arrows.
|
||||
|
||||
Using vertical layout for reliable terminal rendering — horizontal
|
||||
box-drawing DAGs break on narrow terminals.
|
||||
"""
|
||||
text = Text()
|
||||
# Build dependency lookup for rendering connectors
|
||||
projection_ids = {item.projection_id for item in work_items}
|
||||
|
||||
for i, item in enumerate(work_items):
|
||||
sym, sym_style = _STATUS_SYMBOL.get(item.status, ("\u25cb", "dim"))
|
||||
is_selected = (
|
||||
self.state.selected_task_id is not None
|
||||
and item.task_id == self.state.selected_task_id
|
||||
)
|
||||
|
||||
# Connector line from previous work item
|
||||
if i > 0:
|
||||
if item.dependencies:
|
||||
# Show which projections this depends on
|
||||
dep_labels = [d for d in item.dependencies if d in projection_ids]
|
||||
if dep_labels:
|
||||
text.append(f"\n \u2502 after: {', '.join(dep_labels)}", style="dim")
|
||||
text.append("\n \u2502\n \u25bc\n", style="dim")
|
||||
|
||||
# Work-item box — single-line compact rendering
|
||||
border_style = "bold cyan" if is_selected else "dim"
|
||||
text.append(" \u250c\u2500 ", style=border_style)
|
||||
text.append(f"{item.projection_id}", style="bold #38bdf8" if is_selected else "bold white")
|
||||
|
||||
# Parallel group indicator
|
||||
if item.parallel_group:
|
||||
text.append(f" \u2261{item.parallel_group}", style="dim") # ≡
|
||||
|
||||
# Gate indicator
|
||||
if item.has_gate:
|
||||
gate_label = item.gate_type or "gate"
|
||||
text.append(f" \u229e {gate_label}", style="bold #f59e0b") # ⊞
|
||||
|
||||
text.append(" \u2500\u2510\n", style=border_style)
|
||||
|
||||
# Status line
|
||||
text.append(" \u2502 ", style=border_style)
|
||||
text.append(f"{sym} {item.status}", style=sym_style)
|
||||
text.append(f" {truncate_text(item.title, 30)}", style="white")
|
||||
text.append("\n", style="")
|
||||
|
||||
# Detail line: assignee, elapsed, tool
|
||||
detail_parts: list[str] = []
|
||||
if item.assigned_to:
|
||||
detail_parts.append(item.assigned_to)
|
||||
if item.elapsed_sec > 0 and item.status != "pending":
|
||||
detail_parts.append(_fmt_elapsed(item.elapsed_sec))
|
||||
if item.current_tool:
|
||||
detail_parts.append(f"\u2699{item.current_tool}") # ⚙
|
||||
if item.tool_elapsed_ms > 0:
|
||||
detail_parts.append(f"{item.tool_elapsed_ms}ms")
|
||||
if item.context_remaining_pct > 0:
|
||||
detail_parts.append(f"ctx {item.context_remaining_pct}%")
|
||||
if item.turn_cost_usd > 0:
|
||||
detail_parts.append(f"${item.turn_cost_usd:.4f}")
|
||||
|
||||
if detail_parts:
|
||||
text.append(" \u2502 ", style=border_style)
|
||||
text.append(" ".join(detail_parts), style="dim")
|
||||
text.append("\n", style="")
|
||||
|
||||
if item.last_tool_summary:
|
||||
text.append(" \u2502 ", style=border_style)
|
||||
text.append(truncate_text(item.last_tool_summary, 44), style="dim")
|
||||
text.append("\n", style="")
|
||||
|
||||
text.append(" \u2514", style=border_style)
|
||||
text.append("\u2500" * 40, style=border_style)
|
||||
text.append("\u2518\n", style=border_style)
|
||||
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def _render_footer(pipe: PipelineSnapshot) -> Text:
|
||||
text = Text("\n")
|
||||
# Progress bar
|
||||
ratio = min(1.0, pipe.done_count / pipe.total_count) if pipe.total_count > 0 else 0.0
|
||||
bar_width = 20
|
||||
filled = int(ratio * bar_width)
|
||||
text.append("Progress: ", style="dim")
|
||||
text.append("\u2588" * filled, style="bold #22c55e")
|
||||
text.append("\u2591" * (bar_width - filled), style="dim")
|
||||
text.append(f" {pipe.done_count}/{pipe.total_count} projected steps", style="white")
|
||||
text.append(f" Elapsed: {_fmt_elapsed(pipe.elapsed_sec)}", style="dim")
|
||||
return text
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Shared render helpers for the CLI board widgets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from rich.text import Text
|
||||
|
||||
STATUS_STYLES = {
|
||||
"pending": "bold black on #64748b",
|
||||
"running": "bold black on #22c55e",
|
||||
"idle": "bold black on #38bdf8",
|
||||
"blocked": "bold black on #f59e0b",
|
||||
"awaiting_peer": "bold black on #f59e0b",
|
||||
"awaiting_review": "bold black on #f59e0b",
|
||||
"done": "bold black on #10b981",
|
||||
"failed": "bold white on #ef4444",
|
||||
"cancelled": "bold white on #9333ea",
|
||||
"reflecting": "bold black on #a78bfa",
|
||||
"tool_active": "bold black on #fb7185",
|
||||
"info": "bold black on #38bdf8",
|
||||
"warn": "bold black on #f59e0b",
|
||||
"error": "bold white on #ef4444",
|
||||
}
|
||||
|
||||
PRIORITY_STYLES = {
|
||||
"urgent": "bold white on #dc2626",
|
||||
"high": "bold black on #fb7185",
|
||||
"medium": "bold black on #fbbf24",
|
||||
"low": "bold black on #60a5fa",
|
||||
}
|
||||
|
||||
ROLE_STYLES = {
|
||||
"user": "bold #38bdf8",
|
||||
"assistant": "bold #22c55e",
|
||||
"system": "bold #f59e0b",
|
||||
"subagent": "bold #c084fc",
|
||||
}
|
||||
|
||||
|
||||
def truncate_text(value: str | None, limit: int) -> str:
|
||||
text = " ".join(str(value or "").split())
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return f"{text[: max(0, limit - 1)].rstrip()}…"
|
||||
|
||||
|
||||
def humanize_age(timestamp: float | None, *, now: float | None = None) -> str:
|
||||
if not timestamp:
|
||||
return "n/a"
|
||||
current = float(now if now is not None else datetime.now().timestamp())
|
||||
seconds = max(0, int(current - float(timestamp)))
|
||||
if seconds < 60:
|
||||
return f"{seconds}s"
|
||||
if seconds < 3600:
|
||||
return f"{seconds // 60}m"
|
||||
if seconds < 86400:
|
||||
return f"{seconds // 3600}h"
|
||||
return f"{seconds // 86400}d"
|
||||
|
||||
|
||||
def format_clock(timestamp: float | None) -> str:
|
||||
if not timestamp:
|
||||
return "--:--"
|
||||
return datetime.fromtimestamp(float(timestamp)).strftime("%H:%M")
|
||||
|
||||
|
||||
def badge(label: str, style: str, *, prefix: str = "") -> Text:
|
||||
text = Text()
|
||||
if prefix:
|
||||
text.append(prefix, style="dim")
|
||||
text.append(f" {label} ", style=style)
|
||||
return text
|
||||
|
||||
|
||||
def status_style(status: str | None) -> str:
|
||||
return STATUS_STYLES.get(str(status or "").strip().lower(), "bold black on #475569")
|
||||
|
||||
|
||||
def priority_style(priority: str | None) -> str:
|
||||
return PRIORITY_STYLES.get(str(priority or "").strip().lower(), "bold black on #475569")
|
||||
|
||||
|
||||
def role_style(role: str | None) -> str:
|
||||
return ROLE_STYLES.get(str(role or "").strip().lower(), "bold white")
|
||||
|
||||
|
||||
def adaptive_summary(metadata: dict[str, Any] | None) -> dict[str, Any]:
|
||||
meta = dict(metadata or {})
|
||||
adaptive = dict(meta.get("adaptive", {}) or {})
|
||||
if not adaptive:
|
||||
return {
|
||||
"state": "",
|
||||
"blocked_reason": "",
|
||||
"gate_owner": "",
|
||||
"missing_signals": [],
|
||||
"confidence_label": "",
|
||||
"invalidated": False,
|
||||
}
|
||||
work_item_profile = dict(adaptive.get("work_item_profile", {}) or {})
|
||||
missing_signals = [
|
||||
str(item.get("name", "") or "").strip()
|
||||
for item in list(adaptive.get("signals", []) or [])
|
||||
if isinstance(item, dict)
|
||||
and bool(item.get("required", True))
|
||||
and not bool(item.get("satisfied", False))
|
||||
and str(item.get("name", "") or "").strip()
|
||||
]
|
||||
confidence = adaptive.get("confidence")
|
||||
try:
|
||||
confidence_value = float(confidence)
|
||||
except (TypeError, ValueError):
|
||||
confidence_value = None
|
||||
return {
|
||||
"state": str(adaptive.get("normalized_state", "") or "").strip(),
|
||||
"blocked_reason": str(adaptive.get("blocked_reason", "") or "").strip(),
|
||||
"gate_owner": str(work_item_profile.get("gate_owner_role_id", "") or "").strip(),
|
||||
"missing_signals": missing_signals,
|
||||
"confidence_label": (
|
||||
f"{round(confidence_value * 100)}%"
|
||||
if confidence_value is not None
|
||||
else ""
|
||||
),
|
||||
"invalidated": str(adaptive.get("normalized_state", "") or "").strip().lower() == "invalidated",
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Session transcript pane."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from ..state.models import TaskDetailView
|
||||
from ..state.store import BoardStateStore
|
||||
from .render_utils import format_clock, role_style, status_style, truncate_text
|
||||
|
||||
|
||||
class SessionPaneWidget(Static):
|
||||
"""Render transcript and live progress for the selected task."""
|
||||
|
||||
def __init__(self, state: BoardStateStore) -> None:
|
||||
super().__init__(id="session-pane")
|
||||
self.state = state
|
||||
self.detail: TaskDetailView | None = None
|
||||
|
||||
def set_detail(self, detail: TaskDetailView | None) -> None:
|
||||
self.detail = detail
|
||||
self.refresh()
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
focused = self.state.pane_focus == "context" and self.state.context_tab == "session"
|
||||
company_mode = self.state.snapshot.mode == "company"
|
||||
title = "Runtime Session" if company_mode else "Session"
|
||||
if self.detail is None:
|
||||
return Panel(
|
||||
Text("No Runtime Session selected." if company_mode else "No session selected.", style="dim"),
|
||||
title=f"{title} [Focused]" if focused else title,
|
||||
border_style="cyan" if focused else "white",
|
||||
)
|
||||
|
||||
messages: list[RenderableType] = []
|
||||
for message in self.detail.transcript[-14:]:
|
||||
block = Text()
|
||||
block.append(f"[{format_clock(message.created_at)}] ", style="dim")
|
||||
block.append(message.sender_name, style=role_style(message.role))
|
||||
block.append("\n")
|
||||
# Show content with natural line breaks — let the panel wrap
|
||||
content = message.content.strip()
|
||||
lines = content.split("\n")
|
||||
for line in lines[:20]:
|
||||
block.append(f"{line}\n", style="white")
|
||||
if len(lines) > 20:
|
||||
block.append(f"\u2026 ({len(lines) - 20} more lines)\n", style="dim")
|
||||
messages.append(block)
|
||||
|
||||
progress_entries = list(self.detail.progress_entries)
|
||||
runtime = self.state.runtime_for(self.detail.task.task_id)
|
||||
if runtime and runtime.progress_entries:
|
||||
progress_entries.extend(runtime.progress_entries[-10:])
|
||||
|
||||
if progress_entries:
|
||||
progress_title = "Execution Progress Timeline" if company_mode else "Progress Timeline"
|
||||
progress = Text(f"\n{progress_title}\n", style="bold #cbd5e1")
|
||||
for entry in progress_entries[-10:]:
|
||||
progress.append(f"• {truncate_text(entry, 90)}\n", style="dim")
|
||||
messages.append(progress)
|
||||
|
||||
if runtime and (
|
||||
runtime.current_tool
|
||||
or runtime.context_window > 0
|
||||
or runtime.turn_cost_usd > 0
|
||||
or runtime.pending_permission_count > 0
|
||||
):
|
||||
tail = Text("\nRuntime\n", style="bold #cbd5e1")
|
||||
tail.append(f"status {runtime.status}", style=status_style(runtime.status))
|
||||
if runtime.current_tool:
|
||||
tail.append(f" tool {runtime.current_tool}", style="dim")
|
||||
if runtime.iteration:
|
||||
tail.append(f" iter {runtime.iteration}", style="dim")
|
||||
if runtime.tool_elapsed_ms > 0:
|
||||
tail.append(f" {runtime.tool_elapsed_ms}ms", style="dim")
|
||||
if runtime.last_tool_summary:
|
||||
tail.append(f"\nsummary {truncate_text(runtime.last_tool_summary, 90)}", style="dim")
|
||||
if runtime.context_window > 0:
|
||||
tail.append(
|
||||
f"\ncontext {runtime.context_tokens}/{runtime.context_window} ({runtime.context_remaining_pct}% left)",
|
||||
style="dim",
|
||||
)
|
||||
if runtime.turn_cost_usd > 0 or runtime.session_cost_usd > 0:
|
||||
tail.append(
|
||||
f"\ncost turn=${runtime.turn_cost_usd:.4f} session=${runtime.session_cost_usd:.4f}",
|
||||
style="dim",
|
||||
)
|
||||
if runtime.pending_permission_count > 0:
|
||||
tail.append(f"\napprovals pending {runtime.pending_permission_count}", style="bold yellow")
|
||||
messages.append(tail)
|
||||
|
||||
if not messages:
|
||||
messages.append(Text("No transcript recorded yet.", style="dim"))
|
||||
|
||||
return Panel(
|
||||
Group(*messages),
|
||||
title=f"{title} [Focused]" if focused else title,
|
||||
border_style="cyan" if focused else "white",
|
||||
)
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Session tree widget for the CLI board.
|
||||
|
||||
Displays tasks in a tree structure where parent tasks are expandable nodes
|
||||
and child work items appear as leaves beneath them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from ..state.models import BoardTaskView, SessionSummaryView
|
||||
from ..state.store import BoardStateStore
|
||||
from .render_utils import badge, humanize_age, priority_style, status_style, truncate_text
|
||||
|
||||
_STATUS_SYMBOL = {
|
||||
"done": ("\u2713", "bold #22c55e"),
|
||||
"running": ("\u25cf", "bold #38bdf8"),
|
||||
"idle": ("\u25cf", "bold #38bdf8"),
|
||||
"pending": ("\u25cb", "dim"),
|
||||
"failed": ("\u2717", "bold #ef4444"),
|
||||
"cancelled": ("\u2717", "bold #9333ea"),
|
||||
"blocked": ("\u25a0", "bold #f59e0b"),
|
||||
"awaiting_peer": ("\u25a0", "bold #f59e0b"),
|
||||
"awaiting_review": ("\u25a0", "bold #f59e0b"),
|
||||
}
|
||||
|
||||
|
||||
class SessionSidebarWidget(Static):
|
||||
"""Render a session tree with parent-child relationships."""
|
||||
|
||||
def __init__(self, state: BoardStateStore) -> None:
|
||||
super().__init__(id="session-sidebar")
|
||||
self.state = state
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
company_mode = self.state.snapshot.mode == "company"
|
||||
title = "Runtime Sessions" if company_mode else "Sessions"
|
||||
if self.state.pane_focus == "session-rail":
|
||||
title += " [Focused]"
|
||||
|
||||
tree = self._build_tree()
|
||||
if not tree:
|
||||
return Panel(
|
||||
Text("No runtime sessions available." if company_mode else "No sessions available.", style="dim"),
|
||||
title=title,
|
||||
border_style="cyan" if self.state.pane_focus == "session-rail" else "white",
|
||||
)
|
||||
|
||||
return Panel(
|
||||
Group(*tree),
|
||||
title=title,
|
||||
border_style="cyan" if self.state.pane_focus == "session-rail" else "white",
|
||||
)
|
||||
|
||||
def _build_tree(self) -> list[RenderableType]:
|
||||
"""Build a tree from tasks, grouping children under parents."""
|
||||
all_tasks = self.state.filtered_tasks()
|
||||
if not all_tasks:
|
||||
return []
|
||||
|
||||
# Separate parent (visible/top-level) tasks from child tasks
|
||||
# Child tasks have origin_task_id pointing to their parent
|
||||
parent_tasks: list[BoardTaskView] = []
|
||||
children_by_parent: dict[str, list[BoardTaskView]] = {}
|
||||
|
||||
# First pass: identify parents and children from the visible task list
|
||||
# Also look at linked executions from the snapshot data
|
||||
visible_ids = {t.task_id for t in all_tasks}
|
||||
|
||||
for task in all_tasks:
|
||||
if task.origin_task_id and task.origin_task_id in visible_ids:
|
||||
children_by_parent.setdefault(task.origin_task_id, []).append(task)
|
||||
else:
|
||||
parent_tasks.append(task)
|
||||
|
||||
# Also pull in hidden linked tasks from snapshot metadata
|
||||
for task in self.state.all_tasks():
|
||||
if task.task_id in visible_ids:
|
||||
continue
|
||||
if task.origin_task_id and task.origin_task_id in visible_ids:
|
||||
children_by_parent.setdefault(task.origin_task_id, []).append(task)
|
||||
|
||||
# Sort parents: live first, then queue, then archive
|
||||
parent_tasks.sort(key=lambda t: (
|
||||
0 if self._is_live(t) else 1 if t.column_id != "done" else 2,
|
||||
-float(t.updated_at),
|
||||
))
|
||||
|
||||
lines: list[RenderableType] = []
|
||||
for parent in parent_tasks:
|
||||
children = children_by_parent.get(parent.task_id, [])
|
||||
children.sort(key=lambda t: (float(t.created_at), t.title))
|
||||
lines.append(self._render_parent(parent, children))
|
||||
|
||||
return lines
|
||||
|
||||
def _render_parent(self, task: BoardTaskView, children: list[BoardTaskView]) -> Text:
|
||||
selected = task.task_id == self.state.selected_task_id
|
||||
runtime = self.state.runtime_for(task.task_id)
|
||||
has_children = bool(children)
|
||||
sym, sym_style = _STATUS_SYMBOL.get(task.status, ("\u25cb", "dim"))
|
||||
|
||||
text = Text()
|
||||
# Expand/collapse indicator
|
||||
if has_children:
|
||||
text.append("\u25bc ", style="bold white") # ▼
|
||||
else:
|
||||
text.append(" ", style="")
|
||||
|
||||
# Selection marker
|
||||
if selected:
|
||||
text.append("\u25c6 ", style="bold cyan") # ◆
|
||||
else:
|
||||
text.append(" ", style="")
|
||||
|
||||
# Status + title
|
||||
text.append(f"{sym} ", style=sym_style)
|
||||
title_style = "bold white" if selected else "white"
|
||||
text.append(truncate_text(task.title, 22), style=title_style)
|
||||
text.append(f" {humanize_age(task.updated_at)}", style="dim")
|
||||
|
||||
# Runtime info
|
||||
if runtime and runtime.current_tool:
|
||||
text.append(f" \u2699{runtime.current_tool}", style="dim")
|
||||
|
||||
# Badges on next line if relevant
|
||||
if task.pending_checkpoint or task.priority:
|
||||
text.append("\n ")
|
||||
if task.priority:
|
||||
text += badge(task.priority.upper(), priority_style(task.priority))
|
||||
text.append(" ")
|
||||
if task.pending_checkpoint:
|
||||
text += badge("REVIEW", status_style("warn"))
|
||||
|
||||
# Children
|
||||
for i, child in enumerate(children[:10]):
|
||||
is_last = i == len(children) - 1 or i == 9
|
||||
text.append("\n")
|
||||
text.append(self._render_child(child, is_last=is_last))
|
||||
|
||||
if len(children) > 10:
|
||||
text.append(f"\n \u2026 +{len(children) - 10} more", style="dim")
|
||||
|
||||
text.append("\n")
|
||||
return text
|
||||
|
||||
def _render_child(self, task: BoardTaskView, *, is_last: bool) -> Text:
|
||||
selected = task.task_id == self.state.selected_task_id
|
||||
runtime = self.state.runtime_for(task.task_id)
|
||||
sym, sym_style = _STATUS_SYMBOL.get(task.status, ("\u25cb", "dim"))
|
||||
|
||||
connector = " \u2514\u2500 " if is_last else " \u251c\u2500 " # └─ or ├─
|
||||
text = Text()
|
||||
text.append(connector, style="dim")
|
||||
|
||||
if selected:
|
||||
text.append(f"{sym} ", style=sym_style)
|
||||
text.append(truncate_text(task.title, 18), style="bold cyan")
|
||||
else:
|
||||
text.append(f"{sym} ", style=sym_style)
|
||||
text.append(truncate_text(task.title, 18), style="white")
|
||||
|
||||
# Assignee
|
||||
if task.assigned_to:
|
||||
text.append(f" {truncate_text(task.assigned_to, 10)}", style="dim")
|
||||
|
||||
# Elapsed
|
||||
text.append(f" {humanize_age(task.updated_at)}", style="dim")
|
||||
|
||||
# Current tool
|
||||
if runtime and runtime.current_tool:
|
||||
text.append(f" \u2699{truncate_text(runtime.current_tool, 12)}", style="dim")
|
||||
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def _is_live(task: BoardTaskView) -> bool:
|
||||
return task.status in {"running", "idle", "blocked", "awaiting_peer", "awaiting_review"} or task.pending_checkpoint is not None
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Bottom status bar — context-aware action guide."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from ..state.store import BoardStateStore
|
||||
from .render_utils import truncate_text
|
||||
|
||||
|
||||
class StatusBarWidget(Static):
|
||||
"""Show available actions based on current state, plus key status info."""
|
||||
|
||||
def __init__(self, state: BoardStateStore) -> None:
|
||||
super().__init__(id="status-bar")
|
||||
self.state = state
|
||||
self.message = ""
|
||||
self.exec_mode = "task"
|
||||
self.company_profile = "corporate"
|
||||
|
||||
def set_message(self, message: str) -> None:
|
||||
self.message = str(message or "").strip()
|
||||
self.refresh()
|
||||
|
||||
def render(self) -> Text:
|
||||
selected = self.state.selected_task()
|
||||
metrics = self.state.metrics()
|
||||
text = Text()
|
||||
|
||||
# Flash message (temporary status, shown prominently)
|
||||
if self.message:
|
||||
text.append(f"{self.message} ", style="bold white")
|
||||
text.append("\u2502 ", style="dim")
|
||||
|
||||
# Context-aware action hints
|
||||
actions = self._build_action_hints(selected)
|
||||
text.append(actions)
|
||||
|
||||
# Separator + key status
|
||||
text.append(" \u2502 ", style="dim")
|
||||
text.append(self.state.view_mode, style="bold cyan")
|
||||
text.append(" ", style="dim")
|
||||
text.append(f"{self.exec_mode}/{self.company_profile}", style="bold #22c55e")
|
||||
|
||||
if metrics.visible_tasks > 0:
|
||||
item_label = "work items" if self.state.snapshot.mode == "company" else "tasks"
|
||||
text.append(f" {metrics.visible_tasks} {item_label}", style="dim")
|
||||
|
||||
if selected:
|
||||
text.append(" \u2502 ", style="dim")
|
||||
text.append(truncate_text(selected.title, 24), style="bold #22c55e")
|
||||
runtime = self.state.runtime_for(selected.task_id)
|
||||
if runtime:
|
||||
if runtime.current_tool:
|
||||
text.append(" \u2502 ", style="dim")
|
||||
text.append(f"\u2699 {truncate_text(runtime.current_tool, 18)}", style="bold #f59e0b")
|
||||
if runtime.context_window > 0:
|
||||
text.append(" \u2502 ", style="dim")
|
||||
text.append(f"ctx {runtime.context_remaining_pct}%", style="bold #38bdf8")
|
||||
if runtime.turn_cost_usd > 0 or runtime.session_cost_usd > 0:
|
||||
text.append(" \u2502 ", style="dim")
|
||||
text.append(
|
||||
f"${runtime.turn_cost_usd:.4f}/${runtime.session_cost_usd:.4f}",
|
||||
style="bold #22c55e",
|
||||
)
|
||||
if runtime.pending_permission_count > 0:
|
||||
text.append(" \u2502 ", style="dim")
|
||||
text.append(f"approvals {runtime.pending_permission_count}", style="bold #f59e0b")
|
||||
|
||||
if self.state.search_query:
|
||||
text.append(" \u2502 ", style="dim")
|
||||
text.append(f"\u2315 {truncate_text(self.state.search_query, 16)}", style="yellow")
|
||||
|
||||
return text
|
||||
|
||||
def _build_action_hints(self, selected: object | None) -> Text:
|
||||
hints = Text()
|
||||
|
||||
if selected is None:
|
||||
# No task selected
|
||||
hints.append("n", style="bold cyan")
|
||||
hints.append(" new ", style="dim")
|
||||
hints.append("/", style="bold cyan")
|
||||
hints.append(" search ", style="dim")
|
||||
hints.append("?", style="bold cyan")
|
||||
hints.append(" help", style="dim")
|
||||
return hints
|
||||
|
||||
# Task selected — show relevant actions
|
||||
task = selected
|
||||
has_checkpoint = getattr(task, "pending_checkpoint", None) is not None
|
||||
status = getattr(task, "status", "")
|
||||
is_terminal = status in {"done", "failed", "cancelled"}
|
||||
|
||||
hints.append("n", style="bold cyan")
|
||||
hints.append(" new ", style="dim")
|
||||
|
||||
if not is_terminal:
|
||||
hints.append("g", style="bold cyan")
|
||||
hints.append(" run ", style="dim")
|
||||
hints.append("s", style="bold cyan")
|
||||
hints.append(" chat ", style="dim")
|
||||
|
||||
if has_checkpoint:
|
||||
hints.append("\u26a1", style="bold #f59e0b")
|
||||
hints.append(" ", style="")
|
||||
hints.append("a", style="bold #22c55e")
|
||||
hints.append("/", style="dim")
|
||||
hints.append("d", style="bold #ef4444")
|
||||
hints.append(" approve/deny ", style="dim")
|
||||
hints.append("e", style="bold cyan")
|
||||
hints.append(" feedback ", style="dim")
|
||||
|
||||
if is_terminal:
|
||||
hints.append("t", style="bold cyan")
|
||||
hints.append(" retry ", style="dim")
|
||||
|
||||
hints.append("3", style="bold cyan")
|
||||
hints.append(" chat view ", style="dim")
|
||||
hints.append("?", style="bold cyan")
|
||||
hints.append(" help", style="dim")
|
||||
|
||||
return hints
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Dense task list view."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from ..state.store import BoardStateStore
|
||||
from .render_utils import humanize_age, priority_style, status_style, truncate_text
|
||||
|
||||
|
||||
class TaskListWidget(Static):
|
||||
"""Render a dense linear task view."""
|
||||
|
||||
def __init__(self, state: BoardStateStore) -> None:
|
||||
super().__init__(id="task-list")
|
||||
self.state = state
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
tasks = self.state.linear_tasks()
|
||||
rows: list[RenderableType] = [self._header_row()]
|
||||
item_label = "work items" if self.state.snapshot.mode == "company" else "tasks"
|
||||
if not tasks:
|
||||
rows.append(Text(f"No {item_label} match the current filter.", style="dim"))
|
||||
for task in tasks:
|
||||
rows.append(self._task_row(task))
|
||||
title = "Work Item List" if self.state.snapshot.mode == "company" else "Task List"
|
||||
if self.state.pane_focus == "main":
|
||||
title += " [Focused]"
|
||||
return Panel(Group(*rows), title=title, border_style="cyan" if self.state.pane_focus == "main" else "white")
|
||||
|
||||
def _header_row(self) -> Text:
|
||||
row = Text(style="bold #94a3b8")
|
||||
row.append(("WORK ITEM" if self.state.snapshot.mode == "company" else "TASK").ljust(10))
|
||||
row.append("TITLE".ljust(30))
|
||||
row.append("STATUS".ljust(16))
|
||||
row.append("OWNER".ljust(16))
|
||||
row.append("AGE")
|
||||
return row
|
||||
|
||||
def _task_row(self, task) -> Text:
|
||||
selected = task.task_id == self.state.selected_task_id
|
||||
row = Text(style="black on #22d3ee" if selected else "white")
|
||||
row.append((task.display_id or task.task_id[:8]).ljust(10), style="bold")
|
||||
row.append(truncate_text(task.title, 28).ljust(30))
|
||||
row.append(task.status[:14].upper().ljust(16), style=status_style(task.status))
|
||||
row.append(truncate_text(task.assigned_to or "-", 14).ljust(16), style="dim" if not selected else "black on #22d3ee")
|
||||
row.append(humanize_age(task.updated_at), style="dim" if not selected else "black on #22d3ee")
|
||||
if task.priority:
|
||||
row.append(" ")
|
||||
row.append(task.priority[0].upper(), style=priority_style(task.priority))
|
||||
if task.pending_checkpoint:
|
||||
row.append(" !", style=status_style("warn"))
|
||||
return row
|
||||
Reference in New Issue
Block a user