Files
2026-07-14 14:35:43 +08:00

1365 lines
59 KiB
Python

"""Textual application for the OpenOPC CLI board."""
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING, Any
from textual import work
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.widgets import Footer, Header
from opc.plugins.cli_board.state.store import BoardStateStore
from opc.plugins.cli_board.tui.screens.help import HelpScreen
from opc.plugins.cli_board.tui.screens.palette import CommandPaletteScreen, PaletteCommand
from opc.plugins.cli_board.tui.screens.prompt import PromptField, PromptScreen
from opc.plugins.cli_board.widgets.activity_pane import ActivityPaneWidget
from opc.plugins.cli_board.widgets.context_tabs import ContextTabsWidget
from opc.plugins.cli_board.widgets.detail_pane import DetailPaneWidget
from opc.plugins.cli_board.widgets.focus_view import FocusTaskWidget
from opc.plugins.cli_board.widgets.kanban_board import KanbanBoardWidget
from opc.plugins.cli_board.widgets.metrics_bar import MetricsBarWidget
from opc.plugins.cli_board.widgets.org_viewer import OrgViewerWidget
from opc.plugins.cli_board.widgets.pipeline_view import PipelineViewWidget
from opc.plugins.cli_board.widgets.session_pane import SessionPaneWidget
from opc.plugins.cli_board.widgets.session_sidebar import SessionSidebarWidget
from opc.plugins.cli_board.widgets.status_bar import StatusBarWidget
from opc.plugins.cli_board.widgets.task_list import TaskListWidget
from opc.plugins.office_ui.services.factory import OfficeServiceFactory
if TYPE_CHECKING:
from opc.plugins.cli_board.services.actions import BoardActions
from opc.plugins.cli_board.services.board_repository import BoardRepository
from opc.plugins.cli_board.services.engine_facade import EngineFacade
from opc.plugins.cli_board.services.event_bridge import CliBoardEventBridge
from opc.plugins.cli_board.services.reconcile import ReconcileLoop
class CliBoardApp(App[None]):
"""Terminal command center for OpenOPC."""
CSS_PATH = "board.tcss"
BINDINGS = [
Binding("left", "move_left", "Left", show=False),
Binding("h", "move_left", "Left", show=False),
Binding("right", "move_right", "Right", show=False),
Binding("l", "move_right", "Right", show=False),
Binding("up", "move_up", "Up", show=False),
Binding("k", "move_up", "Up", show=False),
Binding("down", "move_down", "Down", show=False),
Binding("j", "move_down", "Down", show=False),
Binding("tab", "focus_next_pane", "Next Pane", priority=True),
Binding("shift+tab", "focus_prev_pane", "Prev Pane", show=False, priority=True),
Binding("enter", "activate_selection", "Open"),
Binding("space", "toggle_density", "Density"),
Binding("1", "view_kanban", "Kanban"),
Binding("2", "view_list", "List"),
Binding("3", "view_focus", "Focus"),
Binding("4", "view_pipeline", "Pipeline"),
Binding("5", "view_org", "Org"),
Binding("ctrl+k", "open_palette", "Palette", priority=True),
Binding(":", "open_palette", "Palette", show=False, priority=True),
Binding("n", "new_task", "New"),
Binding("g", "run_selected", "Run"),
Binding("s", "reply", "Reply"),
Binding("m", "move_task", "Move"),
Binding("a", "approve_selected", "Approve"),
Binding("d", "deny_selected", "Deny"),
Binding("c", "mark_done", "Done"),
Binding("x", "cancel_task", "Cancel"),
Binding("t", "retry_selected", "Retry"),
Binding("e", "checkpoint_feedback", "Feedback"),
Binding("R", "rename_session", "Rename", show=False),
Binding("D", "delete_session", "Delete", show=False),
Binding("E", "switch_mode", "Mode", show=False),
Binding("f", "toggle_done", "Toggle Done"),
Binding("/", "search", "Search"),
Binding("r", "refresh_board", "Refresh"),
Binding("?", "show_help", "Help"),
Binding("q", "quit_board", "Quit"),
Binding("ctrl+q", "quit_board", "Return", show=False, priority=True),
]
def __init__(
self,
*,
project_id: str | None = None,
refresh_interval: float = 2.0,
bootstrap_services: bool = True,
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,
) -> None:
super().__init__()
self.project_id = project_id
self.refresh_interval = refresh_interval
self.attach = attach
self.readonly = readonly
self.initial_view = initial_view
self.initial_session_id = initial_session_id
self.initial_work_item_id = initial_work_item_id
self.initial_role_id = initial_role_id
self.initial_target = initial_target
self.state = BoardStateStore()
self.facade: EngineFacade | None = None
self.repository: BoardRepository | None = None
self.actions: BoardActions | None = None
self.event_bridge: CliBoardEventBridge | None = None
self.reconcile_loop: ReconcileLoop | None = None
self.exec_mode = "task"
self.company_profile = "corporate"
self._refresh_lock = asyncio.Lock()
self.metrics_widget = MetricsBarWidget(self.state, exec_mode=self.exec_mode, company_profile=self.company_profile)
self.session_sidebar = SessionSidebarWidget(self.state)
self.board_widget = KanbanBoardWidget(self.state)
self.list_widget = TaskListWidget(self.state)
self.focus_widget = FocusTaskWidget(self.state)
self.pipeline_widget = PipelineViewWidget(self.state)
self.org_widget = OrgViewerWidget(self.state)
self.context_tabs_widget = ContextTabsWidget(self.state)
self.detail_widget = DetailPaneWidget(self.state)
self.session_widget = SessionPaneWidget(self.state)
self.activity_widget = ActivityPaneWidget(self.state)
self.status_widget = StatusBarWidget(self.state)
if bootstrap_services:
self._bootstrap_services()
def _bootstrap_services(self) -> None:
from opc.plugins.cli_board.services.actions import BoardActions
from opc.plugins.cli_board.services.board_repository import BoardRepository
from opc.plugins.cli_board.services.engine_facade import EngineFacade
from opc.plugins.cli_board.services.event_bridge import CliBoardEventBridge
self.facade = EngineFacade(project_id=self.project_id)
self.repository = BoardRepository(self.facade, project_id=self.project_id)
self.actions = BoardActions(self.facade, project_id=self.project_id)
self.event_bridge = CliBoardEventBridge(self._handle_board_event)
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
yield self.metrics_widget
with Horizontal(id="body"):
with Vertical(id="session-shell"):
with VerticalScroll(id="session-scroll"):
yield self.session_sidebar
with Vertical(id="main-shell"):
with VerticalScroll(id="main-scroll"):
yield self.board_widget
yield self.list_widget
yield self.focus_widget
yield self.pipeline_widget
yield self.org_widget
with Vertical(id="context-shell"):
yield self.context_tabs_widget
with VerticalScroll(id="context-scroll"):
yield self.detail_widget
yield self.session_widget
yield self.activity_widget
yield self.status_widget
yield Footer()
async def on_mount(self) -> None:
self._sync_layout_state()
if not self.facade or not self.repository or not self.actions or not self.event_bridge:
self.status_widget.set_message("Standalone mode.")
self._refresh_all_widgets()
return
self.status_widget.set_message("Initializing engine...")
self.facade.configure_callbacks(
progress_callback=self.event_bridge.handle_progress,
event_callback=self.event_bridge.handle_event,
)
self._infer_default_mode()
await self._refresh_snapshot(reason="startup")
await self._apply_initial_inspector_target()
from opc.plugins.cli_board.services.reconcile import ReconcileLoop
self.reconcile_loop = ReconcileLoop(self.refresh_interval, self._refresh_from_reconcile)
self.run_worker(self.reconcile_loop.run(), group="reconcile", exclusive=True)
async def on_unmount(self) -> None:
if self.reconcile_loop is not None:
self.reconcile_loop.stop()
if self.facade is not None:
await self.facade.shutdown()
def action_quit_board(self) -> None:
if self.reconcile_loop is not None:
self.reconcile_loop.stop()
self.exit()
def _infer_default_mode(self) -> None:
"""Auto-detect exec_mode from org config.
If roles + employees are configured → company mode.
Otherwise → task mode. Avoids needing to sync with UI state.
"""
if self.facade is None or self.facade.engine is None:
return
org = getattr(self.facade.engine, "org_engine", None)
if org is None:
return
try:
roles = org.list_agents()
employees = org.list_employees()
profile = org.get_company_profile()
if profile == "custom":
has_org = len(roles) > 0 and len(employees) > 0
else:
has_org = len(roles) > 1 and len(employees) > 0
except Exception:
has_org = False
if has_org:
self.exec_mode = "company"
self.company_profile = profile
# else keep default "task" / "corporate"
def _require_actions(self) -> BoardActions | None:
if self.readonly:
self.status_widget.set_message("Read-only inspector: return to chat to run mutating commands.")
return None
if self.actions is None:
self.status_widget.set_message("Task actions are unavailable.")
return None
return self.actions
def _readonly_guard(self) -> bool:
if not self.readonly:
return False
self.status_widget.set_message("Read-only inspector: q/Ctrl-Q returns to chat.")
return True
async def _run_office_service(self, operation: Any) -> Any:
if self.facade is None:
raise RuntimeError("Office services are unavailable.")
engine = await self.facade.ensure_ready()
async with OfficeServiceFactory(
config=getattr(engine, "config", None),
project_id=self.project_id or "default",
on_progress=self.event_bridge.handle_progress if self.event_bridge else None,
on_runtime_event=self.event_bridge.handle_event if self.event_bridge else None,
) as services:
return await operation(services)
def action_move_left(self) -> None:
if self.state.pane_focus == "context":
self.state.cycle_context_tab(-1)
self.status_widget.set_message(f"Context tab: {self.state.context_tab}.")
self._refresh_all_widgets()
return
if self.state.pane_focus == "session-rail":
self.state.set_pane_focus("main")
self.status_widget.set_message("Focused main viewport.")
self._refresh_all_widgets()
return
if self.state.view_mode == "kanban":
self.state.move_selection(column_delta=-1)
self._selection_changed()
return
self.state.cycle_view_mode(-1)
self.status_widget.set_message(f"Switched to {self.state.view_mode} view.")
self._refresh_all_widgets()
def action_move_right(self) -> None:
if self.state.pane_focus == "context":
self.state.cycle_context_tab(1)
self.status_widget.set_message(f"Context tab: {self.state.context_tab}.")
self._refresh_all_widgets()
return
if self.state.pane_focus == "session-rail":
self.state.set_pane_focus("main")
self.status_widget.set_message("Focused main viewport.")
self._refresh_all_widgets()
return
if self.state.view_mode == "kanban":
self.state.move_selection(column_delta=1)
self._selection_changed()
return
self.state.cycle_view_mode(1)
self.status_widget.set_message(f"Switched to {self.state.view_mode} view.")
self._refresh_all_widgets()
def action_move_up(self) -> None:
if self.state.pane_focus == "session-rail":
self.state.move_session_selection(-1)
elif self.state.pane_focus == "main":
if self.state.view_mode == "kanban":
self.state.move_selection(row_delta=-1)
else:
self.state.move_linear_selection(-1)
else:
self.state.cycle_context_tab(-1)
self.status_widget.set_message(f"Context tab: {self.state.context_tab}.")
self._refresh_all_widgets()
return
self._selection_changed()
def action_move_down(self) -> None:
if self.state.pane_focus == "session-rail":
self.state.move_session_selection(1)
elif self.state.pane_focus == "main":
if self.state.view_mode == "kanban":
self.state.move_selection(row_delta=1)
else:
self.state.move_linear_selection(1)
else:
self.state.cycle_context_tab(1)
self.status_widget.set_message(f"Context tab: {self.state.context_tab}.")
self._refresh_all_widgets()
return
self._selection_changed()
def action_focus_next_pane(self) -> None:
focus = self.state.cycle_pane_focus(1)
self.status_widget.set_message(f"Focused {focus}.")
self._refresh_all_widgets()
def action_focus_prev_pane(self) -> None:
focus = self.state.cycle_pane_focus(-1)
self.status_widget.set_message(f"Focused {focus}.")
self._refresh_all_widgets()
def action_activate_selection(self) -> None:
if self.state.pane_focus == "session-rail":
self.state.set_view_mode("focus")
self.state.set_pane_focus("main")
self.status_widget.set_message("Opened focus view.")
self._refresh_all_widgets()
return
if self.state.pane_focus == "main":
if self.state.view_mode != "focus":
self.state.set_view_mode("focus")
self.status_widget.set_message("Opened focus view.")
else:
self.state.set_pane_focus("context")
self.status_widget.set_message("Focused context dock.")
self._refresh_all_widgets()
return
self.state.cycle_context_tab(1)
self.status_widget.set_message(f"Context tab: {self.state.context_tab}.")
self._refresh_all_widgets()
def action_toggle_density(self) -> None:
density = self.state.toggle_density()
self.status_widget.set_message(f"Density: {density}.")
self._refresh_all_widgets()
def action_view_kanban(self) -> None:
self.state.set_view_mode("kanban")
self.status_widget.set_message("Kanban view enabled.")
self._refresh_all_widgets()
def action_view_list(self) -> None:
self.state.set_view_mode("list")
self.status_widget.set_message("List view enabled.")
self._refresh_all_widgets()
def action_view_focus(self) -> None:
self.state.set_view_mode("focus")
self.status_widget.set_message("Focus view enabled.")
self._refresh_all_widgets()
def action_view_pipeline(self) -> None:
self.state.set_view_mode("pipeline")
self.status_widget.set_message("Projection view enabled.")
self.run_worker(self._load_pipeline_for_selected(), group="pipeline", exclusive=True)
def action_view_org(self) -> None:
self.state.set_view_mode("org")
self.status_widget.set_message("Organisation view enabled.")
self.run_worker(self._load_org(), group="org", exclusive=True)
def action_open_palette(self) -> None:
self._action_open_palette()
@work(group="modal", exclusive=True)
async def _action_open_palette(self) -> None:
result = await self.push_screen_wait(
CommandPaletteScreen(
title="CLI Board Commands",
commands=self._palette_commands(),
)
)
if not result:
return
await self._dispatch_palette_command(result)
def action_new_task(self) -> None:
if self._readonly_guard():
return
self._action_new_task()
# Textual requires an active worker context for push_screen_wait.
@work(group="modal", exclusive=True)
async def _action_new_task(self) -> None:
result = await self.push_screen_wait(
PromptScreen(
title="Create Task",
fields=[
PromptField("title", "Title", placeholder="Short task title"),
PromptField("description", "Description", placeholder="Optional description"),
PromptField("message", "Initial message", placeholder="Optional first prompt to run immediately"),
],
help_text="If you provide an initial message, the task will start immediately.",
confirm_label="Create",
)
)
if not result:
return
actions = self._require_actions()
if actions is None:
return
title = result.get("title", "")
description = result.get("description", "")
initial_message = result.get("message", "").strip()
await self._launch_action(
actions.create_task(
title=title,
description=description,
auto_run=bool(initial_message),
initial_message=initial_message or None,
mode=self.exec_mode,
company_profile=self.company_profile,
),
success_message="Task created.",
)
async def action_run_selected(self) -> None:
task = self.state.selected_task()
if task is None:
self.status_widget.set_message("No task selected.")
return
actions = self._require_actions()
if actions is None:
return
self._launch_background(
actions.run_task(task.task_id, mode=self.exec_mode, company_profile=self.company_profile),
success_message=f"Triggered {task.title}.",
)
def action_reply(self) -> None:
if self._readonly_guard():
return
self._action_reply()
@work(group="modal", exclusive=True)
async def _action_reply(self) -> None:
task = self.state.selected_task()
if task is None:
self.status_widget.set_message("No task selected.")
return
result = await self.push_screen_wait(
PromptScreen(
title=f"Reply to {task.title}",
fields=[PromptField("message", "Message", placeholder="Continue the session")],
confirm_label="Send",
)
)
if not result or not result.get("message", "").strip():
return
actions = self._require_actions()
if actions is None:
return
self._launch_background(
actions.send_session_message(
task.task_id,
result["message"],
mode=self.exec_mode,
company_profile=self.company_profile,
),
success_message=f"Sent a reply to {task.title}.",
)
def action_move_task(self) -> None:
if self._readonly_guard():
return
self._action_move_task()
@work(group="modal", exclusive=True)
async def _action_move_task(self) -> None:
task = self.state.selected_task()
if task is None:
self.status_widget.set_message("No task selected.")
return
result = await self.push_screen_wait(
PromptScreen(
title=f"Move {task.title}",
fields=[PromptField("column", "Target column", value=task.column_id, placeholder="todo / in-progress / done")],
confirm_label="Move",
)
)
if not result:
return
actions = self._require_actions()
if actions is None:
return
self._launch_background(
actions.move_task(task.task_id, result.get("column", task.column_id)),
success_message=f"Moved {task.title}.",
)
def action_approve_selected(self) -> None:
task = self.state.selected_task()
if task is None:
self.status_widget.set_message("No task selected.")
return
actions = self._require_actions()
if actions is None:
return
self._launch_background(
actions.approve_checkpoint(task.task_id, approved=True),
success_message=f"Approved checkpoint for {task.title}.",
)
def action_deny_selected(self) -> None:
task = self.state.selected_task()
if task is None:
self.status_widget.set_message("No task selected.")
return
actions = self._require_actions()
if actions is None:
return
self._launch_background(
actions.approve_checkpoint(task.task_id, approved=False),
success_message=f"Denied checkpoint for {task.title}.",
)
def action_mark_done(self) -> None:
task = self.state.selected_task()
if task is None:
self.status_widget.set_message("No task selected.")
return
actions = self._require_actions()
if actions is None:
return
self._launch_background(
actions.complete_task(task.task_id),
success_message=f"Marked {task.title} done.",
)
def action_cancel_task(self) -> None:
task = self.state.selected_task()
if task is None:
self.status_widget.set_message("No task selected.")
return
actions = self._require_actions()
if actions is None:
return
self._launch_background(
actions.cancel_task(task.task_id),
success_message=f"Cancelled {task.title}.",
)
def action_retry_selected(self) -> None:
task = self.state.selected_task()
if task is None:
self.status_widget.set_message("No task selected.")
return
actions = self._require_actions()
if actions is None:
return
self._launch_background(
actions.retry_task(task.task_id, mode=self.exec_mode, company_profile=self.company_profile),
success_message=f"Reran {task.title}.",
)
def action_switch_mode(self) -> None:
if self._readonly_guard():
return
self._action_switch_mode()
@work(group="modal", exclusive=True)
async def _action_switch_mode(self) -> None:
result = await self.push_screen_wait(
PromptScreen(
title="Switch Execution Mode",
fields=[
PromptField("mode", "Mode", value=self.exec_mode, placeholder="task / company / custom"),
PromptField("profile", "Company Profile", value=self.company_profile, placeholder="corporate / custom"),
],
help_text="Mode determines how tasks are orchestrated. Profile selects the company runtime variant.",
confirm_label="Apply",
)
)
if not result:
return
new_mode = result.get("mode", "").strip().lower()
new_profile = result.get("profile", "").strip().lower()
if new_mode in {"task", "company", "custom"}:
self.exec_mode = new_mode
if new_profile:
self.company_profile = new_profile
self.status_widget.set_message(f"Mode: {self.exec_mode}/{self.company_profile}.")
self._refresh_all_widgets()
def action_checkpoint_feedback(self) -> None:
if self._readonly_guard():
return
self._action_checkpoint_feedback()
@work(group="modal", exclusive=True)
async def _action_checkpoint_feedback(self) -> None:
task = self.state.selected_task()
if task is None:
self.status_widget.set_message("No task selected.")
return
if not task.pending_checkpoint:
self.status_widget.set_message("No pending checkpoint on this task.")
return
result = await self.push_screen_wait(
PromptScreen(
title=f"Checkpoint Feedback: {task.pending_checkpoint.short_label}",
fields=[
PromptField("feedback", "Your feedback", placeholder="Provide guidance or constraints...", multiline=True),
PromptField("action", "Action", value="approve", placeholder="approve / deny"),
],
help_text="The feedback text will be sent along with your approve/deny decision.",
confirm_label="Send",
)
)
if not result:
return
feedback_text = result.get("feedback", "").strip()
action_input = result.get("action", "approve").strip().lower()
actions = self._require_actions()
if actions is None:
return
decision = action_input if action_input in {"approve", "deny"} else "approve"
reply = f"{decision}: {feedback_text}" if feedback_text else decision
label = "Approved" if decision == "approve" else "Denied"
suffix = " with feedback" if feedback_text else ""
self._launch_background(
actions.approve_checkpoint(task.task_id, reply=reply),
success_message=f"{label} checkpoint for {task.title}{suffix}.",
)
def action_rename_session(self) -> None:
if self._readonly_guard():
return
self._action_rename_session()
@work(group="modal", exclusive=True)
async def _action_rename_session(self) -> None:
task = self.state.selected_task()
if task is None:
self.status_widget.set_message("No task selected.")
return
result = await self.push_screen_wait(
PromptScreen(
title="Rename Session",
fields=[PromptField("title", "New title", value=task.title)],
confirm_label="Rename",
)
)
if not result:
return
new_title = result.get("title", "").strip()
if not new_title:
self.status_widget.set_message("Title cannot be empty.")
return
actions = self._require_actions()
if actions is None:
return
try:
engine = await self.facade.ensure_ready()
if engine.store:
t = await engine.store.get_task(task.task_id)
if t:
t.title = new_title
await engine.store.save_task(t)
await self._refresh_snapshot(reason="rename", silent=True)
self.status_widget.set_message(f"Renamed to \"{new_title}\".")
except Exception as exc:
self.status_widget.set_message(f"Rename failed: {exc}")
def action_delete_session(self) -> None:
if self._readonly_guard():
return
self._action_delete_session()
@work(group="modal", exclusive=True)
async def _action_delete_session(self) -> None:
task = self.state.selected_task()
if task is None:
self.status_widget.set_message("No task selected.")
return
result = await self.push_screen_wait(
PromptScreen(
title=f"Delete \"{task.title}\"?",
fields=[PromptField("confirm", "Type 'yes' to confirm", placeholder="yes")],
help_text="This will cancel the task. This action cannot be undone.",
confirm_label="Delete",
)
)
if not result or result.get("confirm", "").strip().lower() != "yes":
self.status_widget.set_message("Cancelled.")
return
try:
engine = await self.facade.ensure_ready()
if engine.store:
await engine.store.hard_delete_task(task.task_id, task.session_id)
await self._refresh_snapshot(reason="delete", silent=True)
self.status_widget.set_message(f"Deleted {task.title}.")
except Exception as exc:
self.status_widget.set_message(f"Delete failed: {exc}")
def action_purge_cancelled(self) -> None:
if self._readonly_guard():
return
self._action_purge_cancelled()
@work(group="modal", exclusive=True)
async def _action_purge_cancelled(self) -> None:
engine = await self.facade.ensure_ready() if self.facade else None
if not engine or not engine.store:
self.status_widget.set_message("Store unavailable.")
return
project_id = self.project_id or "default"
from opc.core.models import TaskStatus
all_tasks = await engine.store.get_tasks(project_id=project_id)
purgeable = [
t for t in all_tasks
if t.status in (TaskStatus.CANCELLED, TaskStatus.FAILED)
]
if not purgeable:
self.status_widget.set_message("No cancelled/failed tasks to purge.")
return
result = await self.push_screen_wait(
PromptScreen(
title=f"Purge {len(purgeable)} cancelled/failed tasks?",
fields=[PromptField("confirm", "Type 'yes' to confirm", placeholder="yes")],
help_text=f"This permanently deletes {len(purgeable)} tasks and all their data from the database.",
confirm_label="Purge",
)
)
if not result or result.get("confirm", "").strip().lower() != "yes":
self.status_widget.set_message("Cancelled.")
return
deleted = 0
for task in purgeable:
try:
await engine.store.hard_delete_task(task.id, task.session_id)
deleted += 1
except Exception:
pass
await self._refresh_snapshot(reason="purge", silent=True)
self.status_widget.set_message(f"Purged {deleted} tasks.")
def action_switch_project(self) -> None:
if self._readonly_guard():
return
self._action_switch_project()
@work(group="modal", exclusive=True)
async def _action_switch_project(self) -> None:
# Discover available projects from .opc/projects/
project_ids: list[str] = []
if self.facade:
projects_dir = self.facade.opc_home / "projects"
if projects_dir.is_dir():
project_ids = sorted(
d.name for d in projects_dir.iterdir() if d.is_dir()
)
hint = ", ".join(project_ids[:8]) if project_ids else "no projects found"
result = await self.push_screen_wait(
PromptScreen(
title="Switch Project",
fields=[
PromptField("project", "Project ID", value=self.project_id or "default",
placeholder=hint),
],
help_text=f"Available: {hint}",
confirm_label="Switch",
)
)
if not result:
return
new_id = result.get("project", "").strip()
if not new_id or new_id == (self.project_id or "default"):
self.status_widget.set_message("Project unchanged.")
return
# Shutdown current engine and reinitialize
self.status_widget.set_message(f"Switching to {new_id}...")
if self.reconcile_loop:
self.reconcile_loop.stop()
if self.facade:
await self.facade.shutdown()
self.project_id = new_id
self._bootstrap_services()
if self.facade and self.event_bridge:
self.facade.configure_callbacks(
progress_callback=self.event_bridge.handle_progress,
event_callback=self.event_bridge.handle_event,
)
self._infer_default_mode()
await self._refresh_snapshot(reason="project_switch")
from opc.plugins.cli_board.services.reconcile import ReconcileLoop
self.reconcile_loop = ReconcileLoop(self.refresh_interval, self._refresh_from_reconcile)
self.run_worker(self.reconcile_loop.run(), group="reconcile", exclusive=True)
self.status_widget.set_message(f"Switched to project: {new_id}.")
def action_project_create(self) -> None:
if self._readonly_guard():
return
self._action_project_create()
@work(group="modal", exclusive=True)
async def _action_project_create(self) -> None:
result = await self.push_screen_wait(
PromptScreen(
title="Create Project",
fields=[PromptField("project", "Project ID", placeholder="letters, numbers, hyphens, underscores")],
confirm_label="Create",
)
)
if not result:
return
project_id = result.get("project", "").strip()
if not project_id:
self.status_widget.set_message("Project ID cannot be empty.")
return
try:
await self._run_office_service(lambda svc: svc.project.create(project_id, active_project_id=self.project_id or "default"))
self.status_widget.set_message(f"Created project: {project_id}.")
except Exception as exc:
self.status_widget.set_message(f"Project create failed: {exc}")
def action_project_delete(self) -> None:
if self._readonly_guard():
return
self._action_project_delete()
@work(group="modal", exclusive=True)
async def _action_project_delete(self) -> None:
result = await self.push_screen_wait(
PromptScreen(
title="Delete Project",
fields=[
PromptField("project", "Project ID", value=self.project_id or "default"),
PromptField("confirm", "Type 'yes' to confirm", placeholder="yes"),
],
help_text="The default project cannot be deleted.",
confirm_label="Delete",
)
)
if not result or result.get("confirm", "").strip().lower() != "yes":
self.status_widget.set_message("Cancelled.")
return
project_id = result.get("project", "").strip()
try:
await self._run_office_service(lambda svc: svc.project.delete(project_id))
self.status_widget.set_message(f"Deleted project: {project_id}.")
if project_id == (self.project_id or "default"):
self.project_id = "default"
if self.facade:
await self.facade.shutdown()
self._bootstrap_services()
if self.facade and self.event_bridge:
self.facade.configure_callbacks(
progress_callback=self.event_bridge.handle_progress,
event_callback=self.event_bridge.handle_event,
)
await self._refresh_snapshot(reason="project_delete", silent=True)
except Exception as exc:
self.status_widget.set_message(f"Project delete failed: {exc}")
def action_session_config(self) -> None:
if self._readonly_guard():
return
self._action_session_config()
@work(group="modal", exclusive=True)
async def _action_session_config(self) -> None:
task = self.state.selected_task()
if task is None:
self.status_widget.set_message("No session selected.")
return
metadata = dict(task.metadata or {})
result = await self.push_screen_wait(
PromptScreen(
title="Session Config",
fields=[
PromptField("mode", "Mode", value=str(metadata.get("exec_mode") or self.exec_mode), placeholder="task / company / org"),
PromptField("profile", "Company Profile", value=str(metadata.get("company_profile") or self.company_profile), placeholder="corporate / custom"),
PromptField("agent", "Preferred Agent", value=str(metadata.get("preferred_agent") or ""), placeholder="native / codex / claude_code"),
PromptField("org", "Org ID", value=str(metadata.get("org_id") or metadata.get("organization_id") or ""), placeholder="saved org id"),
],
confirm_label="Apply",
)
)
if not result:
return
try:
await self._run_office_service(
lambda svc: svc.session.update_config(
project_id=self.project_id or "default",
task_id=task.task_id,
exec_mode=result.get("mode") or None,
company_profile=result.get("profile") or None,
preferred_agent=result.get("agent") or None,
org_id=result.get("org") or None,
)
)
await self._refresh_snapshot(reason="session_config", silent=True)
self.status_widget.set_message("Session config updated.")
except Exception as exc:
self.status_widget.set_message(f"Session config failed: {exc}")
def action_org_add_role(self) -> None:
if self._readonly_guard():
return
self._action_org_add_role()
@work(group="modal", exclusive=True)
async def _action_org_add_role(self) -> None:
result = await self.push_screen_wait(
PromptScreen(
title="Add Org Role",
fields=[
PromptField("role_id", "Role ID", placeholder="qa_lead"),
PromptField("name", "Name", placeholder="QA Lead"),
PromptField("responsibility", "Responsibility", placeholder="Owns quality and review"),
PromptField("reports_to", "Reports To", value="owner"),
],
confirm_label="Add",
)
)
if not result or not result.get("role_id", "").strip():
return
try:
await self._run_office_service(lambda svc: svc.org.add_role({
"role_id": result.get("role_id", "").strip(),
"name": result.get("name", "").strip() or result.get("role_id", "").strip(),
"responsibility": result.get("responsibility", "").strip(),
"reports_to": result.get("reports_to", "").strip() or "owner",
}))
await self._load_org()
self.status_widget.set_message("Role added.")
except Exception as exc:
self.status_widget.set_message(f"Add role failed: {exc}")
def action_talent_scan(self) -> None:
self.run_worker(self._action_talent_scan(), group="panel", exclusive=True)
async def _action_talent_scan(self) -> None:
try:
result = await self._run_office_service(lambda svc: svc.talent.scan())
count = len(result.payload.get("templates", []) or [])
self.status_widget.set_message(f"Talent scan found {count} template(s).")
except Exception as exc:
self.status_widget.set_message(f"Talent scan failed: {exc}")
def action_market_browse(self) -> None:
self.run_worker(self._action_market_browse(), group="panel", exclusive=True)
async def _action_market_browse(self) -> None:
try:
result = await self._run_office_service(lambda svc: svc.market.browse())
count = len(result.payload.get("presets", []) or [])
self.status_widget.set_message(f"Market has {count} architecture preset(s).")
except Exception as exc:
self.status_widget.set_message(f"Market browse failed: {exc}")
def action_agent_list(self) -> None:
self.run_worker(self._action_agent_list(), group="panel", exclusive=True)
async def _action_agent_list(self) -> None:
try:
result = await self._run_office_service(lambda svc: svc.agent.list())
count = len(result.payload.get("agents", []) or [])
self.status_widget.set_message(f"Agents: {count}.")
except Exception as exc:
self.status_widget.set_message(f"Agent list failed: {exc}")
def action_role_logs(self) -> None:
self.run_worker(self._action_role_logs(), group="panel", exclusive=True)
async def _action_role_logs(self) -> None:
task = self.state.selected_task()
role_id = task.assigned_to if task is not None else ""
if not role_id:
self.status_widget.set_message("No role selected.")
return
try:
result = await self._run_office_service(lambda svc: svc.work_item.logs(project_id=self.project_id or "default", role_id=role_id, limit=50))
count = len(result.payload.get("events", []) or [])
self.status_widget.set_message(f"{role_id} logs: {count} event(s).")
except Exception as exc:
self.status_widget.set_message(f"Role logs failed: {exc}")
def action_toggle_done(self) -> None:
showing = self.state.toggle_show_done()
self.status_widget.set_message("Done tasks visible." if showing else "Done tasks hidden.")
self._selection_changed(load_detail=True)
def action_search(self) -> None:
self._action_search()
@work(group="modal", exclusive=True)
async def _action_search(self) -> None:
result = await self.push_screen_wait(
PromptScreen(
title="Search tasks",
fields=[PromptField("query", "Filter text", value=self.state.search_query)],
help_text="Matches title, description, tags, assignee, and status.",
confirm_label="Apply",
)
)
if result is None:
return
self.state.set_search_query(result.get("query", ""))
self.status_widget.set_message("Updated search filter.")
self._selection_changed(load_detail=True)
def action_refresh_board(self) -> None:
self.run_worker(self._refresh_snapshot(reason="manual"), group="refresh", exclusive=True)
def action_show_help(self) -> None:
self.push_screen(HelpScreen())
async def _refresh_from_reconcile(self) -> None:
await self._refresh_snapshot(reason="reconcile", silent=True)
async def _refresh_snapshot(self, *, reason: str, silent: bool = False) -> None:
if self.repository is None:
return
async with self._refresh_lock:
snapshot = await self.repository.load_snapshot()
self.state.replace_snapshot(snapshot)
await self._load_selected_detail()
if not silent:
self.status_widget.set_message(f"Board refreshed ({reason}).")
async def _apply_initial_inspector_target(self) -> None:
view = str(self.initial_view or "kanban").strip().lower().replace("_", "-")
if view == "work-item":
view = "focus"
elif view == "logs":
view = "focus"
self.state.set_context_tab("activity")
elif view == "role":
view = "list"
if self.initial_role_id:
self.state.set_search_query(self.initial_role_id)
if view not in {"kanban", "list", "focus", "pipeline", "org"}:
view = "kanban"
target_ids = {
str(self.initial_work_item_id or "").strip(),
str(self.initial_target or "").strip(),
}
if self.initial_session_id:
target_ids.add(str(self.initial_session_id).strip())
target_ids.discard("")
selected_id = ""
if target_ids:
for task in self.state.all_tasks():
candidates = {
str(task.task_id or ""),
str(task.session_id or ""),
str(task.work_item_id or ""),
str(task.runtime_task_id or ""),
str(task.execution_turn_id or ""),
}
if target_ids.intersection(candidates):
selected_id = task.task_id
break
if not selected_id and self.initial_role_id:
role_id = str(self.initial_role_id or "").strip()
for task in self.state.all_tasks():
if role_id and role_id in {str(task.assigned_to or ""), str(task.metadata.get("role_id", "") or "")}:
selected_id = task.task_id
break
if selected_id:
self.state.select_task(selected_id)
if self.attach:
self._sync_inspector_mode_from_selected_task()
self.state.set_view_mode(view) # type: ignore[arg-type]
if self.attach:
mode = "read-only " if self.readonly else ""
self.status_widget.set_message(f"Attached {mode}inspector. q/Ctrl-Q returns to chat; r refreshes.")
if view == "pipeline":
await self._load_pipeline_for_selected()
elif view == "org":
await self._load_org()
else:
await self._load_selected_detail()
def _sync_inspector_mode_from_selected_task(self) -> None:
"""Attached inspectors display the selected session identity, not global mode."""
task = self.state.selected_task()
if task is None:
return
metadata = dict(getattr(task, "metadata", {}) or {})
raw_mode = str(
metadata.get("exec_mode")
or metadata.get("mode")
or metadata.get("execution_mode")
or ""
).strip().lower()
raw_profile = str(metadata.get("company_profile") or "").strip().lower()
raw_org = str(metadata.get("org_id") or metadata.get("organization_id") or "").strip()
if raw_mode in {"org", "custom"} or raw_profile == "custom" or raw_org:
self.exec_mode = "org"
self.company_profile = "custom"
elif raw_mode in {"company", "company_mode"} or raw_profile == "corporate":
self.exec_mode = "company"
self.company_profile = "corporate"
elif raw_mode in {"task", "task_mode", "project", "project_mode", "single"}:
self.exec_mode = "task"
self.company_profile = "corporate"
async def _load_org(self) -> None:
org = None
if self.repository is not None:
org = await self.repository.load_org_snapshot()
self.org_widget.set_org(org)
self._refresh_all_widgets()
async def _load_pipeline_for_selected(self) -> None:
pipeline = None
if self.repository is not None:
selected = self.state.selected_task()
if selected:
pipeline = await self.repository.load_pipeline_state(
selected.task_id,
runtime_lookup=self.state.runtime_by_task,
)
self.pipeline_widget.set_pipeline(pipeline)
self._refresh_all_widgets()
async def _load_selected_detail(self) -> None:
detail = None
if self.repository is not None:
selected = self.state.selected_task()
detail = await self.repository.load_task_detail(selected.task_id) if selected else None
self.detail_widget.set_detail(detail)
self.session_widget.set_detail(detail)
self.focus_widget.set_detail(detail)
self.activity_widget.set_detail(detail)
# Auto-focus Session tab when task has conversation or is running
if detail and self.state.context_tab == "detail":
has_conversation = bool(detail.transcript)
is_active = detail.task.status in {"running", "idle", "blocked", "awaiting_review"}
runtime = self.state.runtime_for(detail.task.task_id)
has_runtime = runtime is not None and runtime.status not in {"idle", ""}
if has_conversation or is_active or has_runtime:
self.state.set_context_tab("session")
self._refresh_all_widgets()
def _selection_changed(self, *, load_detail: bool = True) -> None:
if load_detail:
self.run_worker(self._load_selected_detail(), group="detail", exclusive=True)
else:
self._refresh_all_widgets()
async def _handle_board_event(self, payload: dict[str, Any]) -> None:
kind = payload.get("kind")
if kind == "task_status":
self.state.apply_task_status(
str(payload.get("task_id", "") or ""),
str(payload.get("status", "") or ""),
column_id=str(payload.get("column_id", "") or ""),
)
self._selection_changed(load_detail=False)
return
if kind == "runtime":
self.state.apply_runtime_update(
str(payload.get("task_id", "") or ""),
status=str(payload.get("status", "") or "idle"),
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"),
)
self._refresh_all_widgets()
return
if kind == "progress":
task_id = str(payload.get("task_id", "") or "")
if task_id:
self.state.append_progress(task_id, str(payload.get("text", "") or ""))
if payload.get("current_tool"):
self.state.apply_runtime_update(
task_id,
status="tool_active",
current_tool=str(payload.get("current_tool") or ""),
)
self._refresh_all_widgets()
return
if kind == "refresh":
self.run_worker(
self._refresh_snapshot(reason=str(payload.get("reason", "event")), silent=True),
group="refresh",
exclusive=True,
)
async def _launch_action(self, coro: Any, *, success_message: str) -> None:
try:
await coro
await self._refresh_snapshot(reason="action", silent=True)
self.status_widget.set_message(success_message)
except Exception as exc:
self.status_widget.set_message(f"Action failed: {exc}")
def _launch_background(self, coro: Any, *, success_message: str) -> None:
self.status_widget.set_message("Working...")
self.run_worker(self._launch_action(coro, success_message=success_message), exclusive=False)
def _refresh_all_widgets(self) -> None:
self._sync_layout_state()
self.metrics_widget.exec_mode = self.exec_mode
self.metrics_widget.company_profile = self.company_profile
self.status_widget.exec_mode = self.exec_mode
self.status_widget.company_profile = self.company_profile
if self.pipeline_widget.pipeline:
self.metrics_widget.pipeline_done = self.pipeline_widget.pipeline.done_count
self.metrics_widget.pipeline_total = self.pipeline_widget.pipeline.total_count
else:
self.metrics_widget.pipeline_done = 0
self.metrics_widget.pipeline_total = 0
for widget in [
self.metrics_widget,
self.session_sidebar,
self.board_widget,
self.list_widget,
self.focus_widget,
self.pipeline_widget,
self.org_widget,
self.context_tabs_widget,
self.detail_widget,
self.session_widget,
self.activity_widget,
self.status_widget,
]:
widget.refresh()
def _sync_layout_state(self) -> None:
self._set_hidden(self.board_widget, self.state.view_mode != "kanban")
self._set_hidden(self.list_widget, self.state.view_mode != "list")
self._set_hidden(self.focus_widget, self.state.view_mode != "focus")
self._set_hidden(self.pipeline_widget, self.state.view_mode != "pipeline")
self._set_hidden(self.org_widget, self.state.view_mode != "org")
self._set_hidden(self.detail_widget, self.state.context_tab != "detail")
self._set_hidden(self.session_widget, self.state.context_tab != "session")
self._set_hidden(self.activity_widget, self.state.context_tab != "activity")
self._set_hidden(self.query_one("#session-shell"), self.state.view_mode in {"focus", "pipeline", "org"})
self._set_focus_class(self.query_one("#session-shell"), self.state.pane_focus == "session-rail")
self._set_focus_class(self.query_one("#main-shell"), self.state.pane_focus == "main")
self._set_focus_class(self.query_one("#context-shell"), self.state.pane_focus == "context")
@staticmethod
def _set_hidden(node: Any, hidden: bool) -> None:
if hidden:
node.add_class("hidden")
else:
node.remove_class("hidden")
@staticmethod
def _set_focus_class(node: Any, focused: bool) -> None:
if focused:
node.add_class("pane-focused")
else:
node.remove_class("pane-focused")
def _palette_commands(self) -> list[PaletteCommand]:
commands = [
PaletteCommand("new_task", "Create Task", "Create a new task and optional first prompt.", "n"),
PaletteCommand("run_selected", "Run Selected Task", "Start the selected task in the current mode.", "g"),
PaletteCommand("reply", "Reply in Session", "Send a follow-up message to the selected session.", "s"),
PaletteCommand("move_task", "Move Task", "Move the selected task to another lane.", "m"),
PaletteCommand("approve_selected", "Approve Checkpoint", "Approve the pending checkpoint.", "a"),
PaletteCommand("deny_selected", "Deny Checkpoint", "Reject the pending checkpoint.", "d"),
PaletteCommand("mark_done", "Mark Done", "Mark the selected task as done.", "c"),
PaletteCommand("cancel_task", "Cancel Task", "Cancel the selected task.", "x"),
PaletteCommand("retry_selected", "Retry Task", "Retry the selected task.", "t"),
PaletteCommand("checkpoint_feedback", "Checkpoint Feedback", "Approve or deny with custom feedback.", "e"),
PaletteCommand("switch_mode", "Switch Execution Mode", "Change between task, company, and custom modes.", "E"),
PaletteCommand("search", "Search / Filter", "Filter tasks and sessions by text.", "/"),
PaletteCommand("refresh_board", "Refresh Board", "Reload the board snapshot from storage.", "r"),
PaletteCommand("project_create", "Create Project", "Create a project through the shared Office service.", ""),
PaletteCommand("project_delete", "Delete Project", "Delete a project through the shared Office service.", ""),
PaletteCommand("session_config", "Configure Session", "Edit selected session mode, profile, agent, and org.", ""),
PaletteCommand("org_add_role", "Add Org Role", "Add a role through the shared Office service.", ""),
PaletteCommand("talent_scan", "Talent Scan", "Scan local recruitable talent templates.", ""),
PaletteCommand("market_browse", "Market Browse", "Browse architecture presets.", ""),
PaletteCommand("agent_list", "Agent List", "Refresh visual office agent count.", ""),
PaletteCommand("role_logs", "Role Logs", "Aggregate selected role work-item events.", ""),
PaletteCommand("view_kanban", "Switch to Kanban", "Show the command-center Kanban view.", "1"),
PaletteCommand("view_list", "Switch to List", "Show a dense linear task list.", "2"),
PaletteCommand("view_focus", "Switch to Focus", "Zoom into the selected task.", "3"),
PaletteCommand("view_pipeline", "Switch to Projection", "Show the read-only work-item projection for the selected company run.", "4"),
PaletteCommand("view_org", "Switch to Organisation", "Show read-only org structure.", "5"),
PaletteCommand("rename_session", "Rename Session", "Change the title of the selected task.", "R"),
PaletteCommand("delete_session", "Delete Session", "Cancel and remove the selected task.", "D"),
PaletteCommand("purge_cancelled", "Purge Cancelled Tasks", "Permanently delete all cancelled/failed tasks.", ""),
PaletteCommand("switch_project", "Switch Project", "Change the active project.", ""),
PaletteCommand("toggle_done", "Toggle Done Visibility", "Hide or show done tasks.", "f"),
PaletteCommand("toggle_density", "Toggle Density", "Switch between compact and comfortable density.", "space"),
PaletteCommand("focus_next_pane", "Focus Next Pane", "Cycle focus between session rail, viewport, and dock.", "tab"),
PaletteCommand("show_help", "Open Help", "Display grouped keyboard shortcuts.", "?"),
]
if not self.readonly:
return commands
mutating = {
"new_task",
"run_selected",
"reply",
"move_task",
"approve_selected",
"deny_selected",
"mark_done",
"cancel_task",
"retry_selected",
"checkpoint_feedback",
"switch_mode",
"project_create",
"project_delete",
"session_config",
"org_add_role",
"rename_session",
"delete_session",
"purge_cancelled",
"switch_project",
}
return [command for command in commands if command.command_id not in mutating]
async def _dispatch_palette_command(self, command_id: str) -> None:
if command_id == "run_selected":
await self.action_run_selected()
return
action = getattr(self, f"action_{command_id}", None)
if action is None:
self.status_widget.set_message(f"Unknown palette command: {command_id}")
return
result = action()
if asyncio.iscoroutine(result):
await result