499 lines
18 KiB
Python
499 lines
18 KiB
Python
"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses.
|
||
|
||
Copy this file into your CLI package at:
|
||
cli_anything/<software>/utils/repl_skin.py
|
||
|
||
Usage:
|
||
from cli_anything.<software>.utils.repl_skin import ReplSkin
|
||
|
||
skin = ReplSkin("shotcut", version="1.0.0")
|
||
skin.print_banner()
|
||
prompt_text = skin.prompt(project_name="my_video.mlt", modified=True)
|
||
skin.success("Project saved")
|
||
skin.error("File not found")
|
||
skin.warning("Unsaved changes")
|
||
skin.info("Processing 24 clips...")
|
||
skin.status("Track 1", "3 clips, 00:02:30")
|
||
skin.table(headers, rows)
|
||
skin.print_goodbye()
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
|
||
# ── ANSI color codes (no external deps for core styling) ──────────────
|
||
|
||
_RESET = "\033[0m"
|
||
_BOLD = "\033[1m"
|
||
_DIM = "\033[2m"
|
||
_ITALIC = "\033[3m"
|
||
_UNDERLINE = "\033[4m"
|
||
|
||
# Brand colors
|
||
_CYAN = "\033[38;5;80m" # cli-anything brand cyan
|
||
_CYAN_BG = "\033[48;5;80m"
|
||
_WHITE = "\033[97m"
|
||
_GRAY = "\033[38;5;245m"
|
||
_DARK_GRAY = "\033[38;5;240m"
|
||
_LIGHT_GRAY = "\033[38;5;250m"
|
||
|
||
# Software accent colors — each software gets a unique accent
|
||
_ACCENT_COLORS = {
|
||
"gimp": "\033[38;5;214m", # warm orange
|
||
"blender": "\033[38;5;208m", # deep orange
|
||
"inkscape": "\033[38;5;39m", # bright blue
|
||
"audacity": "\033[38;5;33m", # navy blue
|
||
"libreoffice": "\033[38;5;40m", # green
|
||
"obs_studio": "\033[38;5;55m", # purple
|
||
"kdenlive": "\033[38;5;69m", # slate blue
|
||
"shotcut": "\033[38;5;35m", # teal green
|
||
}
|
||
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
|
||
|
||
# Status colors
|
||
_GREEN = "\033[38;5;78m"
|
||
_YELLOW = "\033[38;5;220m"
|
||
_RED = "\033[38;5;196m"
|
||
_BLUE = "\033[38;5;75m"
|
||
_MAGENTA = "\033[38;5;176m"
|
||
|
||
# ── Brand icon ────────────────────────────────────────────────────────
|
||
|
||
# The cli-anything icon: a small colored diamond/chevron mark
|
||
_ICON = f"{_CYAN}{_BOLD}◆{_RESET}"
|
||
_ICON_SMALL = f"{_CYAN}▸{_RESET}"
|
||
|
||
# ── Box drawing characters ────────────────────────────────────────────
|
||
|
||
_H_LINE = "─"
|
||
_V_LINE = "│"
|
||
_TL = "╭"
|
||
_TR = "╮"
|
||
_BL = "╰"
|
||
_BR = "╯"
|
||
_T_DOWN = "┬"
|
||
_T_UP = "┴"
|
||
_T_RIGHT = "├"
|
||
_T_LEFT = "┤"
|
||
_CROSS = "┼"
|
||
|
||
|
||
def _strip_ansi(text: str) -> str:
|
||
"""Remove ANSI escape codes for length calculation."""
|
||
import re
|
||
return re.sub(r"\033\[[^m]*m", "", text)
|
||
|
||
|
||
def _visible_len(text: str) -> int:
|
||
"""Get visible length of text (excluding ANSI codes)."""
|
||
return len(_strip_ansi(text))
|
||
|
||
|
||
class ReplSkin:
|
||
"""Unified REPL skin for cli-anything CLIs.
|
||
|
||
Provides consistent branding, prompts, and message formatting
|
||
across all CLI harnesses built with the cli-anything methodology.
|
||
"""
|
||
|
||
def __init__(self, software: str, version: str = "1.0.0",
|
||
history_file: str | None = None):
|
||
"""Initialize the REPL skin.
|
||
|
||
Args:
|
||
software: Software name (e.g., "gimp", "shotcut", "blender").
|
||
version: CLI version string.
|
||
history_file: Path for persistent command history.
|
||
Defaults to ~/.cli-anything-<software>/history
|
||
"""
|
||
self.software = software.lower().replace("-", "_")
|
||
self.display_name = software.replace("_", " ").title()
|
||
self.version = version
|
||
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
|
||
|
||
# History file
|
||
if history_file is None:
|
||
from pathlib import Path
|
||
hist_dir = Path.home() / f".cli-anything-{self.software}"
|
||
hist_dir.mkdir(parents=True, exist_ok=True)
|
||
self.history_file = str(hist_dir / "history")
|
||
else:
|
||
self.history_file = history_file
|
||
|
||
# Detect terminal capabilities
|
||
self._color = self._detect_color_support()
|
||
|
||
def _detect_color_support(self) -> bool:
|
||
"""Check if terminal supports color."""
|
||
if os.environ.get("NO_COLOR"):
|
||
return False
|
||
if os.environ.get("CLI_ANYTHING_NO_COLOR"):
|
||
return False
|
||
if not hasattr(sys.stdout, "isatty"):
|
||
return False
|
||
return sys.stdout.isatty()
|
||
|
||
def _c(self, code: str, text: str) -> str:
|
||
"""Apply color code if colors are supported."""
|
||
if not self._color:
|
||
return text
|
||
return f"{code}{text}{_RESET}"
|
||
|
||
# ── Banner ────────────────────────────────────────────────────────
|
||
|
||
def print_banner(self):
|
||
"""Print the startup banner with branding."""
|
||
inner = 54
|
||
|
||
def _box_line(content: str) -> str:
|
||
"""Wrap content in box drawing, padding to inner width."""
|
||
pad = inner - _visible_len(content)
|
||
vl = self._c(_DARK_GRAY, _V_LINE)
|
||
return f"{vl}{content}{' ' * max(0, pad)}{vl}"
|
||
|
||
top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}")
|
||
bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}")
|
||
|
||
# Title: ◆ cli-anything · Shotcut
|
||
icon = self._c(_CYAN + _BOLD, "◆")
|
||
brand = self._c(_CYAN + _BOLD, "cli-anything")
|
||
dot = self._c(_DARK_GRAY, "·")
|
||
name = self._c(self.accent + _BOLD, self.display_name)
|
||
title = f" {icon} {brand} {dot} {name}"
|
||
|
||
ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}"
|
||
tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}"
|
||
empty = ""
|
||
|
||
print(top)
|
||
print(_box_line(title))
|
||
print(_box_line(ver))
|
||
print(_box_line(empty))
|
||
print(_box_line(tip))
|
||
print(bot)
|
||
print()
|
||
|
||
# ── Prompt ────────────────────────────────────────────────────────
|
||
|
||
def prompt(self, project_name: str = "", modified: bool = False,
|
||
context: str = "") -> str:
|
||
"""Build a styled prompt string for prompt_toolkit or input().
|
||
|
||
Args:
|
||
project_name: Current project name (empty if none open).
|
||
modified: Whether the project has unsaved changes.
|
||
context: Optional extra context to show in prompt.
|
||
|
||
Returns:
|
||
Formatted prompt string.
|
||
"""
|
||
parts = []
|
||
|
||
# Icon
|
||
if self._color:
|
||
parts.append(f"{_CYAN}◆{_RESET} ")
|
||
else:
|
||
parts.append("> ")
|
||
|
||
# Software name
|
||
parts.append(self._c(self.accent + _BOLD, self.software))
|
||
|
||
# Project context
|
||
if project_name or context:
|
||
ctx = context or project_name
|
||
mod = "*" if modified else ""
|
||
parts.append(f" {self._c(_DARK_GRAY, '[')}")
|
||
parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}"))
|
||
parts.append(self._c(_DARK_GRAY, ']'))
|
||
|
||
parts.append(self._c(_GRAY, " ❯ "))
|
||
|
||
return "".join(parts)
|
||
|
||
def prompt_tokens(self, project_name: str = "", modified: bool = False,
|
||
context: str = ""):
|
||
"""Build prompt_toolkit formatted text tokens for the prompt.
|
||
|
||
Use with prompt_toolkit's FormattedText for proper ANSI handling.
|
||
|
||
Returns:
|
||
list of (style, text) tuples for prompt_toolkit.
|
||
"""
|
||
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
|
||
tokens = []
|
||
|
||
tokens.append(("class:icon", "◆ "))
|
||
tokens.append(("class:software", self.software))
|
||
|
||
if project_name or context:
|
||
ctx = context or project_name
|
||
mod = "*" if modified else ""
|
||
tokens.append(("class:bracket", " ["))
|
||
tokens.append(("class:context", f"{ctx}{mod}"))
|
||
tokens.append(("class:bracket", "]"))
|
||
|
||
tokens.append(("class:arrow", " ❯ "))
|
||
|
||
return tokens
|
||
|
||
def get_prompt_style(self):
|
||
"""Get a prompt_toolkit Style object matching the skin.
|
||
|
||
Returns:
|
||
prompt_toolkit.styles.Style
|
||
"""
|
||
try:
|
||
from prompt_toolkit.styles import Style
|
||
except ImportError:
|
||
return None
|
||
|
||
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
|
||
|
||
return Style.from_dict({
|
||
"icon": "#5fdfdf bold", # cyan brand color
|
||
"software": f"{accent_hex} bold",
|
||
"bracket": "#585858",
|
||
"context": "#bcbcbc",
|
||
"arrow": "#808080",
|
||
# Completion menu
|
||
"completion-menu.completion": "bg:#303030 #bcbcbc",
|
||
"completion-menu.completion.current": f"bg:{accent_hex} #000000",
|
||
"completion-menu.meta.completion": "bg:#303030 #808080",
|
||
"completion-menu.meta.completion.current": f"bg:{accent_hex} #000000",
|
||
# Auto-suggest
|
||
"auto-suggest": "#585858",
|
||
# Bottom toolbar
|
||
"bottom-toolbar": "bg:#1c1c1c #808080",
|
||
"bottom-toolbar.text": "#808080",
|
||
})
|
||
|
||
# ── Messages ──────────────────────────────────────────────────────
|
||
|
||
def success(self, message: str):
|
||
"""Print a success message with green checkmark."""
|
||
icon = self._c(_GREEN + _BOLD, "✓")
|
||
print(f" {icon} {self._c(_GREEN, message)}")
|
||
|
||
def error(self, message: str):
|
||
"""Print an error message with red cross."""
|
||
icon = self._c(_RED + _BOLD, "✗")
|
||
print(f" {icon} {self._c(_RED, message)}", file=sys.stderr)
|
||
|
||
def warning(self, message: str):
|
||
"""Print a warning message with yellow triangle."""
|
||
icon = self._c(_YELLOW + _BOLD, "⚠")
|
||
print(f" {icon} {self._c(_YELLOW, message)}")
|
||
|
||
def info(self, message: str):
|
||
"""Print an info message with blue dot."""
|
||
icon = self._c(_BLUE, "●")
|
||
print(f" {icon} {self._c(_LIGHT_GRAY, message)}")
|
||
|
||
def hint(self, message: str):
|
||
"""Print a subtle hint message."""
|
||
print(f" {self._c(_DARK_GRAY, message)}")
|
||
|
||
def section(self, title: str):
|
||
"""Print a section header."""
|
||
print()
|
||
print(f" {self._c(self.accent + _BOLD, title)}")
|
||
print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}")
|
||
|
||
# ── Status display ────────────────────────────────────────────────
|
||
|
||
def status(self, label: str, value: str):
|
||
"""Print a key-value status line."""
|
||
lbl = self._c(_GRAY, f" {label}:")
|
||
val = self._c(_WHITE, f" {value}")
|
||
print(f"{lbl}{val}")
|
||
|
||
def status_block(self, items: dict[str, str], title: str = ""):
|
||
"""Print a block of status key-value pairs.
|
||
|
||
Args:
|
||
items: Dict of label -> value pairs.
|
||
title: Optional title for the block.
|
||
"""
|
||
if title:
|
||
self.section(title)
|
||
|
||
max_key = max(len(k) for k in items) if items else 0
|
||
for label, value in items.items():
|
||
lbl = self._c(_GRAY, f" {label:<{max_key}}")
|
||
val = self._c(_WHITE, f" {value}")
|
||
print(f"{lbl}{val}")
|
||
|
||
def progress(self, current: int, total: int, label: str = ""):
|
||
"""Print a simple progress indicator.
|
||
|
||
Args:
|
||
current: Current step number.
|
||
total: Total number of steps.
|
||
label: Optional label for the progress.
|
||
"""
|
||
pct = int(current / total * 100) if total > 0 else 0
|
||
bar_width = 20
|
||
filled = int(bar_width * current / total) if total > 0 else 0
|
||
bar = "█" * filled + "░" * (bar_width - filled)
|
||
text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}"
|
||
if label:
|
||
text += f" {self._c(_LIGHT_GRAY, label)}"
|
||
print(text)
|
||
|
||
# ── Table display ─────────────────────────────────────────────────
|
||
|
||
def table(self, headers: list[str], rows: list[list[str]],
|
||
max_col_width: int = 40):
|
||
"""Print a formatted table with box-drawing characters.
|
||
|
||
Args:
|
||
headers: Column header strings.
|
||
rows: List of rows, each a list of cell strings.
|
||
max_col_width: Maximum column width before truncation.
|
||
"""
|
||
if not headers:
|
||
return
|
||
|
||
# Calculate column widths
|
||
col_widths = [min(len(h), max_col_width) for h in headers]
|
||
for row in rows:
|
||
for i, cell in enumerate(row):
|
||
if i < len(col_widths):
|
||
col_widths[i] = min(
|
||
max(col_widths[i], len(str(cell))), max_col_width
|
||
)
|
||
|
||
def pad(text: str, width: int) -> str:
|
||
t = str(text)[:width]
|
||
return t + " " * (width - len(t))
|
||
|
||
# Header
|
||
header_cells = [
|
||
self._c(_CYAN + _BOLD, pad(h, col_widths[i]))
|
||
for i, h in enumerate(headers)
|
||
]
|
||
sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
|
||
header_line = f" {sep.join(header_cells)}"
|
||
print(header_line)
|
||
|
||
# Separator
|
||
sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths]
|
||
sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}")
|
||
print(sep_line)
|
||
|
||
# Rows
|
||
for row in rows:
|
||
cells = []
|
||
for i, cell in enumerate(row):
|
||
if i < len(col_widths):
|
||
cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i])))
|
||
row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
|
||
print(f" {row_sep.join(cells)}")
|
||
|
||
# ── Help display ──────────────────────────────────────────────────
|
||
|
||
def help(self, commands: dict[str, str]):
|
||
"""Print a formatted help listing.
|
||
|
||
Args:
|
||
commands: Dict of command -> description pairs.
|
||
"""
|
||
self.section("Commands")
|
||
max_cmd = max(len(c) for c in commands) if commands else 0
|
||
for cmd, desc in commands.items():
|
||
cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}")
|
||
desc_styled = self._c(_GRAY, f" {desc}")
|
||
print(f"{cmd_styled}{desc_styled}")
|
||
print()
|
||
|
||
# ── Goodbye ───────────────────────────────────────────────────────
|
||
|
||
def print_goodbye(self):
|
||
"""Print a styled goodbye message."""
|
||
print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n")
|
||
|
||
# ── Prompt toolkit session factory ────────────────────────────────
|
||
|
||
def create_prompt_session(self):
|
||
"""Create a prompt_toolkit PromptSession with skin styling.
|
||
|
||
Returns:
|
||
A configured PromptSession, or None if prompt_toolkit unavailable.
|
||
"""
|
||
try:
|
||
from prompt_toolkit import PromptSession
|
||
from prompt_toolkit.history import FileHistory
|
||
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
||
from prompt_toolkit.formatted_text import FormattedText
|
||
|
||
style = self.get_prompt_style()
|
||
|
||
session = PromptSession(
|
||
history=FileHistory(self.history_file),
|
||
auto_suggest=AutoSuggestFromHistory(),
|
||
style=style,
|
||
enable_history_search=True,
|
||
)
|
||
return session
|
||
except ImportError:
|
||
return None
|
||
|
||
def get_input(self, pt_session, project_name: str = "",
|
||
modified: bool = False, context: str = "") -> str:
|
||
"""Get input from user using prompt_toolkit or fallback.
|
||
|
||
Args:
|
||
pt_session: A prompt_toolkit PromptSession (or None).
|
||
project_name: Current project name.
|
||
modified: Whether project has unsaved changes.
|
||
context: Optional context string.
|
||
|
||
Returns:
|
||
User input string (stripped).
|
||
"""
|
||
if pt_session is not None:
|
||
from prompt_toolkit.formatted_text import FormattedText
|
||
tokens = self.prompt_tokens(project_name, modified, context)
|
||
return pt_session.prompt(FormattedText(tokens)).strip()
|
||
else:
|
||
raw_prompt = self.prompt(project_name, modified, context)
|
||
return input(raw_prompt).strip()
|
||
|
||
# ── Toolbar builder ───────────────────────────────────────────────
|
||
|
||
def bottom_toolbar(self, items: dict[str, str]):
|
||
"""Create a bottom toolbar callback for prompt_toolkit.
|
||
|
||
Args:
|
||
items: Dict of label -> value pairs to show in toolbar.
|
||
|
||
Returns:
|
||
A callable that returns FormattedText for the toolbar.
|
||
"""
|
||
def toolbar():
|
||
from prompt_toolkit.formatted_text import FormattedText
|
||
parts = []
|
||
for i, (k, v) in enumerate(items.items()):
|
||
if i > 0:
|
||
parts.append(("class:bottom-toolbar.text", " │ "))
|
||
parts.append(("class:bottom-toolbar.text", f" {k}: "))
|
||
parts.append(("class:bottom-toolbar", v))
|
||
return FormattedText(parts)
|
||
return toolbar
|
||
|
||
|
||
# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ─────────
|
||
|
||
_ANSI_256_TO_HEX = {
|
||
"\033[38;5;33m": "#0087ff", # audacity navy blue
|
||
"\033[38;5;35m": "#00af5f", # shotcut teal
|
||
"\033[38;5;39m": "#00afff", # inkscape bright blue
|
||
"\033[38;5;40m": "#00d700", # libreoffice green
|
||
"\033[38;5;55m": "#5f00af", # obs purple
|
||
"\033[38;5;69m": "#5f87ff", # kdenlive slate blue
|
||
"\033[38;5;75m": "#5fafff", # default sky blue
|
||
"\033[38;5;80m": "#5fd7d7", # brand cyan
|
||
"\033[38;5;208m": "#ff8700", # blender deep orange
|
||
"\033[38;5;214m": "#ffaf00", # gimp warm orange
|
||
}
|