refactor(cli): split _chat_render_turn and _process_unregistered_slash into per-branch helpers

This commit is contained in:
Teknium
2026-09-02 18:59:19 -07:00
parent 458b56a47c
commit f0d24b3c2e
+288 -306
View File
@@ -6283,182 +6283,151 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin, CLITuiMix
expansion -> unknown-command message. Always returns True unless
it re-dispatches through process_command.
"""
# Check for user-defined quick commands (bypass agent loop, no LLM call)
base_cmd = cmd_lower.split()[0]
skill_commands = _ensure_skill_commands()
skill_bundles = get_skill_bundles()
quick_commands = self.config.get("quick_commands", {})
user_args = cmd_original[len(base_cmd):].strip()
if base_cmd.lstrip("/") in quick_commands:
qcmd = quick_commands[base_cmd.lstrip("/")]
if qcmd.get("type") == "exec":
import subprocess
exec_cmd = qcmd.get("command", "")
if exec_cmd:
try:
# shell=True is intentional: quick_commands are user-defined
# shell snippets from config.yaml — not agent/LLM controlled.
# Sanitize env to prevent credential leakage —
# quick commands run in the CLI process which
# has all API keys in os.environ.
from tools.environments.local import build_subprocess_env
sanitized_env = build_subprocess_env()
from hermes_cli._subprocess_compat import windows_hide_flags
result = subprocess.run(
exec_cmd, shell=True, capture_output=True,
text=True, encoding="utf-8", errors="replace", timeout=30, env=sanitized_env,
# No console flash on Windows (#56747).
creationflags=windows_hide_flags(),
)
output = result.stdout.strip() or result.stderr.strip()
if output:
from agent.redact import redact_sensitive_text
output = redact_sensitive_text(output)
self._console_print(_rich_text_from_ansi(output))
else:
self._console_print("[dim]Command returned no output[/]")
except subprocess.TimeoutExpired:
self._console_print("[bold red]Quick command timed out (30s)[/]")
except Exception as e:
self._console_print(f"[bold red]Quick command error: {e}[/]")
else:
self._console_print(f"[bold red]Quick command '{base_cmd}' has no command defined[/]")
elif qcmd.get("type") == "alias":
target = qcmd.get("target", "").strip()
if target:
target = target if target.startswith("/") else f"/{target}"
user_args = cmd_original[len(base_cmd):].strip()
aliased_command = f"{target} {user_args}".strip()
return self.process_command(aliased_command)
else:
self._console_print(f"[bold red]Quick command '{base_cmd}' has no target defined[/]")
else:
self._console_print(f"[bold red]Quick command '{base_cmd}' has unsupported type (supported: 'exec', 'alias')[/]")
# Check for plugin-registered slash commands
elif base_cmd.lstrip("/") in _get_plugin_cmd_handler_names():
from hermes_cli.plugins import (
get_plugin_command_handler,
resolve_plugin_command_result,
)
plugin_handler = get_plugin_command_handler(base_cmd.lstrip("/"))
if plugin_handler:
user_args = cmd_original[len(base_cmd):].strip()
try:
result = resolve_plugin_command_result(
plugin_handler(user_args)
)
if result:
_cprint(str(result))
except Exception as e:
_cprint(f"\033[1;31mPlugin command error: {e}{_RST}")
# Skill bundles take precedence over individual skills — /<bundle>
# loads multiple skills at once. Rescans cheaply when files change.
return self._run_quick_command(base_cmd, quick_commands[base_cmd.lstrip("/")], user_args)
if base_cmd.lstrip("/") in _get_plugin_cmd_handler_names():
self._run_plugin_slash_command(base_cmd, user_args)
elif base_cmd in skill_bundles:
user_instruction = cmd_original[len(base_cmd):].strip()
bundle_result = build_bundle_invocation_message(
base_cmd, user_instruction, task_id=self.session_id
)
if bundle_result:
msg, loaded_names, missing = bundle_result
bundle_info = skill_bundles[base_cmd]
print(
f"\n⚡ Loading bundle: {bundle_info['name']} "
f"({len(loaded_names)} skills)"
)
if missing:
ChatConsole().print(
f"[yellow]Skipped missing skills: {', '.join(missing)}[/]"
)
if hasattr(self, '_pending_input'):
self._pending_input.put(msg)
else:
ChatConsole().print(
f"[bold red]Failed to load bundle for {base_cmd}[/]"
)
# Check for skill slash commands (/gif-search, /axolotl, etc.)
self._run_skill_bundle_command(base_cmd, skill_bundles[base_cmd], user_args)
elif base_cmd in skill_commands:
rest = cmd_original[len(base_cmd):].strip()
# Stacked slash-skill invocations: `/skill-a /skill-b do XYZ`
# loads every leading skill (up to 5), not just the first.
# Inspired by Claude Code v2.1.199.
from agent.skill_commands import (
build_stacked_skill_invocation_message,
split_stacked_skill_commands,
)
extra_keys, user_instruction = split_stacked_skill_commands(rest)
if extra_keys:
stacked_result = build_stacked_skill_invocation_message(
[base_cmd, *extra_keys],
user_instruction,
task_id=self.session_id,
)
if stacked_result:
msg, loaded_names, missing = stacked_result
print(
f"\n⚡ Loading {len(loaded_names)} stacked skills: "
f"{', '.join(loaded_names)}"
)
if missing:
ChatConsole().print(
f"[yellow]Skipped missing skills: {', '.join(missing)}[/]"
)
if hasattr(self, '_pending_input'):
self._pending_input.put(msg)
else:
ChatConsole().print(
f"[bold red]Failed to load stacked skills for {base_cmd}[/]"
)
return True
user_instruction = rest
msg = build_skill_invocation_message(
base_cmd, user_instruction, task_id=self.session_id
)
if msg:
skill_name = skill_commands[base_cmd]["name"]
print(f"\n⚡ Loading skill: {skill_name}")
if hasattr(self, '_pending_input'):
self._pending_input.put(msg)
else:
ChatConsole().print(f"[bold red]Failed to load skill for {base_cmd}[/]")
self._run_skill_slash_command(base_cmd, skill_commands[base_cmd], user_args)
else:
# Prefix matching: if input uniquely identifies one command, execute it.
# Matches against both built-in COMMANDS and installed skill commands so
# that execution-time resolution agrees with tab-completion.
from hermes_cli.commands import COMMANDS
typed_base = cmd_lower.split()[0]
all_known = set(COMMANDS) | set(skill_commands) | set(skill_bundles)
matches = [c for c in all_known if c.startswith(typed_base)]
if len(matches) > 1:
# Prefer an exact match (typed the full command name)
exact = [c for c in matches if c == typed_base]
if len(exact) == 1:
matches = exact
return self._expand_slash_prefix(cmd_original, cmd_lower, skill_commands, skill_bundles)
return True
def _run_quick_command(self, base_cmd: str, qcmd: dict, user_args: str) -> bool:
"""User-defined quick command (config.yaml): ``exec`` runs a shell snippet, ``alias`` re-dispatches."""
qtype = qcmd.get("type")
if qtype == "exec":
import subprocess
exec_cmd = qcmd.get("command", "")
if not exec_cmd:
self._console_print(f"[bold red]Quick command '{base_cmd}' has no command defined[/]")
return True
try:
# shell=True is intentional: user-defined shell snippets from config.yaml,
# never agent/LLM controlled. The env is sanitized because the CLI process
# holds every API key in os.environ.
from tools.environments.local import build_subprocess_env
from hermes_cli._subprocess_compat import windows_hide_flags
result = subprocess.run(
exec_cmd, shell=True, capture_output=True,
text=True, encoding="utf-8", errors="replace", timeout=30, env=build_subprocess_env(),
# No console flash on Windows (#56747).
creationflags=windows_hide_flags(),
)
output = result.stdout.strip() or result.stderr.strip()
if output:
from agent.redact import redact_sensitive_text
self._console_print(_rich_text_from_ansi(redact_sensitive_text(output)))
else:
# Prefer the unique shortest match:
# /qui → /quit (5) wins over /quint-pipeline (15)
min_len = min(len(c) for c in matches)
shortest = [c for c in matches if len(c) == min_len]
if len(shortest) == 1:
matches = shortest
if len(matches) == 1:
# Expand the prefix to the full command name, preserving arguments.
# Guard against redispatching the same token to avoid infinite
# recursion when the expanded name still doesn't hit an exact branch
# (e.g. /config with extra args that are not yet handled above).
full_name = matches[0]
if full_name == typed_base:
# Already an exact token — no expansion possible; fall through
_cprint(f"\033[1;31mUnknown command: {cmd_lower}{_RST}")
_cprint(f"{_DIM}{_ACCENT}Type /help for available commands{_RST}")
else:
remainder = cmd_original.strip()[len(typed_base):]
full_cmd = full_name + remainder
return self.process_command(full_cmd)
elif len(matches) > 1:
_cprint(f"{_ACCENT}Ambiguous command: {cmd_lower}{_RST}")
_cprint(f"{_DIM}Did you mean: {', '.join(sorted(matches))}?{_RST}")
self._console_print("[dim]Command returned no output[/]")
except subprocess.TimeoutExpired:
self._console_print("[bold red]Quick command timed out (30s)[/]")
except Exception as e:
self._console_print(f"[bold red]Quick command error: {e}[/]")
elif qtype == "alias":
target = qcmd.get("target", "").strip()
if target:
target = target if target.startswith("/") else f"/{target}"
return self.process_command(f"{target} {user_args}".strip())
self._console_print(f"[bold red]Quick command '{base_cmd}' has no target defined[/]")
else:
self._console_print(f"[bold red]Quick command '{base_cmd}' has unsupported type (supported: 'exec', 'alias')[/]")
return True
def _run_plugin_slash_command(self, base_cmd: str, user_args: str) -> None:
from hermes_cli.plugins import (
get_plugin_command_handler,
resolve_plugin_command_result,
)
plugin_handler = get_plugin_command_handler(base_cmd.lstrip("/"))
if plugin_handler:
try:
result = resolve_plugin_command_result(plugin_handler(user_args))
if result:
_cprint(str(result))
except Exception as e:
_cprint(f"\033[1;31mPlugin command error: {e}{_RST}")
def _queue_skill_message(self, msg) -> None:
if hasattr(self, '_pending_input'):
self._pending_input.put(msg)
def _run_skill_bundle_command(self, base_cmd: str, bundle_info: dict, user_instruction: str) -> None:
"""``/<bundle>`` loads several skills at once (bundles win over same-named skills)."""
bundle_result = build_bundle_invocation_message(
base_cmd, user_instruction, task_id=self.session_id
)
if not bundle_result:
ChatConsole().print(f"[bold red]Failed to load bundle for {base_cmd}[/]")
return
msg, loaded_names, missing = bundle_result
print(f"\n⚡ Loading bundle: {bundle_info['name']} ({len(loaded_names)} skills)")
if missing:
ChatConsole().print(f"[yellow]Skipped missing skills: {', '.join(missing)}[/]")
self._queue_skill_message(msg)
def _run_skill_slash_command(self, base_cmd: str, skill_info: dict, rest: str) -> None:
"""``/<skill> ...``; stacked ``/skill-a /skill-b do XYZ`` loads every leading skill (up to 5)."""
from agent.skill_commands import (
build_stacked_skill_invocation_message,
split_stacked_skill_commands,
)
extra_keys, user_instruction = split_stacked_skill_commands(rest)
if extra_keys:
stacked_result = build_stacked_skill_invocation_message(
[base_cmd, *extra_keys],
user_instruction,
task_id=self.session_id,
)
if not stacked_result:
ChatConsole().print(f"[bold red]Failed to load stacked skills for {base_cmd}[/]")
return
msg, loaded_names, missing = stacked_result
print(f"\n⚡ Loading {len(loaded_names)} stacked skills: {', '.join(loaded_names)}")
if missing:
ChatConsole().print(f"[yellow]Skipped missing skills: {', '.join(missing)}[/]")
self._queue_skill_message(msg)
return
msg = build_skill_invocation_message(base_cmd, rest, task_id=self.session_id)
if msg:
print(f"\n⚡ Loading skill: {skill_info['name']}")
self._queue_skill_message(msg)
else:
ChatConsole().print(f"[bold red]Failed to load skill for {base_cmd}[/]")
def _expand_slash_prefix(self, cmd_original: str, cmd_lower: str, skill_commands, skill_bundles) -> bool:
"""Unique-prefix expansion against built-in COMMANDS + skill commands/bundles (agrees with tab-completion)."""
from hermes_cli.commands import COMMANDS
typed_base = cmd_lower.split()[0]
all_known = set(COMMANDS) | set(skill_commands) | set(skill_bundles)
matches = [c for c in all_known if c.startswith(typed_base)]
if len(matches) > 1:
exact = [c for c in matches if c == typed_base]
if len(exact) == 1:
matches = exact
else:
_cprint(f"\033[1;31mUnknown command: {cmd_lower}{_RST}")
_cprint(f"{_DIM}{_ACCENT}Type /help for available commands{_RST}")
# Unique shortest match wins: /qui → /quit (5) over /quint-pipeline (15)
min_len = min(len(c) for c in matches)
shortest = [c for c in matches if len(c) == min_len]
if len(shortest) == 1:
matches = shortest
if len(matches) == 1 and matches[0] != typed_base:
# Expand to the full name, preserving arguments.
return self.process_command(matches[0] + cmd_original.strip()[len(typed_base):])
if len(matches) > 1:
_cprint(f"{_ACCENT}Ambiguous command: {cmd_lower}{_RST}")
_cprint(f"{_DIM}Did you mean: {', '.join(sorted(matches))}?{_RST}")
else:
# Exact token with no handler (never re-dispatch the same token: recursion), or no match.
_cprint(f"\033[1;31mUnknown command: {cmd_lower}{_RST}")
_cprint(f"{_DIM}{_ACCENT}Type /help for available commands{_RST}")
return True
def _owns_process_notification(self, event: dict) -> bool:
@@ -7158,150 +7127,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin, CLITuiMix
self._voice_continuous = False
_cprint(f"\n{_DIM}Continuous voice mode stopped due to error.{_RST}")
# Handle interrupt - check if we were interrupted
pending_message = None
_show_interrupt_marker = False
_interrupted_this_turn = bool(turn.result and turn.result.get("interrupted"))
# Expose the flag for post-turn hooks (e.g. goal continuation)
# so they can skip themselves when the turn was user-cancelled.
self._last_turn_interrupted = _interrupted_this_turn
if _interrupted_this_turn:
pending_message = turn.result.get("interrupt_message") or interrupt_msg
# #60920: Don't append the interruption marker to response so it
# is never recorded in _OUTPUT_HISTORY by the Panel rendering
# below. The marker is printed separately with _suspend_output_history
# after the response Panel to preserve the visual while avoiding
# duplicates on terminal redraw (_recover_terminal_after_interrupt).
_show_interrupt_marker = bool(response and pending_message)
elif interrupt_msg:
# We fired agent.interrupt(interrupt_msg) but the turn result
# doesn't acknowledge it. Two ways this happens, both racy:
# 1. The agent thread had already passed its last interrupt
# check (or finished) when the interrupt landed — the turn
# completed normally and finalize_turn() never saw the flag.
# 2. The 10s post-interrupt wait above expired and we
# abandoned the daemon thread; `result` is still None.
# In both cases the user's message must NOT be dropped —
# re-queue it as the next turn (#interrupt-vacuumed-into-void).
pending_message = interrupt_msg
# If the interrupt landed after finalize_turn()'s
# clear_interrupt(), the stale flag would instantly abort the
# NEXT turn at its first loop check. Clear it now that we've
# claimed the message — but ONLY if the agent thread actually
# exited. If it's still alive (abandoned after the 10s wait),
# the flag is what makes the wedged tool eventually unwind;
# clearing it would un-signal that thread.
try:
if (
not agent_thread.is_alive()
and self.agent
and getattr(self.agent, "_interrupt_requested", False)
):
self.agent.clear_interrupt()
except Exception:
pass
pending_message, _show_interrupt_marker = self._chat_resolve_interrupt(turn, agent_thread, interrupt_msg, response)
response_previewed = turn.result.get("response_previewed", False) if turn.result else False
# Display reasoning (thinking) box if enabled and available.
# Skip when streaming already showed reasoning live. Use the
# turn-persistent flag (_reasoning_shown_this_turn) instead of
# _reasoning_stream_started — the latter gets reset during
# intermediate turn boundaries (tool-calling loops), which caused
# the reasoning box to re-render after the final response.
_reasoning_already_shown = getattr(self, '_reasoning_shown_this_turn', False)
if self.show_reasoning and turn.result and not _reasoning_already_shown:
reasoning = turn.result.get("last_reasoning")
if reasoning:
w = self._scrollback_box_width()
r_label = " Reasoning "
r_fill = w - 2 - len(r_label)
r_top = f"{_DIM}┌─{r_label}{'─' * max(r_fill - 1, 0)}┐{_RST}"
r_bot = f"{_DIM}└{'─' * (w - 2)}┘{_RST}"
# Collapse long reasoning to the first 10 lines unless the
# user opted into full display via /reasoning full.
lines = reasoning.strip().splitlines()
if len(lines) > 10 and not getattr(self, "reasoning_full", False):
display_reasoning = "\n".join(lines[:10])
display_reasoning += f"\n{_DIM} ... ({len(lines) - 10} more lines — /reasoning full to show){_RST}"
else:
display_reasoning = reasoning.strip()
_cprint(f"\n{r_top}\n{_DIM}{display_reasoning}{_RST}\n{r_bot}")
self._chat_print_reasoning_box(turn)
if response and not response_previewed:
# Use skin engine for label/color with fallback
try:
from hermes_cli.skin_engine import get_active_skin
_skin = get_active_skin()
label = _skin.get_branding("response_label", "⚕ Hermes")
_resp_color = _maybe_remap_for_light_mode(_skin.get_color("response_border", "#CD7F32"))
_resp_text = _maybe_remap_for_light_mode(_skin.get_color("banner_text", "#FFF8DC"))
except Exception:
label = "⚕ Hermes"
_resp_color = _maybe_remap_for_light_mode("#CD7F32")
_resp_text = _maybe_remap_for_light_mode("#FFF8DC")
is_error_response = turn.result and (turn.result.get("failed") or turn.result.get("partial"))
already_streamed = self._stream_started and self._stream_box_opened and not is_error_response
if turn.use_streaming_tts and turn.box_opened and not is_error_response:
# Text was already printed sentence-by-sentence; just close the box
w = self._scrollback_box_width()
_cprint(f"\n{_ACCENT}╰{'─' * (w - 2)}╯{_RST}")
elif already_streamed:
# Response was already streamed token-by-token with box framing;
# _flush_stream() already closed the box. Skip Rich Panel.
# A transform hook runs after streaming. Show a suffix for
# append-only changes, or the complete replacement otherwise.
_post_stream_text = _post_stream_transform_output(response, turn.result)
if _post_stream_text.strip():
_cprint(_post_stream_text)
else:
_chat_console = ChatConsole()
_chat_console.print(Panel(
_render_final_assistant_content(response, mode=self.final_response_markdown),
title=f"[{_resp_color} bold]{label}[/]",
title_align="left",
border_style=_resp_color,
style=_resp_text,
box=rich_box.HORIZONTALS,
padding=(1, 0),
width=self._scrollback_box_width(),
))
# Durable, provider-agnostic billing CTA below the response. The
# response panel carries the full guidance; this pins the single
# action to take (Nous → /topup, other providers → their billing
# page) so it stays visible instead of scrolling away as prose.
if turn.result and turn.result.get("failure_reason") == "billing":
_bb = turn.result.get("billing_block") or {}
_prov_label = _bb.get("provider_label") or "your provider"
if _bb.get("is_nous"):
_cta_lines = [
"Run [bold]/topup[/] to add credits, or "
"[bold]/subscription[/] to change plan.",
]
else:
_url = _bb.get("billing_url")
_cta_lines = [
f"Add credits with {_prov_label}"
+ (f": [bold]{_url}[/]" if _url else ".")
]
_cta_lines.append(
"Or switch providers with "
"[bold]/model <model> --provider <provider>[/]."
)
try:
ChatConsole().print(Panel(
"\n".join(_cta_lines),
title="[#CD7F32 bold]⚡ Out of credits[/]",
title_align="left",
border_style="#CD7F32",
box=rich_box.HORIZONTALS,
padding=(1, 4),
width=self._scrollback_box_width(),
))
except Exception:
pass
self._chat_print_response_panel(turn, response, response_previewed)
# #60920: Print interruption marker with history suppressed so it
# is never recorded in _OUTPUT_HISTORY. The marker was previously
@@ -7377,6 +7209,156 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin, CLITuiMix
return response
def _chat_resolve_interrupt(self, turn, agent_thread, interrupt_msg, response):
"""Decide the re-queued interrupt message and whether to print the interrupt marker; clears a stale agent interrupt flag."""
# Handle interrupt - check if we were interrupted
pending_message = None
_show_interrupt_marker = False
_interrupted_this_turn = bool(turn.result and turn.result.get("interrupted"))
# Expose the flag for post-turn hooks (e.g. goal continuation)
# so they can skip themselves when the turn was user-cancelled.
self._last_turn_interrupted = _interrupted_this_turn
if _interrupted_this_turn:
pending_message = turn.result.get("interrupt_message") or interrupt_msg
# #60920: Don't append the interruption marker to response so it
# is never recorded in _OUTPUT_HISTORY by the Panel rendering
# below. The marker is printed separately with _suspend_output_history
# after the response Panel to preserve the visual while avoiding
# duplicates on terminal redraw (_recover_terminal_after_interrupt).
_show_interrupt_marker = bool(response and pending_message)
elif interrupt_msg:
# We fired agent.interrupt(interrupt_msg) but the turn result
# doesn't acknowledge it. Two ways this happens, both racy:
# 1. The agent thread had already passed its last interrupt
# check (or finished) when the interrupt landed — the turn
# completed normally and finalize_turn() never saw the flag.
# 2. The 10s post-interrupt wait above expired and we
# abandoned the daemon thread; `result` is still None.
# In both cases the user's message must NOT be dropped —
# re-queue it as the next turn (#interrupt-vacuumed-into-void).
pending_message = interrupt_msg
# If the interrupt landed after finalize_turn()'s
# clear_interrupt(), the stale flag would instantly abort the
# NEXT turn at its first loop check. Clear it now that we've
# claimed the message — but ONLY if the agent thread actually
# exited. If it's still alive (abandoned after the 10s wait),
# the flag is what makes the wedged tool eventually unwind;
# clearing it would un-signal that thread.
try:
if (
not agent_thread.is_alive()
and self.agent
and getattr(self.agent, "_interrupt_requested", False)
):
self.agent.clear_interrupt()
except Exception:
pass
return pending_message, _show_interrupt_marker
def _chat_print_reasoning_box(self, turn):
"""Collapsed reasoning box when show_reasoning is on and streaming did not already show it this turn."""
# Display reasoning (thinking) box if enabled and available.
# Skip when streaming already showed reasoning live. Use the
# turn-persistent flag (_reasoning_shown_this_turn) instead of
# _reasoning_stream_started — the latter gets reset during
# intermediate turn boundaries (tool-calling loops), which caused
# the reasoning box to re-render after the final response.
_reasoning_already_shown = getattr(self, '_reasoning_shown_this_turn', False)
if self.show_reasoning and turn.result and not _reasoning_already_shown:
reasoning = turn.result.get("last_reasoning")
if reasoning:
w = self._scrollback_box_width()
r_label = " Reasoning "
r_fill = w - 2 - len(r_label)
r_top = f"{_DIM}┌─{r_label}{'─' * max(r_fill - 1, 0)}┐{_RST}"
r_bot = f"{_DIM}└{'─' * (w - 2)}┘{_RST}"
# Collapse long reasoning to the first 10 lines unless the
# user opted into full display via /reasoning full.
lines = reasoning.strip().splitlines()
if len(lines) > 10 and not getattr(self, "reasoning_full", False):
display_reasoning = "\n".join(lines[:10])
display_reasoning += f"\n{_DIM} ... ({len(lines) - 10} more lines — /reasoning full to show){_RST}"
else:
display_reasoning = reasoning.strip()
_cprint(f"\n{r_top}\n{_DIM}{display_reasoning}{_RST}\n{r_bot}")
def _chat_print_response_panel(self, turn, response, response_previewed):
"""Response box: close the TTS-drawn box, print the post-stream transform, or render the Rich Panel; then the billing CTA."""
if response and not response_previewed:
# Use skin engine for label/color with fallback
try:
from hermes_cli.skin_engine import get_active_skin
_skin = get_active_skin()
label = _skin.get_branding("response_label", "⚕ Hermes")
_resp_color = _maybe_remap_for_light_mode(_skin.get_color("response_border", "#CD7F32"))
_resp_text = _maybe_remap_for_light_mode(_skin.get_color("banner_text", "#FFF8DC"))
except Exception:
label = "⚕ Hermes"
_resp_color = _maybe_remap_for_light_mode("#CD7F32")
_resp_text = _maybe_remap_for_light_mode("#FFF8DC")
is_error_response = turn.result and (turn.result.get("failed") or turn.result.get("partial"))
already_streamed = self._stream_started and self._stream_box_opened and not is_error_response
if turn.use_streaming_tts and turn.box_opened and not is_error_response:
# Text was already printed sentence-by-sentence; just close the box
w = self._scrollback_box_width()
_cprint(f"\n{_ACCENT}╰{'─' * (w - 2)}╯{_RST}")
elif already_streamed:
# Response was already streamed token-by-token with box framing;
# _flush_stream() already closed the box. Skip Rich Panel.
# A transform hook runs after streaming. Show a suffix for
# append-only changes, or the complete replacement otherwise.
_post_stream_text = _post_stream_transform_output(response, turn.result)
if _post_stream_text.strip():
_cprint(_post_stream_text)
else:
_chat_console = ChatConsole()
_chat_console.print(Panel(
_render_final_assistant_content(response, mode=self.final_response_markdown),
title=f"[{_resp_color} bold]{label}[/]",
title_align="left",
border_style=_resp_color,
style=_resp_text,
box=rich_box.HORIZONTALS,
padding=(1, 0),
width=self._scrollback_box_width(),
))
# Durable, provider-agnostic billing CTA below the response. The
# response panel carries the full guidance; this pins the single
# action to take (Nous → /topup, other providers → their billing
# page) so it stays visible instead of scrolling away as prose.
if turn.result and turn.result.get("failure_reason") == "billing":
_bb = turn.result.get("billing_block") or {}
_prov_label = _bb.get("provider_label") or "your provider"
if _bb.get("is_nous"):
_cta_lines = [
"Run [bold]/topup[/] to add credits, or "
"[bold]/subscription[/] to change plan.",
]
else:
_url = _bb.get("billing_url")
_cta_lines = [
f"Add credits with {_prov_label}"
+ (f": [bold]{_url}[/]" if _url else ".")
]
_cta_lines.append(
"Or switch providers with "
"[bold]/model <model> --provider <provider>[/]."
)
try:
ChatConsole().print(Panel(
"\n".join(_cta_lines),
title="[#CD7F32 bold]⚡ Out of credits[/]",
title_align="left",
border_style="#CD7F32",
box=rich_box.HORIZONTALS,
padding=(1, 4),
width=self._scrollback_box_width(),
))
except Exception:
pass
def _tui_process_loop(self):
while not self._should_exit:
try: