Files
luzia/lib/cli_feedback.py
admin ec33ac1936 Refactor cockpit to use DockerTmuxController pattern
Based on claude-code-tools TmuxCLIController, this refactor:

- Added DockerTmuxController class for robust tmux session management
- Implements send_keys() with configurable delay_enter
- Implements capture_pane() for output retrieval
- Implements wait_for_prompt() for pattern-based completion detection
- Implements wait_for_idle() for content-hash-based idle detection
- Implements wait_for_shell_prompt() for shell prompt detection

Also includes workflow improvements:
- Pre-task git snapshot before agent execution
- Post-task commit protocol in agent guidelines

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:42:16 -03:00

218 lines
7.4 KiB
Python

#!/usr/bin/env python3
"""
CLI Feedback System - Non-blocking Status Display and Progress Tracking
Provides responsive feedback to the user while tasks run in the background:
- Immediate job confirmation with job_id
- Live progress indicators
- Status polling without blocking
- Pretty-printed status displays
- Multi-task tracking
"""
import json
import sys
from typing import Dict, Optional, List
from datetime import datetime
from pathlib import Path
class Colors:
"""ANSI color codes for terminal output"""
GREEN = "\033[92m"
YELLOW = "\033[93m"
RED = "\033[91m"
BLUE = "\033[94m"
CYAN = "\033[96m"
GRAY = "\033[90m"
BOLD = "\033[1m"
RESET = "\033[0m"
@staticmethod
def status_color(status: str) -> str:
"""Get color for status"""
colors = {
"dispatched": Colors.CYAN,
"starting": Colors.BLUE,
"running": Colors.YELLOW,
"completed": Colors.GREEN,
"failed": Colors.RED,
"killed": Colors.RED,
"stalled": Colors.YELLOW,
}
return colors.get(status, Colors.GRAY)
class ProgressBar:
"""ASCII progress bar renderer"""
@staticmethod
def render(progress: int, width: int = 20) -> str:
"""Render progress bar"""
filled = int(width * progress / 100)
bar = "" * filled + "" * (width - filled)
return f"[{bar}] {progress}%"
class CLIFeedback:
"""Non-blocking feedback system for task dispatch"""
@staticmethod
def job_dispatched(job_id: str, project: str, task: str, show_details: bool = False) -> None:
"""Show immediate feedback when job is dispatched"""
print(f"\n{Colors.GREEN}{Colors.BOLD}✓ Dispatched{Colors.RESET}")
print(f" {Colors.BOLD}Job ID:{Colors.RESET} {job_id}")
print(f" {Colors.BOLD}Project:{Colors.RESET} {project}")
if show_details and len(task) <= 60:
print(f" {Colors.BOLD}Task:{Colors.RESET} {task}")
elif show_details and len(task) > 60:
print(f" {Colors.BOLD}Task:{Colors.RESET} {task[:57]}...")
print(f"\n {Colors.GRAY}Use: {Colors.CYAN}luzia jobs{Colors.GRAY} to view status")
print(f" {Colors.CYAN}luzia jobs {job_id}{Colors.GRAY} for details{Colors.RESET}\n")
@staticmethod
def show_status(status: Dict, show_full: bool = False) -> None:
"""Pretty-print job status"""
job_id = status.get("id", "unknown")
job_status = status.get("status", "unknown")
progress = status.get("progress", 0)
message = status.get("message", "")
project = status.get("project", "")
status_color = Colors.status_color(job_status)
status_text = job_status.upper()
# Single line summary
bar = ProgressBar.render(progress)
print(f" {status_color}{status_text:12}{Colors.RESET} {bar} {message}")
if show_full:
print(f"\n {Colors.BOLD}Details:{Colors.RESET}")
print(f" Job ID: {job_id}")
print(f" Project: {project}")
print(f" Status: {job_status}")
print(f" Progress: {progress}%")
print(f" Message: {message}")
# Show timestamps
created = status.get("dispatched_at")
updated = status.get("updated_at")
if created:
print(f" Created: {created}")
if updated:
print(f" Updated: {updated}")
# Show exit code if completed
if "exit_code" in status:
print(f" Exit Code: {status['exit_code']}")
@staticmethod
def show_status_line(status: Dict) -> str:
"""Format status as single line for list views"""
job_id = status.get("id", "unknown")
job_status = status.get("status", "unknown")
progress = status.get("progress", 0)
message = status.get("message", "")
project = status.get("project", "")
status_color = Colors.status_color(job_status)
status_text = f"{status_color}{job_status:10}{Colors.RESET}"
progress_text = f"{progress:3d}%"
project_text = f"{project:12}"
# Truncate message
if len(message) > 40:
message = message[:37] + "..."
return f" {job_id:13} {status_text} {progress_text} {project_text} {message}"
@staticmethod
def show_jobs_list(jobs: List[Dict]) -> None:
"""Pretty-print list of jobs"""
if not jobs:
print(f" {Colors.GRAY}No jobs found{Colors.RESET}")
return
print(f"\n {Colors.BOLD}Recent Jobs:{Colors.RESET}\n")
print(f" {'Job ID':13} {'Status':10} {'Prog'} {'Project':12} Message")
print(f" {'-' * 100}")
for job in jobs[:20]: # Show last 20
print(CLIFeedback.show_status_line(job))
print()
@staticmethod
def show_concurrent_jobs(jobs: List[Dict], max_shown: int = 5) -> None:
"""Show summary of concurrent jobs"""
if not jobs:
return
running = [j for j in jobs if j.get("status") == "running"]
pending = [j for j in jobs if j.get("status") == "dispatched"]
completed = [j for j in jobs if j.get("status") == "completed"]
failed = [j for j in jobs if j.get("status") == "failed"]
print(f"\n{Colors.BOLD}Task Summary:{Colors.RESET}")
print(f" {Colors.YELLOW}Running:{Colors.RESET} {len(running)}")
print(f" {Colors.CYAN}Pending:{Colors.RESET} {len(pending)}")
print(f" {Colors.GREEN}Completed:{Colors.RESET} {len(completed)}")
print(f" {Colors.RED}Failed:{Colors.RESET} {len(failed)}")
if running:
print(f"\n{Colors.BOLD}Currently Running:{Colors.RESET}")
for job in running[:max_shown]:
CLIFeedback.show_status(job)
@staticmethod
def spinner(status_func, interval: float = 0.1):
"""Show spinning indicator while waiting"""
import itertools
spinner = itertools.cycle(["|", "/", "-", "\\"])
while True:
char = next(spinner)
print(f"\r {char} ", end="", flush=True)
result = status_func()
if result:
print(f"\r", end="")
return result
sys.stdout.flush()
class ResponseiveOutput:
"""Context manager for responsive output during long operations"""
def __init__(self, message: str = "Processing"):
self.message = message
self.status = "running"
def __enter__(self):
print(f"{Colors.CYAN}{self.message}...{Colors.RESET}", end="", flush=True)
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is None:
print(f"\r{Colors.GREEN}{self.message}{Colors.RESET}")
else:
print(f"\r{Colors.RED}{self.message} ({exc_type.__name__}){Colors.RESET}")
return False
def update(self, message: str):
"""Update the message"""
self.message = message
print(f"\r{Colors.CYAN}{self.message}...{Colors.RESET}", end="", flush=True)
def format_duration(seconds: float) -> str:
"""Format duration in human-readable format"""
if seconds < 60:
return f"{int(seconds)}s"
elif seconds < 3600:
return f"{int(seconds // 60)}m {int(seconds % 60)}s"
else:
return f"{int(seconds // 3600)}h {int((seconds % 3600) // 60)}m"