fix(cli): make TUI prompt and accent output skin-aware
Salvaged from PR #932 by Wayne onto current main. Apply skin-aware prompt symbols and live prompt_toolkit color refresh, replace lingering hardcoded accent output with active-skin colors, keep ANSI-safe response rendering, preserve secret-capture and approval-prompt state handling, and add integration coverage for prompt state and style refresh behavior.
This commit is contained in:
parent
29312a23d9
commit
41f22de20f
4 changed files with 436 additions and 51 deletions
225
cli.py
225
cli.py
|
|
@ -404,8 +404,10 @@ except Exception:
|
||||||
|
|
||||||
from rich import box as rich_box
|
from rich import box as rich_box
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
from rich.markup import escape as _escape
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
from rich.text import Text as _RichText
|
||||||
|
|
||||||
import fire
|
import fire
|
||||||
|
|
||||||
|
|
@ -696,6 +698,24 @@ _BOLD = "\033[1m"
|
||||||
_DIM = "\033[2m"
|
_DIM = "\033[2m"
|
||||||
_RST = "\033[0m"
|
_RST = "\033[0m"
|
||||||
|
|
||||||
|
def _accent_hex() -> str:
|
||||||
|
"""Return the active skin accent color for legacy CLI output lines."""
|
||||||
|
try:
|
||||||
|
from hermes_cli.skin_engine import get_active_skin
|
||||||
|
return get_active_skin().get_color("ui_accent", "#FFBF00")
|
||||||
|
except Exception:
|
||||||
|
return "#FFBF00"
|
||||||
|
|
||||||
|
|
||||||
|
def _rich_text_from_ansi(text: str) -> _RichText:
|
||||||
|
"""Safely render assistant/tool output that may contain ANSI escapes.
|
||||||
|
|
||||||
|
Using Rich Text.from_ansi preserves literal bracketed text like
|
||||||
|
``[not markup]`` while still interpreting real ANSI color codes.
|
||||||
|
"""
|
||||||
|
return _RichText.from_ansi(text or "")
|
||||||
|
|
||||||
|
|
||||||
def _cprint(text: str):
|
def _cprint(text: str):
|
||||||
"""Print ANSI-colored text through prompt_toolkit's native renderer.
|
"""Print ANSI-colored text through prompt_toolkit's native renderer.
|
||||||
|
|
||||||
|
|
@ -718,7 +738,12 @@ class ChatConsole:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
self._buffer = StringIO()
|
self._buffer = StringIO()
|
||||||
self._inner = Console(file=self._buffer, force_terminal=True, highlight=False)
|
self._inner = Console(
|
||||||
|
file=self._buffer,
|
||||||
|
force_terminal=True,
|
||||||
|
color_system="truecolor",
|
||||||
|
highlight=False,
|
||||||
|
)
|
||||||
|
|
||||||
def print(self, *args, **kwargs):
|
def print(self, *args, **kwargs):
|
||||||
self._buffer.seek(0)
|
self._buffer.seek(0)
|
||||||
|
|
@ -1472,13 +1497,16 @@ class HermesCLI:
|
||||||
title_part = ""
|
title_part = ""
|
||||||
if session_meta.get("title"):
|
if session_meta.get("title"):
|
||||||
title_part = f" \"{session_meta['title']}\""
|
title_part = f" \"{session_meta['title']}\""
|
||||||
_cprint(
|
ChatConsole().print(
|
||||||
f"{_GOLD}↻ Resumed session {_BOLD}{self.session_id}{_RST}{_GOLD}{title_part} "
|
f"[bold {_accent_hex()}]↻ Resumed session[/] "
|
||||||
f"({msg_count} user message{'s' if msg_count != 1 else ''}, "
|
f"[bold]{_escape(self.session_id)}[/]"
|
||||||
f"{len(restored)} total messages){_RST}"
|
f"[bold {_accent_hex()}]{_escape(title_part)}[/] "
|
||||||
|
f"({msg_count} user message{'s' if msg_count != 1 else ''}, {len(restored)} total messages)"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
_cprint(f"{_GOLD}Session {self.session_id} found but has no messages. Starting fresh.{_RST}")
|
ChatConsole().print(
|
||||||
|
f"[bold {_accent_hex()}]Session {_escape(self.session_id)} found but has no messages. Starting fresh.[/]"
|
||||||
|
)
|
||||||
# Re-open the session (clear ended_at so it's active again)
|
# Re-open the session (clear ended_at so it's active again)
|
||||||
try:
|
try:
|
||||||
self._session_db._conn.execute(
|
self._session_db._conn.execute(
|
||||||
|
|
@ -1738,6 +1766,19 @@ class HermesCLI:
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
|
try:
|
||||||
|
from hermes_cli.skin_engine import get_active_skin
|
||||||
|
_skin = get_active_skin()
|
||||||
|
_history_text_c = _skin.get_color("banner_text", "#FFF8DC")
|
||||||
|
_session_label_c = _skin.get_color("session_label", "#DAA520")
|
||||||
|
_session_border_c = _skin.get_color("session_border", "#8B8682")
|
||||||
|
_assistant_label_c = _skin.get_color("ui_ok", "#8FBC8F")
|
||||||
|
except Exception:
|
||||||
|
_history_text_c = "#FFF8DC"
|
||||||
|
_session_label_c = "#DAA520"
|
||||||
|
_session_border_c = "#8B8682"
|
||||||
|
_assistant_label_c = "#8FBC8F"
|
||||||
|
|
||||||
lines = Text()
|
lines = Text()
|
||||||
if skipped:
|
if skipped:
|
||||||
lines.append(
|
lines.append(
|
||||||
|
|
@ -1747,14 +1788,14 @@ class HermesCLI:
|
||||||
|
|
||||||
for i, (role, text) in enumerate(entries):
|
for i, (role, text) in enumerate(entries):
|
||||||
if role == "user":
|
if role == "user":
|
||||||
lines.append(" ● You: ", style="dim bold #DAA520")
|
lines.append(" ● You: ", style=f"dim bold {_session_label_c}")
|
||||||
# Show first line inline, indent rest
|
# Show first line inline, indent rest
|
||||||
msg_lines = text.splitlines()
|
msg_lines = text.splitlines()
|
||||||
lines.append(msg_lines[0] + "\n", style="dim")
|
lines.append(msg_lines[0] + "\n", style="dim")
|
||||||
for ml in msg_lines[1:]:
|
for ml in msg_lines[1:]:
|
||||||
lines.append(f" {ml}\n", style="dim")
|
lines.append(f" {ml}\n", style="dim")
|
||||||
else:
|
else:
|
||||||
lines.append(" ◆ Hermes: ", style="dim bold #8FBC8F")
|
lines.append(" ◆ Hermes: ", style=f"dim bold {_assistant_label_c}")
|
||||||
msg_lines = text.splitlines()
|
msg_lines = text.splitlines()
|
||||||
lines.append(msg_lines[0] + "\n", style="dim")
|
lines.append(msg_lines[0] + "\n", style="dim")
|
||||||
for ml in msg_lines[1:]:
|
for ml in msg_lines[1:]:
|
||||||
|
|
@ -1764,9 +1805,10 @@ class HermesCLI:
|
||||||
|
|
||||||
panel = Panel(
|
panel = Panel(
|
||||||
lines,
|
lines,
|
||||||
title="[dim #DAA520]Previous Conversation[/]",
|
title=f"[dim {_session_label_c}]Previous Conversation[/]",
|
||||||
border_style="dim #8B8682",
|
border_style=f"dim {_session_border_c}",
|
||||||
padding=(0, 1),
|
padding=(0, 1),
|
||||||
|
style=_history_text_c,
|
||||||
)
|
)
|
||||||
self.console.print(panel)
|
self.console.print(panel)
|
||||||
|
|
||||||
|
|
@ -1976,19 +2018,30 @@ class HermesCLI:
|
||||||
"""Display help information with categorized commands."""
|
"""Display help information with categorized commands."""
|
||||||
from hermes_cli.commands import COMMANDS_BY_CATEGORY
|
from hermes_cli.commands import COMMANDS_BY_CATEGORY
|
||||||
|
|
||||||
_cprint(f"\n{_BOLD}+{'-' * 55}+{_RST}")
|
try:
|
||||||
_cprint(f"{_BOLD}|{' ' * 14}(^_^)? Available Commands{' ' * 15}|{_RST}")
|
from hermes_cli.skin_engine import get_active_help_header
|
||||||
_cprint(f"{_BOLD}+{'-' * 55}+{_RST}")
|
header = get_active_help_header("(^_^)? Available Commands")
|
||||||
|
except Exception:
|
||||||
|
header = "(^_^)? Available Commands"
|
||||||
|
header = (header or "").strip() or "(^_^)? Available Commands"
|
||||||
|
inner_width = 55
|
||||||
|
if len(header) > inner_width:
|
||||||
|
header = header[:inner_width]
|
||||||
|
_cprint(f"\n{_BOLD}+{'-' * inner_width}+{_RST}")
|
||||||
|
_cprint(f"{_BOLD}|{header:^{inner_width}}|{_RST}")
|
||||||
|
_cprint(f"{_BOLD}+{'-' * inner_width}+{_RST}")
|
||||||
|
|
||||||
for category, commands in COMMANDS_BY_CATEGORY.items():
|
for category, commands in COMMANDS_BY_CATEGORY.items():
|
||||||
_cprint(f"\n {_BOLD}── {category} ──{_RST}")
|
_cprint(f"\n {_BOLD}── {category} ──{_RST}")
|
||||||
for cmd, desc in commands.items():
|
for cmd, desc in commands.items():
|
||||||
_cprint(f" {_GOLD}{cmd:<15}{_RST} {_DIM}-{_RST} {desc}")
|
ChatConsole().print(f" [bold {_accent_hex()}]{cmd:<15}[/] [dim]-[/] {_escape(desc)}")
|
||||||
|
|
||||||
if _skill_commands:
|
if _skill_commands:
|
||||||
_cprint(f"\n ⚡ {_BOLD}Skill Commands{_RST} ({len(_skill_commands)} installed):")
|
_cprint(f"\n ⚡ {_BOLD}Skill Commands{_RST} ({len(_skill_commands)} installed):")
|
||||||
for cmd, info in sorted(_skill_commands.items()):
|
for cmd, info in sorted(_skill_commands.items()):
|
||||||
_cprint(f" {_GOLD}{cmd:<22}{_RST} {_DIM}-{_RST} {info['description']}")
|
ChatConsole().print(
|
||||||
|
f" [bold {_accent_hex()}]{cmd:<22}[/] [dim]-[/] {_escape(info['description'])}"
|
||||||
|
)
|
||||||
|
|
||||||
_cprint(f"\n {_DIM}Tip: Just type your message to chat with Hermes!{_RST}")
|
_cprint(f"\n {_DIM}Tip: Just type your message to chat with Hermes!{_RST}")
|
||||||
_cprint(f" {_DIM}Multi-line: Alt+Enter for a new line{_RST}")
|
_cprint(f" {_DIM}Multi-line: Alt+Enter for a new line{_RST}")
|
||||||
|
|
@ -2981,8 +3034,7 @@ class HermesCLI:
|
||||||
)
|
)
|
||||||
output = result.stdout.strip() or result.stderr.strip()
|
output = result.stdout.strip() or result.stderr.strip()
|
||||||
if output:
|
if output:
|
||||||
from rich.text import Text as _RichText
|
self.console.print(_rich_text_from_ansi(output))
|
||||||
self.console.print(_RichText.from_ansi(output))
|
|
||||||
else:
|
else:
|
||||||
self.console.print("[dim]Command returned no output[/]")
|
self.console.print("[dim]Command returned no output[/]")
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
|
|
@ -3076,27 +3128,29 @@ class HermesCLI:
|
||||||
|
|
||||||
# Display result in the CLI (thread-safe via patch_stdout)
|
# Display result in the CLI (thread-safe via patch_stdout)
|
||||||
print()
|
print()
|
||||||
_cprint(f"{_GOLD}{'─' * 40}{_RST}")
|
ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]")
|
||||||
_cprint(f" ✅ Background task #{task_num} complete")
|
_cprint(f" ✅ Background task #{task_num} complete")
|
||||||
_cprint(f" Prompt: \"{prompt[:60]}{'...' if len(prompt) > 60 else ''}\"")
|
_cprint(f" Prompt: \"{prompt[:60]}{'...' if len(prompt) > 60 else ''}\"")
|
||||||
_cprint(f"{_GOLD}{'─' * 40}{_RST}")
|
ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]")
|
||||||
if response:
|
if response:
|
||||||
try:
|
try:
|
||||||
from hermes_cli.skin_engine import get_active_skin
|
from hermes_cli.skin_engine import get_active_skin
|
||||||
_skin = get_active_skin()
|
_skin = get_active_skin()
|
||||||
label = _skin.get_branding("response_label", "⚕ Hermes")
|
label = _skin.get_branding("response_label", "⚕ Hermes")
|
||||||
_resp_color = _skin.get_color("response_border", "#CD7F32")
|
_resp_color = _skin.get_color("response_border", "#CD7F32")
|
||||||
|
_resp_text = _skin.get_color("banner_text", "#FFF8DC")
|
||||||
except Exception:
|
except Exception:
|
||||||
label = "⚕ Hermes"
|
label = "⚕ Hermes"
|
||||||
_resp_color = "#CD7F32"
|
_resp_color = "#CD7F32"
|
||||||
|
_resp_text = "#FFF8DC"
|
||||||
|
|
||||||
from rich.text import Text as _RichText
|
|
||||||
_chat_console = ChatConsole()
|
_chat_console = ChatConsole()
|
||||||
_chat_console.print(Panel(
|
_chat_console.print(Panel(
|
||||||
_RichText.from_ansi(response),
|
_rich_text_from_ansi(response),
|
||||||
title=f"[bold]{label} (background #{task_num})[/bold]",
|
title=f"[{_resp_color} bold]{label} (background #{task_num})[/]",
|
||||||
title_align="left",
|
title_align="left",
|
||||||
border_style=_resp_color,
|
border_style=_resp_color,
|
||||||
|
style=_resp_text,
|
||||||
box=rich_box.HORIZONTALS,
|
box=rich_box.HORIZONTALS,
|
||||||
padding=(1, 2),
|
padding=(1, 2),
|
||||||
))
|
))
|
||||||
|
|
@ -3156,6 +3210,8 @@ class HermesCLI:
|
||||||
else:
|
else:
|
||||||
print(f" Skin set to: {new_skin}")
|
print(f" Skin set to: {new_skin}")
|
||||||
print(" Note: banner colors will update on next session start.")
|
print(" Note: banner colors will update on next session start.")
|
||||||
|
if self._apply_tui_skin_style():
|
||||||
|
print(" Prompt + TUI colors updated.")
|
||||||
|
|
||||||
def _toggle_verbose(self):
|
def _toggle_verbose(self):
|
||||||
"""Cycle tool progress mode: off → new → all → verbose → off."""
|
"""Cycle tool progress mode: off → new → all → verbose → off."""
|
||||||
|
|
@ -3689,8 +3745,8 @@ class HermesCLI:
|
||||||
|
|
||||||
# Add user message to history
|
# Add user message to history
|
||||||
self.conversation_history.append({"role": "user", "content": message})
|
self.conversation_history.append({"role": "user", "content": message})
|
||||||
|
|
||||||
_cprint(f"{_GOLD}{'─' * 40}{_RST}")
|
ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]")
|
||||||
print(flush=True)
|
print(flush=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -3803,17 +3859,19 @@ class HermesCLI:
|
||||||
_skin = get_active_skin()
|
_skin = get_active_skin()
|
||||||
label = _skin.get_branding("response_label", "⚕ Hermes")
|
label = _skin.get_branding("response_label", "⚕ Hermes")
|
||||||
_resp_color = _skin.get_color("response_border", "#CD7F32")
|
_resp_color = _skin.get_color("response_border", "#CD7F32")
|
||||||
|
_resp_text = _skin.get_color("banner_text", "#FFF8DC")
|
||||||
except Exception:
|
except Exception:
|
||||||
label = "⚕ Hermes"
|
label = "⚕ Hermes"
|
||||||
_resp_color = "#CD7F32"
|
_resp_color = "#CD7F32"
|
||||||
|
_resp_text = "#FFF8DC"
|
||||||
|
|
||||||
from rich.text import Text as _RichText
|
|
||||||
_chat_console = ChatConsole()
|
_chat_console = ChatConsole()
|
||||||
_chat_console.print(Panel(
|
_chat_console.print(Panel(
|
||||||
_RichText.from_ansi(response),
|
_rich_text_from_ansi(response),
|
||||||
title=f"[bold]{label}[/bold]",
|
title=f"[{_resp_color} bold]{label}[/]",
|
||||||
title_align="left",
|
title_align="left",
|
||||||
border_style=_resp_color,
|
border_style=_resp_color,
|
||||||
|
style=_resp_text,
|
||||||
box=rich_box.HORIZONTALS,
|
box=rich_box.HORIZONTALS,
|
||||||
padding=(1, 2),
|
padding=(1, 2),
|
||||||
))
|
))
|
||||||
|
|
@ -3869,7 +3927,80 @@ class HermesCLI:
|
||||||
print(f"Duration: {duration_str}")
|
print(f"Duration: {duration_str}")
|
||||||
print(f"Messages: {msg_count} ({user_msgs} user, {tool_calls} tool calls)")
|
print(f"Messages: {msg_count} ({user_msgs} user, {tool_calls} tool calls)")
|
||||||
else:
|
else:
|
||||||
print("Goodbye! ⚕")
|
try:
|
||||||
|
from hermes_cli.skin_engine import get_active_goodbye
|
||||||
|
goodbye = get_active_goodbye("Goodbye! ⚕")
|
||||||
|
except Exception:
|
||||||
|
goodbye = "Goodbye! ⚕"
|
||||||
|
print(goodbye)
|
||||||
|
|
||||||
|
def _get_tui_prompt_symbols(self) -> tuple[str, str]:
|
||||||
|
"""Return ``(normal_prompt, state_suffix)`` for the active skin.
|
||||||
|
|
||||||
|
``normal_prompt`` is the full ``branding.prompt_symbol``.
|
||||||
|
``state_suffix`` is what special states (sudo/secret/approval/agent)
|
||||||
|
should render after their leading icon.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from hermes_cli.skin_engine import get_active_prompt_symbol
|
||||||
|
symbol = get_active_prompt_symbol("❯ ")
|
||||||
|
except Exception:
|
||||||
|
symbol = "❯ "
|
||||||
|
|
||||||
|
symbol = (symbol or "❯ ").rstrip() + " "
|
||||||
|
stripped = symbol.rstrip()
|
||||||
|
if not stripped:
|
||||||
|
return "❯ ", "❯ "
|
||||||
|
|
||||||
|
parts = stripped.split()
|
||||||
|
candidate = parts[-1] if parts else ""
|
||||||
|
arrow_chars = ("❯", ">", "$", "#", "›", "»", "→")
|
||||||
|
if any(ch in candidate for ch in arrow_chars):
|
||||||
|
return symbol, candidate.rstrip() + " "
|
||||||
|
|
||||||
|
# Icon-only custom prompts should still remain visible in special states.
|
||||||
|
return symbol, symbol
|
||||||
|
|
||||||
|
def _get_tui_prompt_fragments(self):
|
||||||
|
"""Return the prompt_toolkit fragments for the current interactive state."""
|
||||||
|
symbol, state_suffix = self._get_tui_prompt_symbols()
|
||||||
|
if self._sudo_state:
|
||||||
|
return [("class:sudo-prompt", f"🔐 {state_suffix}")]
|
||||||
|
if self._secret_state:
|
||||||
|
return [("class:sudo-prompt", f"🔑 {state_suffix}")]
|
||||||
|
if self._approval_state:
|
||||||
|
return [("class:prompt-working", f"⚠ {state_suffix}")]
|
||||||
|
if self._clarify_freetext:
|
||||||
|
return [("class:clarify-selected", f"✎ {state_suffix}")]
|
||||||
|
if self._clarify_state:
|
||||||
|
return [("class:prompt-working", f"? {state_suffix}")]
|
||||||
|
if self._command_running:
|
||||||
|
return [("class:prompt-working", f"{self._command_spinner_frame()} {state_suffix}")]
|
||||||
|
if self._agent_running:
|
||||||
|
return [("class:prompt-working", f"⚕ {state_suffix}")]
|
||||||
|
return [("class:prompt", symbol)]
|
||||||
|
|
||||||
|
def _get_tui_prompt_text(self) -> str:
|
||||||
|
"""Return the visible prompt text for width calculations."""
|
||||||
|
return "".join(text for _, text in self._get_tui_prompt_fragments())
|
||||||
|
|
||||||
|
def _build_tui_style_dict(self) -> dict[str, str]:
|
||||||
|
"""Layer the active skin's prompt_toolkit colors over the base TUI style."""
|
||||||
|
style_dict = dict(getattr(self, "_tui_style_base", {}) or {})
|
||||||
|
try:
|
||||||
|
from hermes_cli.skin_engine import get_prompt_toolkit_style_overrides
|
||||||
|
style_dict.update(get_prompt_toolkit_style_overrides())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return style_dict
|
||||||
|
|
||||||
|
def _apply_tui_skin_style(self) -> bool:
|
||||||
|
"""Refresh prompt_toolkit styling for a running interactive TUI."""
|
||||||
|
if not getattr(self, "_app", None) or not getattr(self, "_tui_style_base", None):
|
||||||
|
return False
|
||||||
|
self._app.style = PTStyle.from_dict(self._build_tui_style_dict())
|
||||||
|
self._invalidate(min_interval=0.0)
|
||||||
|
return True
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""Run the interactive CLI loop with persistent input at bottom."""
|
"""Run the interactive CLI loop with persistent input at bottom."""
|
||||||
|
|
@ -4241,21 +4372,7 @@ class HermesCLI:
|
||||||
cli_ref = self
|
cli_ref = self
|
||||||
|
|
||||||
def get_prompt():
|
def get_prompt():
|
||||||
if cli_ref._sudo_state:
|
return cli_ref._get_tui_prompt_fragments()
|
||||||
return [('class:sudo-prompt', '🔐 ❯ ')]
|
|
||||||
if cli_ref._secret_state:
|
|
||||||
return [('class:sudo-prompt', '🔑 ❯ ')]
|
|
||||||
if cli_ref._approval_state:
|
|
||||||
return [('class:prompt-working', '⚠ ❯ ')]
|
|
||||||
if cli_ref._clarify_freetext:
|
|
||||||
return [('class:clarify-selected', '✎ ❯ ')]
|
|
||||||
if cli_ref._clarify_state:
|
|
||||||
return [('class:prompt-working', '? ❯ ')]
|
|
||||||
if cli_ref._command_running:
|
|
||||||
return [('class:prompt-working', f"{cli_ref._command_spinner_frame()} ❯ ")]
|
|
||||||
if cli_ref._agent_running:
|
|
||||||
return [('class:prompt-working', '⚕ ❯ ')]
|
|
||||||
return [('class:prompt', '❯ ')]
|
|
||||||
|
|
||||||
# Create the input area with multiline (shift+enter), autocomplete, and paste handling
|
# Create the input area with multiline (shift+enter), autocomplete, and paste handling
|
||||||
input_area = TextArea(
|
input_area = TextArea(
|
||||||
|
|
@ -4272,11 +4389,11 @@ class HermesCLI:
|
||||||
|
|
||||||
# Dynamic height: accounts for both explicit newlines AND visual
|
# Dynamic height: accounts for both explicit newlines AND visual
|
||||||
# wrapping of long lines so the input area always fits its content.
|
# wrapping of long lines so the input area always fits its content.
|
||||||
# The prompt characters ("❯ " etc.) consume ~4 columns.
|
|
||||||
def _input_height():
|
def _input_height():
|
||||||
try:
|
try:
|
||||||
doc = input_area.buffer.document
|
doc = input_area.buffer.document
|
||||||
available_width = shutil.get_terminal_size().columns - 4 # subtract prompt width
|
prompt_width = max(2, len(self._get_tui_prompt_text()))
|
||||||
|
available_width = shutil.get_terminal_size().columns - prompt_width
|
||||||
if available_width < 10:
|
if available_width < 10:
|
||||||
available_width = 40
|
available_width = 40
|
||||||
visual_lines = 0
|
visual_lines = 0
|
||||||
|
|
@ -4717,7 +4834,7 @@ class HermesCLI:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Style for the application
|
# Style for the application
|
||||||
style = PTStyle.from_dict({
|
self._tui_style_base = {
|
||||||
'input-area': '#FFF8DC',
|
'input-area': '#FFF8DC',
|
||||||
'placeholder': '#555555 italic',
|
'placeholder': '#555555 italic',
|
||||||
'prompt': '#FFF8DC',
|
'prompt': '#FFF8DC',
|
||||||
|
|
@ -4752,7 +4869,8 @@ class HermesCLI:
|
||||||
'approval-cmd': '#AAAAAA italic',
|
'approval-cmd': '#AAAAAA italic',
|
||||||
'approval-choice': '#AAAAAA',
|
'approval-choice': '#AAAAAA',
|
||||||
'approval-selected': '#FFD700 bold',
|
'approval-selected': '#FFD700 bold',
|
||||||
})
|
}
|
||||||
|
style = PTStyle.from_dict(self._build_tui_style_dict())
|
||||||
|
|
||||||
# Create the application
|
# Create the application
|
||||||
app = Application(
|
app = Application(
|
||||||
|
|
@ -4815,20 +4933,25 @@ class HermesCLI:
|
||||||
full_text = paste_path.read_text(encoding="utf-8")
|
full_text = paste_path.read_text(encoding="utf-8")
|
||||||
line_count = full_text.count('\n') + 1
|
line_count = full_text.count('\n') + 1
|
||||||
print()
|
print()
|
||||||
_cprint(f"{_GOLD}●{_RST} {_BOLD}[Pasted text: {line_count} lines]{_RST}")
|
ChatConsole().print(
|
||||||
|
f"[bold {_accent_hex()}]●[/] [bold]{_escape(f'[Pasted text: {line_count} lines]')}[/]"
|
||||||
|
)
|
||||||
user_input = full_text
|
user_input = full_text
|
||||||
else:
|
else:
|
||||||
print()
|
print()
|
||||||
_cprint(f"{_GOLD}●{_RST} {_BOLD}{user_input}{_RST}")
|
ChatConsole().print(f"[bold {_accent_hex()}]●[/] [bold]{_escape(user_input)}[/]")
|
||||||
else:
|
else:
|
||||||
if '\n' in user_input:
|
if '\n' in user_input:
|
||||||
first_line = user_input.split('\n')[0]
|
first_line = user_input.split('\n')[0]
|
||||||
line_count = user_input.count('\n') + 1
|
line_count = user_input.count('\n') + 1
|
||||||
print()
|
print()
|
||||||
_cprint(f"{_GOLD}●{_RST} {_BOLD}{first_line}{_RST} {_DIM}(+{line_count - 1} lines){_RST}")
|
ChatConsole().print(
|
||||||
|
f"[bold {_accent_hex()}]●[/] [bold]{_escape(first_line)}[/] "
|
||||||
|
f"[dim](+{line_count - 1} lines)[/]"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
print()
|
print()
|
||||||
_cprint(f"{_GOLD}●{_RST} {_BOLD}{user_input}{_RST}")
|
ChatConsole().print(f"[bold {_accent_hex()}]●[/] [bold]{_escape(user_input)}[/]")
|
||||||
|
|
||||||
# Show image attachment count
|
# Show image attachment count
|
||||||
if submit_images:
|
if submit_images:
|
||||||
|
|
|
||||||
|
|
@ -628,3 +628,88 @@ def init_skin_from_config(config: dict) -> None:
|
||||||
set_active_skin(skin_name.strip())
|
set_active_skin(skin_name.strip())
|
||||||
else:
|
else:
|
||||||
set_active_skin("default")
|
set_active_skin("default")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Convenience helpers for CLI modules
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_prompt_symbol(fallback: str = "❯ ") -> str:
|
||||||
|
"""Get the interactive prompt symbol from the active skin."""
|
||||||
|
try:
|
||||||
|
return get_active_skin().get_branding("prompt_symbol", fallback)
|
||||||
|
except Exception:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_help_header(fallback: str = "(^_^)? Available Commands") -> str:
|
||||||
|
"""Get the /help header from the active skin."""
|
||||||
|
try:
|
||||||
|
return get_active_skin().get_branding("help_header", fallback)
|
||||||
|
except Exception:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_goodbye(fallback: str = "Goodbye! ⚕") -> str:
|
||||||
|
"""Get the goodbye line from the active skin."""
|
||||||
|
try:
|
||||||
|
return get_active_skin().get_branding("goodbye", fallback)
|
||||||
|
except Exception:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_prompt_toolkit_style_overrides() -> Dict[str, str]:
|
||||||
|
"""Return prompt_toolkit style overrides derived from the active skin.
|
||||||
|
|
||||||
|
These are layered on top of the CLI's base TUI style so /skin can refresh
|
||||||
|
the live prompt_toolkit UI immediately without rebuilding the app.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
skin = get_active_skin()
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
prompt = skin.get_color("prompt", "#FFF8DC")
|
||||||
|
input_rule = skin.get_color("input_rule", "#CD7F32")
|
||||||
|
title = skin.get_color("banner_title", "#FFD700")
|
||||||
|
text = skin.get_color("banner_text", prompt)
|
||||||
|
dim = skin.get_color("banner_dim", "#555555")
|
||||||
|
label = skin.get_color("ui_label", title)
|
||||||
|
warn = skin.get_color("ui_warn", "#FF8C00")
|
||||||
|
error = skin.get_color("ui_error", "#FF6B6B")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"input-area": prompt,
|
||||||
|
"placeholder": f"{dim} italic",
|
||||||
|
"prompt": prompt,
|
||||||
|
"prompt-working": f"{dim} italic",
|
||||||
|
"hint": f"{dim} italic",
|
||||||
|
"input-rule": input_rule,
|
||||||
|
"image-badge": f"{label} bold",
|
||||||
|
"completion-menu": f"bg:#1a1a2e {text}",
|
||||||
|
"completion-menu.completion": f"bg:#1a1a2e {text}",
|
||||||
|
"completion-menu.completion.current": f"bg:#333355 {title}",
|
||||||
|
"completion-menu.meta.completion": f"bg:#1a1a2e {dim}",
|
||||||
|
"completion-menu.meta.completion.current": f"bg:#333355 {label}",
|
||||||
|
"clarify-border": input_rule,
|
||||||
|
"clarify-title": f"{title} bold",
|
||||||
|
"clarify-question": f"{text} bold",
|
||||||
|
"clarify-choice": dim,
|
||||||
|
"clarify-selected": f"{title} bold",
|
||||||
|
"clarify-active-other": f"{title} italic",
|
||||||
|
"clarify-countdown": input_rule,
|
||||||
|
"sudo-prompt": f"{error} bold",
|
||||||
|
"sudo-border": input_rule,
|
||||||
|
"sudo-title": f"{error} bold",
|
||||||
|
"sudo-text": text,
|
||||||
|
"approval-border": input_rule,
|
||||||
|
"approval-title": f"{warn} bold",
|
||||||
|
"approval-desc": f"{text} bold",
|
||||||
|
"approval-cmd": f"{dim} italic",
|
||||||
|
"approval-choice": dim,
|
||||||
|
"approval-selected": f"{title} bold",
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,9 @@ class TestBuiltinSkins:
|
||||||
assert skin.name == "ares"
|
assert skin.name == "ares"
|
||||||
assert skin.tool_prefix == "╎"
|
assert skin.tool_prefix == "╎"
|
||||||
assert skin.get_color("banner_border") == "#9F1C1C"
|
assert skin.get_color("banner_border") == "#9F1C1C"
|
||||||
|
assert skin.get_color("response_border") == "#C7A96B"
|
||||||
|
assert skin.get_color("session_label") == "#C7A96B"
|
||||||
|
assert skin.get_color("session_border") == "#6E584B"
|
||||||
assert skin.get_branding("agent_name") == "Ares Agent"
|
assert skin.get_branding("agent_name") == "Ares Agent"
|
||||||
|
|
||||||
def test_ares_has_spinner_customization(self):
|
def test_ares_has_spinner_customization(self):
|
||||||
|
|
@ -230,3 +233,82 @@ class TestDisplayIntegration:
|
||||||
from agent.display import get_cute_tool_message
|
from agent.display import get_cute_tool_message
|
||||||
msg = get_cute_tool_message("terminal", {"command": "ls"}, 0.5)
|
msg = get_cute_tool_message("terminal", {"command": "ls"}, 0.5)
|
||||||
assert msg.startswith("┊")
|
assert msg.startswith("┊")
|
||||||
|
|
||||||
|
|
||||||
|
class TestCliBrandingHelpers:
|
||||||
|
def test_active_prompt_symbol_default(self):
|
||||||
|
from hermes_cli.skin_engine import get_active_prompt_symbol
|
||||||
|
|
||||||
|
assert get_active_prompt_symbol() == "❯ "
|
||||||
|
|
||||||
|
def test_active_prompt_symbol_ares(self):
|
||||||
|
from hermes_cli.skin_engine import set_active_skin, get_active_prompt_symbol
|
||||||
|
|
||||||
|
set_active_skin("ares")
|
||||||
|
assert get_active_prompt_symbol() == "⚔ ❯ "
|
||||||
|
|
||||||
|
def test_active_help_header_ares(self):
|
||||||
|
from hermes_cli.skin_engine import set_active_skin, get_active_help_header
|
||||||
|
|
||||||
|
set_active_skin("ares")
|
||||||
|
assert get_active_help_header() == "(⚔) Available Commands"
|
||||||
|
|
||||||
|
def test_active_goodbye_ares(self):
|
||||||
|
from hermes_cli.skin_engine import set_active_skin, get_active_goodbye
|
||||||
|
|
||||||
|
set_active_skin("ares")
|
||||||
|
assert get_active_goodbye() == "Farewell, warrior! ⚔"
|
||||||
|
|
||||||
|
def test_prompt_toolkit_style_overrides_cover_tui_classes(self):
|
||||||
|
from hermes_cli.skin_engine import set_active_skin, get_prompt_toolkit_style_overrides
|
||||||
|
|
||||||
|
set_active_skin("ares")
|
||||||
|
overrides = get_prompt_toolkit_style_overrides()
|
||||||
|
required = {
|
||||||
|
"input-area",
|
||||||
|
"placeholder",
|
||||||
|
"prompt",
|
||||||
|
"prompt-working",
|
||||||
|
"hint",
|
||||||
|
"input-rule",
|
||||||
|
"image-badge",
|
||||||
|
"completion-menu",
|
||||||
|
"completion-menu.completion",
|
||||||
|
"completion-menu.completion.current",
|
||||||
|
"completion-menu.meta.completion",
|
||||||
|
"completion-menu.meta.completion.current",
|
||||||
|
"clarify-border",
|
||||||
|
"clarify-title",
|
||||||
|
"clarify-question",
|
||||||
|
"clarify-choice",
|
||||||
|
"clarify-selected",
|
||||||
|
"clarify-active-other",
|
||||||
|
"clarify-countdown",
|
||||||
|
"sudo-prompt",
|
||||||
|
"sudo-border",
|
||||||
|
"sudo-title",
|
||||||
|
"sudo-text",
|
||||||
|
"approval-border",
|
||||||
|
"approval-title",
|
||||||
|
"approval-desc",
|
||||||
|
"approval-cmd",
|
||||||
|
"approval-choice",
|
||||||
|
"approval-selected",
|
||||||
|
}
|
||||||
|
assert required.issubset(overrides.keys())
|
||||||
|
|
||||||
|
def test_prompt_toolkit_style_overrides_use_skin_colors(self):
|
||||||
|
from hermes_cli.skin_engine import (
|
||||||
|
set_active_skin,
|
||||||
|
get_active_skin,
|
||||||
|
get_prompt_toolkit_style_overrides,
|
||||||
|
)
|
||||||
|
|
||||||
|
set_active_skin("ares")
|
||||||
|
skin = get_active_skin()
|
||||||
|
overrides = get_prompt_toolkit_style_overrides()
|
||||||
|
assert overrides["prompt"] == skin.get_color("prompt")
|
||||||
|
assert overrides["input-rule"] == skin.get_color("input_rule")
|
||||||
|
assert overrides["clarify-title"] == f"{skin.get_color('banner_title')} bold"
|
||||||
|
assert overrides["sudo-prompt"] == f"{skin.get_color('ui_error')} bold"
|
||||||
|
assert overrides["approval-title"] == f"{skin.get_color('ui_warn')} bold"
|
||||||
|
|
|
||||||
95
tests/test_cli_skin_integration.py
Normal file
95
tests/test_cli_skin_integration.py
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from cli import HermesCLI, _rich_text_from_ansi
|
||||||
|
from hermes_cli.skin_engine import get_active_skin, set_active_skin
|
||||||
|
|
||||||
|
|
||||||
|
def _make_cli_stub():
|
||||||
|
cli = HermesCLI.__new__(HermesCLI)
|
||||||
|
cli._sudo_state = None
|
||||||
|
cli._secret_state = None
|
||||||
|
cli._approval_state = None
|
||||||
|
cli._clarify_state = None
|
||||||
|
cli._clarify_freetext = False
|
||||||
|
cli._command_running = False
|
||||||
|
cli._agent_running = False
|
||||||
|
cli._command_spinner_frame = lambda: "⟳"
|
||||||
|
cli._tui_style_base = {
|
||||||
|
"prompt": "#fff",
|
||||||
|
"input-area": "#fff",
|
||||||
|
"input-rule": "#aaa",
|
||||||
|
"prompt-working": "#888 italic",
|
||||||
|
}
|
||||||
|
cli._app = SimpleNamespace(style=None)
|
||||||
|
cli._invalidate = MagicMock()
|
||||||
|
return cli
|
||||||
|
|
||||||
|
|
||||||
|
class TestCliSkinPromptIntegration:
|
||||||
|
def test_default_prompt_fragments_use_default_symbol(self):
|
||||||
|
cli = _make_cli_stub()
|
||||||
|
|
||||||
|
set_active_skin("default")
|
||||||
|
assert cli._get_tui_prompt_fragments() == [("class:prompt", "❯ ")]
|
||||||
|
|
||||||
|
def test_ares_prompt_fragments_use_skin_symbol(self):
|
||||||
|
cli = _make_cli_stub()
|
||||||
|
|
||||||
|
set_active_skin("ares")
|
||||||
|
assert cli._get_tui_prompt_fragments() == [("class:prompt", "⚔ ❯ ")]
|
||||||
|
|
||||||
|
def test_secret_prompt_fragments_preserve_secret_state(self):
|
||||||
|
cli = _make_cli_stub()
|
||||||
|
cli._secret_state = {"response_queue": object()}
|
||||||
|
|
||||||
|
set_active_skin("ares")
|
||||||
|
assert cli._get_tui_prompt_fragments() == [("class:sudo-prompt", "🔑 ❯ ")]
|
||||||
|
|
||||||
|
def test_icon_only_skin_symbol_still_visible_in_special_states(self):
|
||||||
|
cli = _make_cli_stub()
|
||||||
|
cli._secret_state = {"response_queue": object()}
|
||||||
|
|
||||||
|
with patch("hermes_cli.skin_engine.get_active_prompt_symbol", return_value="⚔ "):
|
||||||
|
assert cli._get_tui_prompt_fragments() == [("class:sudo-prompt", "🔑 ⚔ ")]
|
||||||
|
|
||||||
|
def test_build_tui_style_dict_uses_skin_overrides(self):
|
||||||
|
cli = _make_cli_stub()
|
||||||
|
|
||||||
|
set_active_skin("ares")
|
||||||
|
skin = get_active_skin()
|
||||||
|
style_dict = cli._build_tui_style_dict()
|
||||||
|
|
||||||
|
assert style_dict["prompt"] == skin.get_color("prompt")
|
||||||
|
assert style_dict["input-rule"] == skin.get_color("input_rule")
|
||||||
|
assert style_dict["prompt-working"] == f"{skin.get_color('banner_dim')} italic"
|
||||||
|
assert style_dict["approval-title"] == f"{skin.get_color('ui_warn')} bold"
|
||||||
|
|
||||||
|
def test_apply_tui_skin_style_updates_running_app(self):
|
||||||
|
cli = _make_cli_stub()
|
||||||
|
|
||||||
|
set_active_skin("ares")
|
||||||
|
assert cli._apply_tui_skin_style() is True
|
||||||
|
assert cli._app.style is not None
|
||||||
|
cli._invalidate.assert_called_once_with(min_interval=0.0)
|
||||||
|
|
||||||
|
def test_handle_skin_command_refreshes_live_tui(self, capsys):
|
||||||
|
cli = _make_cli_stub()
|
||||||
|
|
||||||
|
with patch("cli.save_config_value", return_value=True):
|
||||||
|
cli._handle_skin_command("/skin ares")
|
||||||
|
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert "Skin set to: ares (saved)" in output
|
||||||
|
assert "Prompt + TUI colors updated." in output
|
||||||
|
assert cli._app.style is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnsiRichTextHelper:
|
||||||
|
def test_preserves_literal_brackets(self):
|
||||||
|
text = _rich_text_from_ansi("[notatag] literal")
|
||||||
|
assert text.plain == "[notatag] literal"
|
||||||
|
|
||||||
|
def test_strips_ansi_but_keeps_plain_text(self):
|
||||||
|
text = _rich_text_from_ansi("\x1b[31mred\x1b[0m")
|
||||||
|
assert text.plain == "red"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue