180 lines
7.0 KiB
Python
180 lines
7.0 KiB
Python
"""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
|