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>
This commit is contained in:
217
lib/cli_feedback.py
Normal file
217
lib/cli_feedback.py
Normal file
@@ -0,0 +1,217 @@
|
||||
#!/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"
|
||||
Reference in New Issue
Block a user