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>
218 lines
7.4 KiB
Python
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"
|