@@ -1,193 +1,500 @@
""" Shared cli-anything REPL s kin for cli-anything-qgis. """
""" cli-anything REPL S kin — Unified terminal interface for all CLI harnesses.
from __future__ import annotations
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 "
_DARK_GRAY = " \033 [38;5;240 m "
_DIM = " \033 [2 m "
_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 "
_CYAN = " \033 [38;5;80m "
# 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
" qgis " : " \033 [38;5;34m " , # qgis 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 "
_ACCENT_COLORS = {
" qgis " : " \033 [38;5;34m " ,
}
_DEFAULT_ACCENT = " \033 [38;5;75m "
# ── 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 ────────────────────────────────────────────
_ICON_SMALL = " ▸ "
_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 :
""" Small terminal UI wrapper for the QGIS REPL. """
""" Unified REPL skin for cli-anything CLIs.
def __init__ ( self , software : str , version : str = " 1.0.0 " , history_file : str | None = None ) :
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
history_dir = Path . home ( ) / f " .cli-anything- { self . software } "
history_dir . mkdir ( parents = True , exist_ok = True )
self . history_file = str ( history_dir / " history " )
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 :
if os . environ . get ( " NO_COLOR " ) or os . environ . get ( " CLI_ANYTHING_NO_COLOR " ) :
""" 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 } "
def print_banner ( self ) - > None :
# ── Banner ────────────────────────────────────────────────────────
def print_banner ( self ) :
""" Print the startup banner with branding. """
inner = 54
def box_line ( content : str ) - > str :
padding = inner - _visible_len ( content )
return f " { self . _c ( _DARK_GRAY , _V_LINE ) } { content } { ' ' * max ( 0 , padding ) } { self . _c ( _DARK_GRAY , _V_LINE ) } "
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 } " )
bottom = self . _c ( _DARK_GRAY , f " { _BL } { _H_LINE * inner } { _BR } " )
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 " )
name = self . _c ( self . accent + _BOLD , self . display_name )
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 ( f " { icon } { brand } { dot } { name } " ) )
print ( box_line ( f " { self . _c ( _DARK_GRAY , f ' v { self . version } ' ) } " ) )
print ( box_line ( " " ) )
print ( box_line ( f " { self . _c ( _DARK_GRAY , ' Type help for commands, quit to exit ' ) } " ) )
print ( bottom )
print ( _box_line ( title ) )
print ( _box_line ( ver ) )
print ( _box_line ( empty ) )
print ( _box_line ( tip ) )
print ( bot )
print ( )
def prompt ( self , project_name : str = " " , modified : bool = False , context : str = " " ) - > str :
# ── 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 = [ ]
parts . append ( self . _c ( _CYAN , " ◆ " ) if self . _color else " > " )
# 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 :
current = context or project_name
suffix = " * " if modified else " "
ctx = context or project_name
mod = " * " if modified else " "
parts . append ( f " { self . _c ( _DARK_GRAY , ' [ ' ) } " )
parts . append ( self . _c ( _LIGHT_GRAY , f " { current } { suffix } " ) )
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 = " " ) :
tokens = [ ( " class:icon " , " ◆ " ) , ( " class:software " , self . software ) ]
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 :
current = context or project_name
suffix = " * " if modified else " "
tokens . extend (
[
( " class:bracket " , " [ " ) ,
( " class:context " , f " { current } { suffix } " ) ,
( " class:bracket " , " ] " ) ,
]
)
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 = _ANSI_256_TO_HEX . get ( self . accent , " #5fafff " )
return Style . from_dict (
{
" icon " : " #5fd7d7 bold " ,
" software " : f " { accent } bold " ,
" bracket " : " #585858 " ,
" context " : " #bcbcbc " ,
" arrow " : " #808080 " ,
" completion-menu.completion " : " bg:#303030 #bcbcbc " ,
" completion-menu.completion.current " : f " bg: { accent } #000000 " ,
" completion-menu.meta.completion " : " bg:#303030 #808080 " ,
" completion-menu.meta.completion.current " : f " bg: { accent } #000000 " ,
" auto-suggest " : " #585858 " ,
}
)
accent_hex = _ANSI_256_TO_HEX . get ( self . accent , " #5fafff " )
def error ( self , message : str ) - > None :
print ( f " { self . _c ( _RED + _BOLD , ' ✗ ' ) } { self . _c ( _RED , message ) } " , file = sys . stderr )
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 " ,
} )
def warning ( self , message : str ) - > None :
print ( f " { self . _c ( _YELLOW + _BOLD , ' ⚠ ' ) } { self . _c ( _YELLOW , message ) } " )
# ── Messages ──────────────────────────────────────────────────────
def success ( self , message : str ) - > None :
print ( f " { self . _c ( _GREEN + _BOLD , ' ✓ ' ) } { self . _c ( _GREEN , message ) } " )
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 help ( self , commands : dict [ str , str ] ) - > None :
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 , ' Commands ' ) } " )
print ( f " { self . _c ( _DARK_GRAY , _H_LINE * 8 ) } " )
width = max ( ( len ( command ) for command in commands ) , default = 0 )
for command , description in commands . items ( ) :
print ( f " { self . _c ( self . accent , f ' { command : < { width } } ' ) } { self . _c ( _GRAY , description ) } " )
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 ( )
def print_goodbye ( self ) - > None :
print ( f " \n { self . _c ( _CYAN , _ICON_SMALL ) } { self . _c ( _GRAY , ' Goodbye! ' ) } \n " )
# ── 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 . auto_suggest import AutoSuggestFromHistory
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
return PromptSession (
history = FileHistory ( self . history_file ) ,
auto_suggest = AutoSuggestFromHistory ( ) ,
style = self . get_prompt_style ( ) ,
enable_history_search = True ,
)
def get_input ( self , pt_session , project_name : str = " " ,
modified : bool = False , context : str = " " ) - > str :
""" Get input from user using prompt_toolkit or fallback.
def get_input ( self , prompt_session , project_name : str = " " , modified : bool = False , context : str = " " ) - > str :
if prompt_session is not None :
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 ( )
return prompt_session . prompt ( FormattedText ( self . prompt_tokens ( project_name , modified , context ) ) ) . strip ( )
return input ( self . prompt ( project_name , modified , context ) ) . 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;34 m " : " #00af00 " ,
" \033 [38;5;75 m " : " #5fafff " ,
" \033 [38;5;33 m " : " #0087ff " , # audacity navy blue
" \033 [38;5;34 m " : " #00af00 " , # qgis green
" \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
}