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.console import Console
|
||||
from rich.markup import escape as _escape
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich.text import Text as _RichText
|
||||
|
||||
import fire
|
||||
|
||||
|
|
@ -696,6 +698,24 @@ _BOLD = "\033[1m"
|
|||
_DIM = "\033[2m"
|
||||
_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):
|
||||
"""Print ANSI-colored text through prompt_toolkit's native renderer.
|
||||
|
||||
|
|
@ -718,7 +738,12 @@ class ChatConsole:
|
|||
def __init__(self):
|
||||
from io import 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):
|
||||
self._buffer.seek(0)
|
||||
|
|
@ -1472,13 +1497,16 @@ class HermesCLI:
|
|||
title_part = ""
|
||||
if session_meta.get("title"):
|
||||
title_part = f" \"{session_meta['title']}\""
|
||||
_cprint(
|
||||
f"{_GOLD}↻ Resumed session {_BOLD}{self.session_id}{_RST}{_GOLD}{title_part} "
|
||||
f"({msg_count} user message{'s' if msg_count != 1 else ''}, "
|
||||
f"{len(restored)} total messages){_RST}"
|
||||
ChatConsole().print(
|
||||
f"[bold {_accent_hex()}]↻ Resumed session[/] "
|
||||
f"[bold]{_escape(self.session_id)}[/]"
|
||||
f"[bold {_accent_hex()}]{_escape(title_part)}[/] "
|
||||
f"({msg_count} user message{'s' if msg_count != 1 else ''}, {len(restored)} total messages)"
|
||||
)
|
||||
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)
|
||||
try:
|
||||
self._session_db._conn.execute(
|
||||
|
|
@ -1738,6 +1766,19 @@ class HermesCLI:
|
|||
from rich.panel import Panel
|
||||
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()
|
||||
if skipped:
|
||||
lines.append(
|
||||
|
|
@ -1747,14 +1788,14 @@ class HermesCLI:
|
|||
|
||||
for i, (role, text) in enumerate(entries):
|
||||
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
|
||||
msg_lines = text.splitlines()
|
||||
lines.append(msg_lines[0] + "\n", style="dim")
|
||||
for ml in msg_lines[1:]:
|
||||
lines.append(f" {ml}\n", style="dim")
|
||||
else:
|
||||
lines.append(" ◆ Hermes: ", style="dim bold #8FBC8F")
|
||||
lines.append(" ◆ Hermes: ", style=f"dim bold {_assistant_label_c}")
|
||||
msg_lines = text.splitlines()
|
||||
lines.append(msg_lines[0] + "\n", style="dim")
|
||||
for ml in msg_lines[1:]:
|
||||
|
|
@ -1764,9 +1805,10 @@ class HermesCLI:
|
|||
|
||||
panel = Panel(
|
||||
lines,
|
||||
title="[dim #DAA520]Previous Conversation[/]",
|
||||
border_style="dim #8B8682",
|
||||
title=f"[dim {_session_label_c}]Previous Conversation[/]",
|
||||
border_style=f"dim {_session_border_c}",
|
||||
padding=(0, 1),
|
||||
style=_history_text_c,
|
||||
)
|
||||
self.console.print(panel)
|
||||
|
||||
|
|
@ -1976,19 +2018,30 @@ class HermesCLI:
|
|||
"""Display help information with categorized commands."""
|
||||
from hermes_cli.commands import COMMANDS_BY_CATEGORY
|
||||
|
||||
_cprint(f"\n{_BOLD}+{'-' * 55}+{_RST}")
|
||||
_cprint(f"{_BOLD}|{' ' * 14}(^_^)? Available Commands{' ' * 15}|{_RST}")
|
||||
_cprint(f"{_BOLD}+{'-' * 55}+{_RST}")
|
||||
try:
|
||||
from hermes_cli.skin_engine import get_active_help_header
|
||||
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():
|
||||
_cprint(f"\n {_BOLD}── {category} ──{_RST}")
|
||||
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:
|
||||
_cprint(f"\n ⚡ {_BOLD}Skill Commands{_RST} ({len(_skill_commands)} installed):")
|
||||
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" {_DIM}Multi-line: Alt+Enter for a new line{_RST}")
|
||||
|
|
@ -2981,8 +3034,7 @@ class HermesCLI:
|
|||
)
|
||||
output = result.stdout.strip() or result.stderr.strip()
|
||||
if output:
|
||||
from rich.text import Text as _RichText
|
||||
self.console.print(_RichText.from_ansi(output))
|
||||
self.console.print(_rich_text_from_ansi(output))
|
||||
else:
|
||||
self.console.print("[dim]Command returned no output[/]")
|
||||
except subprocess.TimeoutExpired:
|
||||
|
|
@ -3076,27 +3128,29 @@ class HermesCLI:
|
|||
|
||||
# Display result in the CLI (thread-safe via patch_stdout)
|
||||
print()
|
||||
_cprint(f"{_GOLD}{'─' * 40}{_RST}")
|
||||
ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]")
|
||||
_cprint(f" ✅ Background task #{task_num} complete")
|
||||
_cprint(f" Prompt: \"{prompt[:60]}{'...' if len(prompt) > 60 else ''}\"")
|
||||
_cprint(f"{_GOLD}{'─' * 40}{_RST}")
|
||||
ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]")
|
||||
if response:
|
||||
try:
|
||||
from hermes_cli.skin_engine import get_active_skin
|
||||
_skin = get_active_skin()
|
||||
label = _skin.get_branding("response_label", "⚕ Hermes")
|
||||
_resp_color = _skin.get_color("response_border", "#CD7F32")
|
||||
_resp_text = _skin.get_color("banner_text", "#FFF8DC")
|
||||
except Exception:
|
||||
label = "⚕ Hermes"
|
||||
_resp_color = "#CD7F32"
|
||||
_resp_text = "#FFF8DC"
|
||||
|
||||
from rich.text import Text as _RichText
|
||||
_chat_console = ChatConsole()
|
||||
_chat_console.print(Panel(
|
||||
_RichText.from_ansi(response),
|
||||
title=f"[bold]{label} (background #{task_num})[/bold]",
|
||||
_rich_text_from_ansi(response),
|
||||
title=f"[{_resp_color} bold]{label} (background #{task_num})[/]",
|
||||
title_align="left",
|
||||
border_style=_resp_color,
|
||||
style=_resp_text,
|
||||
box=rich_box.HORIZONTALS,
|
||||
padding=(1, 2),
|
||||
))
|
||||
|
|
@ -3156,6 +3210,8 @@ class HermesCLI:
|
|||
else:
|
||||
print(f" Skin set to: {new_skin}")
|
||||
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):
|
||||
"""Cycle tool progress mode: off → new → all → verbose → off."""
|
||||
|
|
@ -3689,8 +3745,8 @@ class HermesCLI:
|
|||
|
||||
# Add user message to history
|
||||
self.conversation_history.append({"role": "user", "content": message})
|
||||
|
||||
_cprint(f"{_GOLD}{'─' * 40}{_RST}")
|
||||
|
||||
ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]")
|
||||
print(flush=True)
|
||||
|
||||
try:
|
||||
|
|
@ -3803,17 +3859,19 @@ class HermesCLI:
|
|||
_skin = get_active_skin()
|
||||
label = _skin.get_branding("response_label", "⚕ Hermes")
|
||||
_resp_color = _skin.get_color("response_border", "#CD7F32")
|
||||
_resp_text = _skin.get_color("banner_text", "#FFF8DC")
|
||||
except Exception:
|
||||
label = "⚕ Hermes"
|
||||
_resp_color = "#CD7F32"
|
||||
_resp_text = "#FFF8DC"
|
||||
|
||||
from rich.text import Text as _RichText
|
||||
_chat_console = ChatConsole()
|
||||
_chat_console.print(Panel(
|
||||
_RichText.from_ansi(response),
|
||||
title=f"[bold]{label}[/bold]",
|
||||
_rich_text_from_ansi(response),
|
||||
title=f"[{_resp_color} bold]{label}[/]",
|
||||
title_align="left",
|
||||
border_style=_resp_color,
|
||||
style=_resp_text,
|
||||
box=rich_box.HORIZONTALS,
|
||||
padding=(1, 2),
|
||||
))
|
||||
|
|
@ -3869,7 +3927,80 @@ class HermesCLI:
|
|||
print(f"Duration: {duration_str}")
|
||||
print(f"Messages: {msg_count} ({user_msgs} user, {tool_calls} tool calls)")
|
||||
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):
|
||||
"""Run the interactive CLI loop with persistent input at bottom."""
|
||||
|
|
@ -4241,21 +4372,7 @@ class HermesCLI:
|
|||
cli_ref = self
|
||||
|
||||
def get_prompt():
|
||||
if cli_ref._sudo_state:
|
||||
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', '❯ ')]
|
||||
return cli_ref._get_tui_prompt_fragments()
|
||||
|
||||
# Create the input area with multiline (shift+enter), autocomplete, and paste handling
|
||||
input_area = TextArea(
|
||||
|
|
@ -4272,11 +4389,11 @@ class HermesCLI:
|
|||
|
||||
# Dynamic height: accounts for both explicit newlines AND visual
|
||||
# wrapping of long lines so the input area always fits its content.
|
||||
# The prompt characters ("❯ " etc.) consume ~4 columns.
|
||||
def _input_height():
|
||||
try:
|
||||
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:
|
||||
available_width = 40
|
||||
visual_lines = 0
|
||||
|
|
@ -4717,7 +4834,7 @@ class HermesCLI:
|
|||
)
|
||||
|
||||
# Style for the application
|
||||
style = PTStyle.from_dict({
|
||||
self._tui_style_base = {
|
||||
'input-area': '#FFF8DC',
|
||||
'placeholder': '#555555 italic',
|
||||
'prompt': '#FFF8DC',
|
||||
|
|
@ -4752,7 +4869,8 @@ class HermesCLI:
|
|||
'approval-cmd': '#AAAAAA italic',
|
||||
'approval-choice': '#AAAAAA',
|
||||
'approval-selected': '#FFD700 bold',
|
||||
})
|
||||
}
|
||||
style = PTStyle.from_dict(self._build_tui_style_dict())
|
||||
|
||||
# Create the application
|
||||
app = Application(
|
||||
|
|
@ -4815,20 +4933,25 @@ class HermesCLI:
|
|||
full_text = paste_path.read_text(encoding="utf-8")
|
||||
line_count = full_text.count('\n') + 1
|
||||
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
|
||||
else:
|
||||
print()
|
||||
_cprint(f"{_GOLD}●{_RST} {_BOLD}{user_input}{_RST}")
|
||||
ChatConsole().print(f"[bold {_accent_hex()}]●[/] [bold]{_escape(user_input)}[/]")
|
||||
else:
|
||||
if '\n' in user_input:
|
||||
first_line = user_input.split('\n')[0]
|
||||
line_count = user_input.count('\n') + 1
|
||||
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:
|
||||
print()
|
||||
_cprint(f"{_GOLD}●{_RST} {_BOLD}{user_input}{_RST}")
|
||||
ChatConsole().print(f"[bold {_accent_hex()}]●[/] [bold]{_escape(user_input)}[/]")
|
||||
|
||||
# Show image attachment count
|
||||
if submit_images:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue