From 59ee2ad3b5395b39f8d9d43474105c727ad161eb Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:23:44 -0700 Subject: [PATCH 1/2] refactor(hermes_cli): split TUI mixin god methods into phase helpers, unify panel/fragment boilerplate, compact docs --- hermes_cli/build_info.py | 48 +- hermes_cli/cli_output.py | 63 +- hermes_cli/cli_tui_mixin.py | 2976 ++++++------------ tests/cli/test_compress_type_ahead.py | 11 +- tests/cli/test_steer_inline_repaint_34569.py | 21 +- 5 files changed, 1061 insertions(+), 2058 deletions(-) diff --git a/hermes_cli/build_info.py b/hermes_cli/build_info.py index 57cddc76e0..3fc7e8eb8a 100644 --- a/hermes_cli/build_info.py +++ b/hermes_cli/build_info.py @@ -1,9 +1,8 @@ """Baked-in build metadata for Hermes Agent. -Source installs report their git revision live via ``git rev-parse`` (see ``hermes_cli/dump.py`` and -``hermes_cli/banner.py``). That doesn't work inside the published Docker image because -``.dockerignore`` excludes ``.git``, so those callsites fall back to ``"(unknown)"`` / drop the -banner suffix entirely. +Source installs report their git revision live via ``git rev-parse`` (``hermes_cli/dump.py``, +``hermes_cli/banner.py``). Inside the published Docker image ``.dockerignore`` excludes ``.git``, +so those callsites fall back to the ``.hermes_build_sha`` file written by the Dockerfile. """ from __future__ import annotations @@ -11,11 +10,8 @@ from __future__ import annotations from pathlib import Path from typing import Optional -# Path is resolved relative to this module so it works regardless of cwd — -# matches the pattern used by ``banner._resolve_repo_dir``. +# Resolved relative to this module so it works regardless of cwd (as banner._resolve_repo_dir). _BUILD_SHA_FILE = Path(__file__).parent.parent / ".hermes_build_sha" - - _code_identity_cache: Optional[dict] = None @@ -28,17 +24,16 @@ def _sha_or_none(value: str) -> Optional[str]: def _resolve_git_head_sha(project_root: Path) -> Optional[str]: - """Resolve the checkout's HEAD commit sha by reading .git directly. + """HEAD sha by reading .git directly (worktrees/submodules, loose refs, packed-refs). Deliberately NOT ``git rev-parse``: this runs in library paths (runtime-status writes, update receipts) where spawning is slow and hostile to tests that mock ``subprocess.run`` tightly. - Handles worktrees/submodules (``gitdir:`` + ``commondir``), loose refs, and packed-refs. Returns None on any failure. """ try: git_path = project_root / ".git" if git_path.is_file(): - # Worktree/submodule: ".git" is a pointer file. + # Worktree/submodule: ".git" is a "gitdir: " pointer file. pointer = _read_stripped(git_path) if not pointer.startswith("gitdir:"): return None @@ -49,24 +44,19 @@ def _resolve_git_head_sha(project_root: Path) -> Optional[str]: git_dir = git_path else: return None - # Refs live in the COMMON git dir for worktrees. common_dir = git_dir commondir_file = git_dir / "commondir" if commondir_file.is_file(): common = Path(_read_stripped(commondir_file)) common_dir = common if common.is_absolute() else (git_dir / common).resolve() - head = _read_stripped(git_dir / "HEAD") if not head.startswith("ref:"): - # Detached HEAD: the file holds the sha itself. - return _sha_or_none(head) + return _sha_or_none(head) # detached HEAD holds the sha itself ref_name = head[len("ref:"):].strip() - loose = common_dir / ref_name if loose.is_file(): return _sha_or_none(_read_stripped(loose)) - packed = common_dir / "packed-refs" if packed.is_file(): for line in _read_stripped(packed).splitlines(): @@ -82,20 +72,16 @@ def _resolve_git_head_sha(project_root: Path) -> Optional[str]: def get_code_identity(refresh: bool = False) -> dict: - """Return the running checkout's code identity as a dict. + """``{sha, short_sha, version, source}`` for the running checkout; never raises. - Resolution order mirrors the banner/dump callsites: live ``git rev-parse`` for source installs, - the baked ``.hermes_build_sha`` for Docker images (no ``.git`` inside the published image), else - unknown. - - Cached per process — code identity cannot change while a process is running (an updated checkout - requires a restart to take effect, which is exactly the property the fleet version verification - relies on). Never raises; every field degrades to ``None`` independently. + Resolution mirrors the banner/dump callsites: live git for source installs, the baked + ``.hermes_build_sha`` for Docker images, else unknown. Cached per process — code identity + cannot change while a process runs (an updated checkout needs a restart), which is exactly + the property fleet version verification relies on. Each field degrades to None independently. """ global _code_identity_cache if _code_identity_cache is not None and not refresh: return dict(_code_identity_cache) - project_root = Path(__file__).parent.parent source = "unknown" sha = _resolve_git_head_sha(project_root) @@ -105,17 +91,14 @@ def get_code_identity(refresh: bool = False) -> dict: sha = get_build_sha(short=0) if sha: source = "build-file" - version: Optional[str] = None try: import tomllib - with open(project_root / "pyproject.toml", "rb") as fh: # windows-footgun: ok — binary mode, tomllib requires bytes raw_version = tomllib.load(fh).get("project", {}).get("version") version = str(raw_version) if raw_version else None except Exception: version = None - _code_identity_cache = { "sha": sha, "short_sha": sha[:8] if sha else None, @@ -126,11 +109,8 @@ def get_code_identity(refresh: bool = False) -> dict: def get_build_sha(short: int = 8) -> Optional[str]: - """Return the baked-in build SHA, truncated to ``short`` chars, or None. - - Reads ``/.hermes_build_sha``, written by the Dockerfile's ``HERMES_GIT_SHA`` - build-arg (full 40-char hash on one line). - """ + """Baked-in build SHA (Dockerfile ``HERMES_GIT_SHA`` build-arg, full 40-char hash on one + line), truncated to ``short`` chars (``short<=0`` = full), or None.""" try: if not _BUILD_SHA_FILE.is_file(): return None diff --git a/hermes_cli/cli_output.py b/hermes_cli/cli_output.py index 7de9198755..6c85046f24 100644 --- a/hermes_cli/cli_output.py +++ b/hermes_cli/cli_output.py @@ -1,9 +1,4 @@ -"""Shared CLI output helpers for Hermes CLI modules. - -Extracts the identical ``print_info/success/warning/error`` and ``prompt()`` -functions previously duplicated across setup.py, tools_config.py, -mcp_config.py, and memory_setup.py. -""" +"""Shared CLI output helpers (``print_*`` + ``prompt()``) for the setup/config wizards.""" import sys @@ -11,91 +6,57 @@ from hermes_cli.colors import Colors, color from hermes_cli.secret_prompt import masked_secret_prompt -# ─── Print Helpers ──────────────────────────────────────────────────────────── - - def print_info(text: str) -> None: - """Print a dim informational message.""" print(color(f" {text}", Colors.DIM)) def print_success(text: str) -> None: - """Print a green success message with ✓ prefix.""" print(color(f"✓ {text}", Colors.GREEN)) def print_warning(text: str) -> None: - """Print a yellow warning message with ⚠ prefix.""" print(color(f"⚠ {text}", Colors.YELLOW)) def print_error(text: str) -> None: - """Print a red error message with ✗ prefix.""" print(color(f"✗ {text}", Colors.RED)) def print_header(text: str) -> None: - """Print a bold yellow header.""" print(color(f"\n {text}", Colors.YELLOW)) -# ─── Input Prompts ──────────────────────────────────────────────────────────── - - def line_input(prompt_text: str) -> str: """Read non-secret text with normal cursor-editing keys on a real TTY. - Setup and model-selection commands run outside the interactive chat's - prompt-toolkit application, so they can safely use a short-lived prompt - here. Redirected input and output retain the built-in ``input`` behavior - used by scripts, tests, and numbered fallbacks. + Setup/model-selection commands run outside the chat's prompt_toolkit application, so a + short-lived prompt is safe here. Redirected stdin/stdout keep the built-in ``input`` used by + scripts, tests and numbered fallbacks. """ if not (sys.stdin.isatty() and sys.stdout.isatty()): return input(prompt_text) - try: from prompt_toolkit import prompt as prompt_toolkit_prompt from prompt_toolkit.formatted_text import ANSI except ImportError: return input(prompt_text) - try: return prompt_toolkit_prompt(ANSI(prompt_text)) except (KeyboardInterrupt, EOFError): raise except Exception: - # Some terminals report isatty() == True yet reject registering stdin - # with the asyncio event-loop selector (observed on macOS, where kqueue - # raises EINVAL / "Invalid argument" for fd 0). prompt_toolkit cannot - # attach its input there, so fall back to the built-in line reader, - # which needs no selector and works in cooked mode. Any prompt_toolkit - # runtime failure (OSError, ValueError, RuntimeError) degrades the same - # way — the wizard proceeds instead of crashing. + # Some terminals report isatty() yet reject registering stdin with the asyncio selector + # (macOS kqueue raises EINVAL for fd 0). Any prompt_toolkit runtime failure degrades to + # the built-in reader, which needs no selector — the wizard proceeds instead of crashing. return input(prompt_text) -def prompt( - question: str, - default: str | None = None, - password: bool = False, -) -> str: - """Prompt the user for input with optional default and password masking. - - Replaces the four independent ``_prompt()`` / ``prompt()`` implementations - in setup.py, tools_config.py, mcp_config.py, and memory_setup.py. - - Returns the user's input (stripped), or *default* if the user presses Enter. - Returns empty string on Ctrl-C or EOF. - """ +def prompt(question: str, default: str | None = None, password: bool = False) -> str: + """Prompt for input (stripped), or ``default`` on plain Enter; "" on Ctrl-C/EOF.""" suffix = f" [{default}]" if default else "" display = color(f" {question}{suffix}: ", Colors.YELLOW) - try: - if password: - value = masked_secret_prompt(display) - else: - value = line_input(display) - value = value.strip() + value = (masked_secret_prompt(display) if password else line_input(display)).strip() return value if value else (default or "") except (KeyboardInterrupt, EOFError): print() @@ -103,9 +64,7 @@ def prompt( def prompt_yes_no(question: str, default: bool = True) -> bool: - """Prompt for a yes/no answer. Returns bool.""" - hint = "Y/n" if default else "y/N" - answer = prompt(f"{question} ({hint})") + answer = prompt(f"{question} ({'Y/n' if default else 'y/N'})") if not answer: return default return answer.lower().startswith("y") diff --git a/hermes_cli/cli_tui_mixin.py b/hermes_cli/cli_tui_mixin.py index c83bac394d..8ca107f7a6 100644 --- a/hermes_cli/cli_tui_mixin.py +++ b/hermes_cli/cli_tui_mixin.py @@ -1,9 +1,6 @@ -"""prompt_toolkit TUI construction, key-binding handlers, and overlay display fragments for the interactive CLI - -Mixin split out of ``cli.py``; bound onto ``HermesCLI`` via the MRO. cli.py-internal -symbols are imported LAZILY inside each method (``from cli import ...``) — the mixin -never imports ``cli`` at module load time (import cycle). -""" +"""prompt_toolkit TUI construction, key-binding handlers, and overlay display fragments for the +interactive CLI. Mixin bound onto ``HermesCLI`` via the MRO; cli.py-internal symbols are imported +LAZILY inside each method (``from cli import ...``) — never at module load (import cycle).""" from __future__ import annotations @@ -12,6 +9,7 @@ import json import os import queue import shutil +import string import sys import threading import time @@ -28,26 +26,64 @@ from prompt_toolkit.layout import ( HSplit, Layout, Window, - WindowAlign, -) + WindowAlign) from prompt_toolkit.layout.dimension import Dimension from prompt_toolkit.layout.menus import CompletionsMenu from prompt_toolkit.layout.processors import ( ConditionalProcessor, PasswordProcessor, Processor, - Transformation, -) + Transformation) from prompt_toolkit.styles import Style as PTStyle from prompt_toolkit.widgets import TextArea from typing import Optional +# Rows below an overlay panel taken by spinner/tool-progress, status bar, input, separators and +# prompt symbol (measured ~6 during live PTY approval prompts) — shared by every panel budget. +_PANEL_RESERVED_BELOW = 6 +_TYPING_CHARS = string.digits + string.ascii_letters + "-_.:/ " + +_APPROVAL_CHOICE_LABELS = { + "once": "Allow once", + "session": "Allow for this session", + "always": "Add to permanent allowlist", + "deny": "Deny", + "view": "Show full command"} + + +def _num_prefix(i: int) -> str: + """Quick-select key label: 1-9 for items 1-9, 0 for the 10th, blank beyond.""" + return str(i + 1) if i < 9 else ("0" if i == 9 else " ") + + +def _term_rows() -> int: + return shutil.get_terminal_size((100, 24)).lines + + +def _panel_open(lines, border: str, box_width: int, title: str = "", title_style: str = "") -> None: + """Top border; with ``title`` the title is inlined into the rule (``╭─ Title ───╮``).""" + if title: + lines.append((border, "╭─ ")) + lines.append((title_style, title)) + lines.append((border, " " + ("─" * max(0, box_width - len(title) - 3)) + "╮\n")) + else: + lines.append((border, "╭" + ("─" * box_width) + "╮\n")) + + +def _panel_close(lines, border: str, box_width: int) -> None: + lines.append((border, "╰" + ("─" * box_width) + "╯\n")) + + +def _wrap_rows(wrap, items, width, indent) -> list[tuple[int, str]]: + """``(index, wrapped_line)`` pairs so selected styling can be re-applied per row.""" + return [(i, w) for i, label in enumerate(items) for w in wrap(label, width, subsequent_indent=indent)] + class CLITuiMixin: - """prompt_toolkit TUI construction, key-binding handlers, and overlay display fragments for the interactive CLI""" + """prompt_toolkit TUI construction, key-binding handlers, and overlay display fragments.""" def _tui_input_rule_height(self, position: str, width: Optional[int] = None) -> int: - """Return the visible height for the top/bottom input separator rules.""" + """Visible height for the top/bottom input separator rules.""" if position not in {"top", "bottom"}: raise ValueError(f"Unknown input rule position: {position}") if getattr(self, "_status_bar_suppressed_after_resize", False): @@ -58,54 +94,36 @@ class CLITuiMixin: def _get_slash_confirm_display_fragments(self): """Render the /new-/clear-style confirmation panel.""" - from cli import ( - _append_blank_panel_line, - _append_panel_line, - _panel_box_width, - _wrap_panel_text_keep_ws, - ) + from cli import _append_blank_panel_line, _append_panel_line, _panel_box_width, _wrap_panel_text_keep_ws state = self._slash_confirm_state if not state: return [] - + wrap = _wrap_panel_text_keep_ws title = state.get("title") or "Confirm action" detail = state.get("detail") or "" choices = state.get("choices") or [] selected = state.get("selected", 0) + footer = "Type 1/2/3 or use ↑/↓ then Enter. ESC/Ctrl+C cancels." + choice_labels = [ + f"{'❯' if idx == selected else ' '} [{idx + 1}] {label} — {desc}" + for idx, (_value, label, desc) in enumerate(choices)] - _wrap_panel_text = _wrap_panel_text_keep_ws - - preview_lines = [] - for line in detail.splitlines(): - preview_lines.extend(_wrap_panel_text(line, 72)) - for idx, (_value, label, desc) in enumerate(choices): - marker = "❯" if idx == selected else " " - preview_lines.extend(_wrap_panel_text(f"{marker} [{idx + 1}] {label} — {desc}", 72, subsequent_indent=" ")) - preview_lines.append("Type 1/2/3 or use ↑/↓ then Enter. ESC/Ctrl+C cancels.") - + preview_lines = [w for line in detail.splitlines() for w in wrap(line, 72)] + preview_lines.extend(w for _i, w in _wrap_rows(wrap, choice_labels, 72, " ")) + preview_lines.append(footer) box_width = _panel_box_width(title, preview_lines, min_width=56, max_width=86) inner_text_width = max(8, box_width - 2) - detail_wrapped = [] - for line in detail.splitlines(): - detail_wrapped.extend(_wrap_panel_text(line, inner_text_width)) - choice_wrapped: list[tuple[int, str]] = [] - for idx, (_value, label, desc) in enumerate(choices): - marker = "❯" if idx == selected else " " - for wrapped in _wrap_panel_text(f"{marker} [{idx + 1}] {label} — {desc}", inner_text_width, subsequent_indent=" "): - choice_wrapped.append((idx, wrapped)) + detail_wrapped = [w for line in detail.splitlines() for w in wrap(line, inner_text_width)] + choice_wrapped = _wrap_rows(wrap, choice_labels, inner_text_width, " ") - term_rows = shutil.get_terminal_size((100, 24)).lines - reserved_below = 6 chrome_full = 6 - available = max(0, term_rows - reserved_below) - max_detail_rows = max(1, available - chrome_full - len(choice_wrapped)) - max_detail_rows = min(max_detail_rows, 8) + available = max(0, _term_rows() - _PANEL_RESERVED_BELOW) + max_detail_rows = min(8, max(1, available - chrome_full - len(choice_wrapped))) if len(detail_wrapped) > max_detail_rows: - keep = max(1, max_detail_rows - 1) - detail_wrapped = detail_wrapped[:keep] + ["… (detail truncated)"] + detail_wrapped = detail_wrapped[:max(1, max_detail_rows - 1)] + ["… (detail truncated)"] lines = [] - lines.append(('class:approval-border', '╭' + ('─' * box_width) + '╮\n')) + _panel_open(lines, 'class:approval-border', box_width) _append_panel_line(lines, 'class:approval-border', 'class:approval-title', title, box_width) _append_blank_panel_line(lines, 'class:approval-border', box_width) for wrapped in detail_wrapped: @@ -115,181 +133,106 @@ class CLITuiMixin: style = 'class:approval-selected' if idx == selected else 'class:approval-choice' _append_panel_line(lines, 'class:approval-border', style, wrapped, box_width) _append_blank_panel_line(lines, 'class:approval-border', box_width) - _append_panel_line(lines, 'class:approval-border', 'class:approval-cmd', 'Type 1/2/3 or use ↑/↓ then Enter. ESC/Ctrl+C cancels.', box_width) - lines.append(('class:approval-border', '╰' + ('─' * box_width) + '╯\n')) + _append_panel_line(lines, 'class:approval-border', 'class:approval-cmd', footer, box_width) + _panel_close(lines, 'class:approval-border', box_width) return lines def _get_approval_display_fragments(self): - """Render the dangerous-command approval panel for the prompt_toolkit UI. + """Render the dangerous-command approval panel. - Layout priority: title + command + choices must always render, even if - the terminal is short or the description is long. Description is placed - at the bottom of the panel and gets truncated to fit the remaining row - budget. This prevents HSplit from clipping approve/deny off-screen when - tirith findings produce multi-paragraph descriptions or when the user - runs in a compact terminal pane. + Layout priority: title + command + choices must always render, even in a short terminal + or with a long (tirith multi-paragraph) description. The description sits at the bottom + and is truncated to the remaining row budget, so HSplit never clips approve/deny off-screen. """ - from cli import ( - _append_blank_panel_line, - _append_panel_line, - _panel_box_width, - _wrap_panel_text_keep_ws, - ) + from cli import _append_blank_panel_line, _append_panel_line, _panel_box_width, _wrap_panel_text_keep_ws state = self._approval_state if not state: return [] - - _wrap_panel_text = _wrap_panel_text_keep_ws - + wrap = _wrap_panel_text_keep_ws command = state["command"] description = state["description"] choices = state["choices"] selected = state.get("selected", 0) show_full = state.get("show_full", False) - title = "⚠️ Dangerous Command" - cmd_display = command - choice_labels = { - "once": "Allow once", - "session": "Allow for this session", - "always": "Add to permanent allowlist", - "deny": "Deny", - "view": "Show full command", - } - preview_lines = _wrap_panel_text(description, 60) - preview_lines.extend(_wrap_panel_text(cmd_display, 60)) + preview_lines = wrap(description, 60) + preview_lines.extend(wrap(command, 60)) for i, choice in enumerate(choices): prefix = '❯ ' if i == selected else ' ' - preview_lines.extend(_wrap_panel_text( - f"{prefix}{choice_labels.get(choice, choice)}", - 60, - subsequent_indent=" ", - )) - + preview_lines.extend(wrap(f"{prefix}{_APPROVAL_CHOICE_LABELS.get(choice, choice)}", 60, subsequent_indent=" ")) box_width = _panel_box_width(title, preview_lines) inner_text_width = max(8, box_width - 2) # Pre-wrap the mandatory content — command + choices must always render. - cmd_wrapped = _wrap_panel_text(cmd_display, inner_text_width) + cmd_wrapped = wrap(command, inner_text_width) if not show_full and "view" in choices and len(cmd_wrapped) > 4: - cmd_wrapped = cmd_wrapped[:3] + _wrap_panel_text( - "… (choose Show full command)", - inner_text_width, - ) + cmd_wrapped = cmd_wrapped[:3] + wrap("… (choose Show full command)", inner_text_width) + choice_labels = [ + f"{'❯' if i == selected else ' '} {_num_prefix(i)}. {_APPROVAL_CHOICE_LABELS.get(choice, choice)}" + for i, choice in enumerate(choices)] + choice_wrapped = _wrap_rows(wrap, choice_labels, inner_text_width, " ") - # (choice_index, wrapped_line) so we can re-apply selected styling below - choice_wrapped: list[tuple[int, str]] = [] - for i, choice in enumerate(choices): - label = choice_labels.get(choice, choice) - # Show number prefix for quick selection (1-9 for items 1-9, 0 for 10th item) - if i < 9: - num_prefix = str(i + 1) - elif i == 9: - num_prefix = '0' - else: - num_prefix = ' ' # No number for items beyond 10th - prefix = f'❯ {num_prefix}. ' if i == selected else f' {num_prefix}. ' - for wrapped in _wrap_panel_text(f"{prefix}{label}", inner_text_width, subsequent_indent=" "): - choice_wrapped.append((i, wrapped)) + # Row budget so HSplit never clips the command or choices. Full chrome = top border + + # title + blank + blank-between-cmd/choices + bottom border (5); when that doesn't fit, + # drop the separator blanks (3) so every choice stays on-screen in compact terminals. + available = max(0, _term_rows() - _PANEL_RESERVED_BELOW) + use_compact_chrome = 5 + len(cmd_wrapped) + len(choice_wrapped) > available + chrome_rows = 3 if use_compact_chrome else 5 - # Budget vertical space so HSplit never clips the command or choices. - # Panel chrome (full layout with separators): - # top border + title + blank_after_title - # + blank_between_cmd_choices + bottom border = 5 rows. - # In tight terminals we collapse to: - # top border + title + bottom border = 3 rows (no blanks). - # - # reserved_below: rows consumed below the approval panel by the - # spinner/tool-progress line, status bar, input area, separators, and - # prompt symbol. Measured at ~6 rows during live PTY approval prompts; - # budget 6 so we don't overestimate the panel's room. - term_rows = shutil.get_terminal_size((100, 24)).lines - chrome_full = 5 - chrome_tight = 3 - reserved_below = 6 - - available = max(0, term_rows - reserved_below) - mandatory_full = chrome_full + len(cmd_wrapped) + len(choice_wrapped) - - # If the full-chrome panel doesn't fit, drop the separator blanks. - # This keeps the command and every choice on-screen in compact terminals. - use_compact_chrome = mandatory_full > available - chrome_rows = chrome_tight if use_compact_chrome else chrome_full - - # If the command itself is too long to leave room for choices (e.g. user - # hit "view" on a multi-hundred-character command), truncate it so the - # approve/deny buttons still render. Keep at least 1 row of command. + # A command too long to leave room for the choices (e.g. "view" on a multi-hundred-char + # command) is truncated so approve/deny still render; keep at least 1 command row. max_cmd_rows = max(1, available - chrome_rows - len(choice_wrapped)) if len(cmd_wrapped) > max_cmd_rows: keep = max(1, max_cmd_rows - 1) if max_cmd_rows > 1 else 1 - cmd_wrapped = cmd_wrapped[:keep] + _wrap_panel_text( - "… (command truncated — use /logs or /debug for full text)", - inner_text_width, - ) + cmd_wrapped = cmd_wrapped[:keep] + wrap( + "… (command truncated — use /logs or /debug for full text)", inner_text_width) - # Allocate any remaining rows to description. The extra -1 in full mode - # accounts for the blank separator between choices and description. + # Remaining rows go to the description (minus the blank separator in full mode), capped + # at 10 so the panel stays compact even on huge terminals. mandatory_no_desc = chrome_rows + len(cmd_wrapped) + len(choice_wrapped) - desc_sep_cost = 0 if use_compact_chrome else 1 - available_for_desc = available - mandatory_no_desc - desc_sep_cost - # Even on huge terminals, cap description height so the panel stays compact. + available_for_desc = available - mandatory_no_desc - (0 if use_compact_chrome else 1) available_for_desc = max(0, min(available_for_desc, 10)) - - desc_wrapped = _wrap_panel_text(description, inner_text_width) if description else [] + desc_wrapped = wrap(description, inner_text_width) if description else [] if available_for_desc < 1 or not desc_wrapped: desc_wrapped = [] elif len(desc_wrapped) > available_for_desc: - keep = max(1, available_for_desc - 1) - desc_wrapped = desc_wrapped[:keep] + ["… (description truncated)"] + desc_wrapped = desc_wrapped[:max(1, available_for_desc - 1)] + ["… (description truncated)"] - # Render: title → command → choices → description (description last so - # any remaining overflow clips from the bottom of the least-critical - # content, never from the command or choices). Use compact chrome (no - # blank separators) when the terminal is tight. + # Render title → command → choices → description; description last so any overflow + # clips the least-critical content, never the command or choices. lines = [] - lines.append(('class:approval-border', '╭' + ('─' * box_width) + '╮\n')) + _panel_open(lines, 'class:approval-border', box_width) _append_panel_line(lines, 'class:approval-border', 'class:approval-title', title, box_width) if not use_compact_chrome: _append_blank_panel_line(lines, 'class:approval-border', box_width) - for wrapped in cmd_wrapped: _append_panel_line(lines, 'class:approval-border', 'class:approval-cmd', wrapped, box_width) if not use_compact_chrome: _append_blank_panel_line(lines, 'class:approval-border', box_width) - for i, wrapped in choice_wrapped: style = 'class:approval-selected' if i == selected else 'class:approval-choice' _append_panel_line(lines, 'class:approval-border', style, wrapped, box_width) - if desc_wrapped: if not use_compact_chrome: _append_blank_panel_line(lines, 'class:approval-border', box_width) for wrapped in desc_wrapped: _append_panel_line(lines, 'class:approval-border', 'class:approval-desc', wrapped, box_width) - - lines.append(('class:approval-border', '╰' + ('─' * box_width) + '╯\n')) + _panel_close(lines, 'class:approval-border', box_width) return lines 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. - - When a profile is active (not "default"), the profile name is - prepended to the prompt symbol: ``coder ❯`` instead of ``❯``. + ``state_suffix`` is what special states (sudo/secret/approval/agent) render after their + leading icon. A non-default profile name is prepended (``coder ❯``). """ try: from hermes_cli.skin_engine import get_active_prompt_symbol symbol = get_active_prompt_symbol("❯ ") except Exception: symbol = "❯ " - symbol = (symbol or "❯ ").rstrip() + " " - - # Prepend profile name when not default try: from hermes_cli.profiles import get_active_profile_name profile = get_active_profile_name() @@ -300,30 +243,23 @@ class CLITuiMixin: 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): + if any(ch in candidate for ch in ("❯", ">", "$", "#", "›", "»", "→")): return symbol, candidate.rstrip() + " " - # Icon-only custom prompts should still remain visible in special states. return symbol, symbol def _audio_level_bar(self) -> str: - """Return a visual audio level indicator based on current RMS.""" - _LEVEL_BARS = " ▁▂▃▄▅▆▇" + """One-char audio level indicator from the recorder's current RMS.""" rec = getattr(self, "_voice_recorder", None) if rec is None: return "" - rms = rec.current_rms - # Normalize RMS (0-32767) to 0-7 index, with log-ish scaling - # Typical speech RMS is 500-5000, we cap display at ~8000 - level = min(rms, 8000) * 7 // 8000 - return _LEVEL_BARS[level] + # RMS 0-32767 → index 0-7; typical speech is 500-5000, display caps at ~8000. + return " ▁▂▃▄▅▆▇"[min(rec.current_rms, 8000) * 7 // 8000] def _get_tui_prompt_fragments(self): - """Return the prompt_toolkit fragments for the current interactive state.""" + """prompt_toolkit fragments for the current interactive state.""" symbol, state_suffix = self._get_tui_prompt_symbols() compact = self._use_minimal_tui_chrome(width=self._get_tui_terminal_width()) @@ -338,17 +274,14 @@ class CLITuiMixin: return [(style, f"{icon} {state_suffix}")] if self._voice_recording: - bar = self._audio_level_bar() - return _state_fragment("class:voice-recording", "●", bar) + return _state_fragment("class:voice-recording", "●", self._audio_level_bar()) if self._voice_processing: return _state_fragment("class:voice-processing", "◉") if self._sudo_state: return _state_fragment("class:sudo-prompt", "🔐") if self._secret_state: return _state_fragment("class:sudo-prompt", "🔑") - if self._approval_state: - return _state_fragment("class:prompt-working", "⚠") - if getattr(self, "_slash_confirm_state", None): + if self._approval_state or getattr(self, "_slash_confirm_state", None): return _state_fragment("class:prompt-working", "⚠") if self._clarify_freetext: return _state_fragment("class:clarify-selected", "✎") @@ -363,16 +296,16 @@ class CLITuiMixin: return [("class:prompt", symbol)] def _get_tui_prompt_text(self) -> str: - """Return the visible prompt text for width calculations.""" + """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. - Also rewrites any hex-color tokens in the resulting style strings - to their light-mode equivalents (via _LIGHT_MODE_REMAP) when the - terminal is detected as light. This makes the chrome readable - on cream Terminal.app backgrounds without per-skin overrides. + On a light terminal, hex tokens in each style string are rewritten through the light-mode + remap so the chrome stays readable on cream Terminal.app backgrounds. CRITICAL: a style + that paints its own ``bg:`` (status bar, completion menu) is left alone — its fg was tuned + for that dark bg and remapping would give dark-on-dark; the terminal's mode is irrelevant. """ from cli import _detect_light_mode, _maybe_remap_for_light_mode style_dict = dict(getattr(self, "_tui_style_base", {}) or {}) @@ -381,32 +314,15 @@ class CLITuiMixin: style_dict.update(get_prompt_toolkit_style_overrides()) except Exception: pass - # Light-mode remap on the style strings. Each value is a pt - # style string like "bg:#1a1a2e #C0C0C0 bold" — split on space, - # rewrite any "#XXX" tokens (including "bg:#XXX") through the - # light-mode remap, rejoin. - # - # CRITICAL: skip the remap entirely when a style string already - # specifies its own bg (e.g. status-bar / completion-menu styles - # with `bg:#1a1a2e ...`). Those colors were tuned for that - # specific dark bg and remapping the FG to a dark equivalent - # would produce dark-on-dark (invisible). The terminal's BG - # mode is irrelevant — what matters is the bg the style itself - # paints. try: if _detect_light_mode(): def _remap_value(v: str) -> str: if not v: return v tokens = v.split() - has_explicit_bg = any(t.startswith("bg:") for t in tokens) - if has_explicit_bg: - # The style paints its own bg — leave its fg alone. + if any(t.startswith("bg:") for t in tokens): return v - return " ".join( - _maybe_remap_for_light_mode(t) if t.startswith("#") else t - for t in tokens - ) + return " ".join(_maybe_remap_for_light_mode(t) if t.startswith("#") else t for t in tokens) style_dict = {k: _remap_value(v or "") for k, v in style_dict.items()} except Exception: pass @@ -421,28 +337,11 @@ class CLITuiMixin: return True def _get_extra_tui_widgets(self) -> list: - """Return extra prompt_toolkit widgets to insert into the TUI layout. - - Wrapper CLIs can override this to inject widgets (e.g. a mini-player, - overlay menu) into the layout without overriding ``run()``. Widgets - are inserted between the spacer and the status bar. - """ + """Extension hook: wrapper CLIs return widgets inserted between the spacer and status bar.""" return [] def _register_extra_tui_keybindings(self, kb, *, input_area) -> None: - """Register extra keybindings on the TUI ``KeyBindings`` object. - - Wrapper CLIs can override this to add keybindings (e.g. transport - controls, modal shortcuts) without overriding ``run()``. - - Parameters - ---------- - kb : KeyBindings - The active keybinding registry for the prompt_toolkit application. - input_area : TextArea - The main input widget, for wrappers that need to inspect or - manipulate user input from a keybinding handler. - """ + """Extension hook: wrapper CLIs add bindings to ``kb`` (``input_area`` is the main TextArea).""" def _build_tui_layout_children( self, @@ -462,38 +361,32 @@ class CLITuiMixin: input_area, input_rule_bot, voice_status_bar, - completions_menu, - ) -> list: - """Assemble the ordered list of children for the root ``HSplit``. - - Wrapper CLIs typically override ``_get_extra_tui_widgets`` instead of - this method. Override this only when you need full control over widget - ordering. - """ - return [ - item for item in [ - Window(height=0), - sudo_widget, - secret_widget, - approval_widget, - slash_confirm_widget, - clarify_widget, - model_picker_widget, - command_palette_widget, - spinner_widget, - spacer, - *self._get_extra_tui_widgets(), - getattr(self, "_pet_widget", None), - getattr(self, "_stash_panel_widget", None), - status_bar, - input_rule_top, - image_bar, - input_area, - input_rule_bot, - voice_status_bar, - completions_menu, - ] if item is not None + completions_menu) -> list: + """Ordered children of the root ``HSplit``; override only for full control over ordering + (wrappers normally override ``_get_extra_tui_widgets`` instead).""" + ordered = [ + Window(height=0), + sudo_widget, + secret_widget, + approval_widget, + slash_confirm_widget, + clarify_widget, + model_picker_widget, + command_palette_widget, + spinner_widget, + spacer, + *self._get_extra_tui_widgets(), + getattr(self, "_pet_widget", None), + getattr(self, "_stash_panel_widget", None), + status_bar, + input_rule_top, + image_bar, + input_area, + input_rule_bot, + voice_status_bar, + completions_menu, ] + return [item for item in ordered if item is not None] def _tui_spinner_loop(self): while not self._should_exit: @@ -504,87 +397,58 @@ class CLITuiMixin: self._invalidate(min_interval=0.1) time.sleep(0.1) else: - # Do not repaint the idle prompt every second. In non-full-screen - # prompt_toolkit mode, background redraws can fight tmux/Ghostty/cmux - # viewport restoration after focus changes and visually move the - # command input area. Keep idle stable; input/agent events still - # invalidate explicitly when the UI actually changes. + # Never repaint the idle prompt on a timer: in non-full-screen mode background + # redraws fight tmux/Ghostty/cmux viewport restoration after focus changes and + # visually move the input area. Input/agent events invalidate explicitly. time.sleep(0.2) def _get_clarify_batch_display_fragments(self, state): - """Build styled text for the batch (multi-question) clarify panel. - - A-compact layout mirroring the TUI: a "N questions" header, one - status line per question (✓ answered → answer / ▸ active / - · pending), and the active question's numbered choices (+ Other) - expanded directly beneath its status line. - """ + """Batch (multi-question) clarify panel: "N questions" header, one status line per question + (✓ answered → answer / ▸ active / · pending), and the active question's numbered choices + (+ Other) expanded beneath its status line.""" from cli import _append_panel_line, _panel_box_width, _wrap_panel_text questions_list = state.get("questions") or [] answers = state.get("answers") or {} + answer_meta = state.get("answer_meta") or {} active = state.get("active", 0) choices = state.get("choices") or [] selected = state.get("selected", 0) multi_select = state.get("multi_select", False) selected_indices = state.get("selected_indices", set()) if multi_select else set() - + freetext = self._clarify_freetext title = "Hermes needs your input" header = f"{len(questions_list)} questions" def _status_rows(width): - """(style, text) rows for the status list + expanded active question.""" rows = [] - answer_meta = state.get("answer_meta") or {} for idx, entry in enumerate(questions_list): answered = entry["qid"] in answers - if answered: - marker = "✓" - elif idx == active: - marker = "▸" - else: - marker = "·" - label = f"{marker} {entry['question']}" + marker = "✓" if answered else ("▸" if idx == active else "·") row_style = 'class:clarify-selected' if idx == active else 'class:clarify-choice' - for wrapped in _wrap_panel_text(label, width, subsequent_indent=" "): + for wrapped in _wrap_panel_text(f"{marker} {entry['question']}", width, subsequent_indent=" "): rows.append((row_style, wrapped)) if answered: - # The locked answer on its own line, in its own color, - # so the current answer stays readable while walking - # the list with Tab/Shift-Tab. - for wrapped in _wrap_panel_text( - f" {answers[entry['qid']]}", width, subsequent_indent=" " - ): + # Locked answer on its own line/color so it stays readable while Tab-walking. + for wrapped in _wrap_panel_text(f" {answers[entry['qid']]}", width, subsequent_indent=" "): rows.append(('class:clarify-answer', wrapped)) if idx != active: continue - # Expanded active question: numbered choices + Other. for i, choice in enumerate(choices): - num_prefix = str(i + 1) if i < 9 else ('0' if i == 9 else ' ') - if multi_select: - cb = "[x]" if i in selected_indices else "[ ]" - cursor = "❯" if i == selected and not self._clarify_freetext else " " - prefix = f" {cursor} {cb} {num_prefix}. " - else: - cursor = "❯" if i == selected and not self._clarify_freetext else " " - prefix = f" {cursor} {num_prefix}. " - style = 'class:clarify-selected' if i == selected and not self._clarify_freetext else 'class:clarify-choice' - for wrapped in _wrap_panel_text(f"{prefix}{choice}", width, subsequent_indent=" "): + cursor = "❯" if i == selected and not freetext else " " + cb = ("[x] " if i in selected_indices else "[ ] ") if multi_select else "" + style = 'class:clarify-selected' if i == selected and not freetext else 'class:clarify-choice' + for wrapped in _wrap_panel_text(f" {cursor} {cb}{_num_prefix(i)}. {choice}", width, subsequent_indent=" "): rows.append((style, wrapped)) if choices: other_idx = len(choices) - other_num = other_idx + 1 - other_num_prefix = str(other_num) if other_num < 10 else ('0' if other_num == 10 else ' ') + mid = _num_prefix(other_idx) if multi_select: - cb = "[x]" if other_idx in selected_indices else "[ ]" - mid = f"{cb} {other_num_prefix}" - else: - mid = other_num_prefix - # An earlier typed answer stays visible next to Other; - # Enter on it edits (the composer is prefilled). - meta = answer_meta.get(entry["qid"]) or {} - other_text = meta.get("other_text") or "" + mid = f"{'[x]' if other_idx in selected_indices else '[ ]'} {mid}" + # An earlier typed answer stays visible next to Other; Enter on it edits + # (the composer is prefilled). + other_text = (answer_meta.get(entry["qid"]) or {}).get("other_text") or "" other_suffix = f"Other: {other_text}" if other_text else None - if self._clarify_freetext: + if freetext: other_label = f" ❯ {mid}. " + (other_suffix or "Other (type below)") other_style = 'class:clarify-active-other' elif selected == other_idx: @@ -595,35 +459,28 @@ class CLITuiMixin: other_style = 'class:clarify-choice' for wrapped in _wrap_panel_text(other_label, width, subsequent_indent=" "): rows.append((other_style, wrapped)) - elif self._clarify_freetext: - for wrapped in _wrap_panel_text( - " Type your answer in the prompt below, then press Enter.", width - ): + elif freetext: + for wrapped in _wrap_panel_text(" Type your answer in the prompt below, then press Enter.", width): rows.append(('class:clarify-active-other', wrapped)) return rows preview_rows = _status_rows(60) box_width = _panel_box_width(title, [header] + [text for _, text in preview_rows]) - inner_text_width = max(8, box_width - 2) - rows = _status_rows(inner_text_width) + rows = _status_rows(max(8, box_width - 2)) lines = [] - lines.append(('class:clarify-border', '╭─ ')) - lines.append(('class:clarify-title', title)) - lines.append(('class:clarify-border', ' ' + ('─' * max(0, box_width - len(title) - 3)) + '╮\n')) + _panel_open(lines, 'class:clarify-border', box_width, title, 'class:clarify-title') _append_panel_line(lines, 'class:clarify-border', 'class:clarify-question', header, box_width) for style, text in rows: _append_panel_line(lines, 'class:clarify-border', style, text, box_width) - lines.append(('class:clarify-border', '╰' + ('─' * box_width) + '╯\n')) + _panel_close(lines, 'class:clarify-border', box_width) return lines def _get_clarify_display_fragments(self): - """Build styled text for the clarify question/choices panel. + """Clarify question/choices panel. - Layout priority: choices + Other option must always render even if - the question is very long. The question is budgeted to leave enough - rows for the choices and trailing chrome; anything over the budget - is truncated with a marker. + Layout priority: choices + the Other option must always render even for a very long + question; the question is budgeted to the rows left over and truncated with a marker. """ from cli import _append_blank_panel_line, _append_panel_line, _panel_box_width, _wrap_panel_text state = self._clarify_state @@ -631,229 +488,130 @@ class CLITuiMixin: return [] if state.get("questions"): return self._get_clarify_batch_display_fragments(state) - + wrap = _wrap_panel_text question = state["question"] choices = state.get("choices") or [] selected = state.get("selected", 0) - # multi-select support multi_select = state.get("multi_select", False) selected_indices = state.get("selected_indices", set()) if multi_select else set() - preview_lines = _wrap_panel_text(question, 60) - for i, choice in enumerate(choices): - # Show number prefix for quick selection (1-9 for items 1-9, 0 for 10th item) - if i < 9: - num_prefix = str(i + 1) - elif i == 9: - num_prefix = '0' - else: - num_prefix = ' ' - if multi_select: - cb = "[x]" if i in selected_indices else "[ ]" - if i == selected and not self._clarify_freetext: - prefix = f"❯ {cb} {num_prefix}. " - else: - prefix = f" {cb} {num_prefix}. " - elif i == selected and not self._clarify_freetext: - prefix = f"❯ {num_prefix}. " - else: - prefix = f" {num_prefix}. " - preview_lines.extend(_wrap_panel_text(f"{prefix}{choice}", 60, subsequent_indent=" ")) - # "Other" option in preview - other_num = len(choices) + 1 - if other_num < 10: - other_num_prefix = str(other_num) - elif other_num == 10: - other_num_prefix = '0' - else: - other_num_prefix = ' ' - other_idx_val = len(choices) - if multi_select: - cb = "[x]" if other_idx_val in selected_indices else "[ ]" - other_label = ( - f"❯ {cb} {other_num_prefix}. Other (type below)" if self._clarify_freetext - else f"❯ {cb} {other_num_prefix}. Other (type your answer)" if selected == other_idx_val - else f" {cb} {other_num_prefix}. Other (type your answer)" - ) - else: - other_label = ( - f"❯ {other_num_prefix}. Other (type below)" if self._clarify_freetext - else f"❯ {other_num_prefix}. Other (type your answer)" if selected == len(choices) - else f" {other_num_prefix}. Other (type your answer)" - ) - preview_lines.extend(_wrap_panel_text(other_label, 60, subsequent_indent=" ")) - box_width = _panel_box_width("Hermes needs your input", preview_lines) + freetext = self._clarify_freetext + title = "Hermes needs your input" + other_idx = len(choices) + + def _label(i, text): + cursor = "❯" if (i == selected and not freetext) or (freetext and i == other_idx) else " " + cb = ("[x] " if i in selected_indices else "[ ] ") if multi_select else "" + return f"{cursor} {cb}{_num_prefix(i)}. {text}" + + choice_labels = [_label(i, c) for i, c in enumerate(choices)] + other_label = _label(other_idx, "Other (type below)" if freetext else "Other (type your answer)") + + preview_lines = wrap(question, 60) + preview_lines.extend(w for _i, w in _wrap_rows(wrap, choice_labels + [other_label], 60, " ")) + box_width = _panel_box_width(title, preview_lines) inner_text_width = max(8, box_width - 2) - # Pre-wrap choices + Other option — these are mandatory. - choice_wrapped: list[tuple[int, str]] = [] + # Mandatory rows: choices + Other (or the freetext guidance line when there are no choices). + choice_wrapped = _wrap_rows(wrap, choice_labels, inner_text_width, " ") if choices: - for i, choice in enumerate(choices): - # Show number prefix for quick selection (1-9 for items 1-9, 0 for 10th item) - if i < 9: - num_prefix = str(i + 1) - elif i == 9: - num_prefix = '0' - else: - num_prefix = ' ' - # multi-select support: add checkbox after cursor indicator - if multi_select: - cb = "[x]" if i in selected_indices else "[ ]" - if i == selected and not self._clarify_freetext: - prefix = f'❯ {cb} {num_prefix}. ' - else: - prefix = f' {cb} {num_prefix}. ' - elif i == selected and not self._clarify_freetext: - prefix = f'❯ {num_prefix}. ' - else: - prefix = f' {num_prefix}. ' - for wrapped in _wrap_panel_text(f"{prefix}{choice}", inner_text_width, subsequent_indent=" "): - choice_wrapped.append((i, wrapped)) - # Trailing Other row(s) - other_idx = len(choices) - other_num = other_idx + 1 - if other_num < 10: - other_num_prefix = str(other_num) - elif other_num == 10: - other_num_prefix = '0' - else: - other_num_prefix = ' ' - # multi-select support: add checkbox to Other option - if multi_select: - cb = "[x]" if other_idx in selected_indices else "[ ]" - if selected == other_idx and not self._clarify_freetext: - other_label_mand = f'❯ {cb} {other_num_prefix}. Other (type your answer)' - elif self._clarify_freetext: - other_label_mand = f'❯ {cb} {other_num_prefix}. Other (type below)' - else: - other_label_mand = f' {cb} {other_num_prefix}. Other (type your answer)' - else: - if selected == other_idx and not self._clarify_freetext: - other_label_mand = f'❯ {other_num_prefix}. Other (type your answer)' - elif self._clarify_freetext: - other_label_mand = f'❯ {other_num_prefix}. Other (type below)' - else: - other_label_mand = f' {other_num_prefix}. Other (type your answer)' - other_wrapped = _wrap_panel_text(other_label_mand, inner_text_width, subsequent_indent=" ") - elif self._clarify_freetext: - # Freetext-only mode: the guidance line takes the place of choices. - other_wrapped = _wrap_panel_text( - "Type your answer in the prompt below, then press Enter.", - inner_text_width, - ) + other_wrapped = wrap(other_label, inner_text_width, subsequent_indent=" ") + elif freetext: + other_wrapped = wrap("Type your answer in the prompt below, then press Enter.", inner_text_width) else: other_wrapped = [] - # Budget the question so mandatory rows always render. - # Chrome layouts: - # full : top border + blank_after_title + blank_after_question - # + blank_before_bottom + bottom border = 5 rows - # tight: top border + bottom border = 2 rows (drop all blanks) - # - # reserved_below matches the approval-panel budget (~6 rows for - # spinner/tool-progress + status + input + separators + prompt). - term_rows = shutil.get_terminal_size((100, 24)).lines - chrome_full = 5 - chrome_tight = 2 - reserved_below = 6 - - available = max(0, term_rows - reserved_below) - # The compact decision must reserve room for at least one question - # row on top of the choices, otherwise full chrome (3 blank - # separators) gets kept when there is no room for it and the panel - # overflows the viewport — HSplit then clips the panel's tail, - # silently dropping the choices (the reported bug). - mandatory_full = chrome_full + 1 + len(choice_wrapped) + len(other_wrapped) - - use_compact_chrome = mandatory_full > available - chrome_rows = chrome_tight if use_compact_chrome else chrome_full - - max_question_rows = max(1, available - chrome_rows - len(choice_wrapped) - len(other_wrapped)) - max_question_rows = min(max_question_rows, 12) # soft cap on huge terminals - - # When the choices alone (plus compact chrome) already exceed the - # viewport, drop the question entirely — the choices are the only - # thing the user must see to make a selection. Without this the - # question would still claim its 1-row floor above and push the - # tail of the choices off-screen (HSplit clips the overflow). - choices_overflow = chrome_rows + len(choice_wrapped) + len(other_wrapped) >= available - if choices_overflow: + # Row budget so the mandatory rows always render. Full chrome = top border + blank after + # title + blank after question + blank before bottom + bottom border (5); tight = the two + # borders (2). The compact decision reserves 1 question row on top of the choices — + # otherwise full chrome is kept when there is no room for it, the panel overflows and + # HSplit silently clips the choices. + available = max(0, _term_rows() - _PANEL_RESERVED_BELOW) + mandatory = len(choice_wrapped) + len(other_wrapped) + use_compact_chrome = 5 + 1 + mandatory > available + chrome_rows = 2 if use_compact_chrome else 5 + max_question_rows = min(12, max(1, available - chrome_rows - mandatory)) # soft cap on huge terminals + # When the choices alone (plus compact chrome) fill the viewport, drop the question + # entirely — the choices are all the user needs to select; the 1-row floor above would + # push the tail of the choices off-screen. + if chrome_rows + mandatory >= available: max_question_rows = 0 - - question_wrapped = _wrap_panel_text(question, inner_text_width) + question_wrapped = wrap(question, inner_text_width) if max_question_rows <= 0: question_wrapped = [] elif len(question_wrapped) > max_question_rows: - # The truncation marker is itself a row, so it must count - # against the budget. With a 1-row budget there is no room for - # both a question line and the marker — show the marker alone - # so the rendered question never exceeds max_question_rows. - keep = max(0, max_question_rows - 1) - question_wrapped = question_wrapped[:keep] + ["… (question truncated)"] + # The marker is itself a row: with a 1-row budget show the marker alone so the + # rendered question never exceeds max_question_rows. + question_wrapped = question_wrapped[:max(0, max_question_rows - 1)] + ["… (question truncated)"] lines = [] - # Box top border - lines.append(('class:clarify-border', '╭─ ')) - lines.append(('class:clarify-title', 'Hermes needs your input')) - lines.append(('class:clarify-border', ' ' + ('─' * max(0, box_width - len("Hermes needs your input") - 3)) + '╮\n')) + _panel_open(lines, 'class:clarify-border', box_width, title, 'class:clarify-title') if not use_compact_chrome: _append_blank_panel_line(lines, 'class:clarify-border', box_width) - - # Question text (bounded) for wrapped in question_wrapped: _append_panel_line(lines, 'class:clarify-border', 'class:clarify-question', wrapped, box_width) if not use_compact_chrome: _append_blank_panel_line(lines, 'class:clarify-border', box_width) - - if self._clarify_freetext and not choices: + if freetext and not choices: for wrapped in other_wrapped: _append_panel_line(lines, 'class:clarify-border', 'class:clarify-choice', wrapped, box_width) if not use_compact_chrome: _append_blank_panel_line(lines, 'class:clarify-border', box_width) - if choices: - # Multiple-choice mode: show selectable options for i, wrapped in choice_wrapped: - style = 'class:clarify-selected' if i == selected and not self._clarify_freetext else 'class:clarify-choice' + style = 'class:clarify-selected' if i == selected and not freetext else 'class:clarify-choice' _append_panel_line(lines, 'class:clarify-border', style, wrapped, box_width) - - # "Other" option (trailing row(s), only shown when choices exist) - other_idx = len(choices) - # Calculate number prefix for "Other" option - other_num = other_idx + 1 - if other_num < 10: - other_num_prefix = str(other_num) - elif other_num == 10: - other_num_prefix = '0' - else: - other_num_prefix = ' ' - - if selected == other_idx and not self._clarify_freetext: + if selected == other_idx and not freetext: other_style = 'class:clarify-selected' - elif self._clarify_freetext: + elif freetext: other_style = 'class:clarify-active-other' else: other_style = 'class:clarify-choice' for wrapped in other_wrapped: _append_panel_line(lines, 'class:clarify-border', other_style, wrapped, box_width) - if not use_compact_chrome: _append_blank_panel_line(lines, 'class:clarify-border', box_width) - lines.append(('class:clarify-border', '╰' + ('─' * box_width) + '╯\n')) + _panel_close(lines, 'class:clarify-border', box_width) + return lines + + def _render_scroll_list_panel(self, state, title, hint, labels, *, min_width, max_width, indent): + """Titled panel with a hint row and a scrolling selectable list (model picker, palette). + + The panel renders into a Window with no max height, so the visible slice is limited to + the terminal rows or the bottom border and trailing items get clipped on long lists + (e.g. Ollama Cloud's 36+ models). ``state["_scroll_offset"]`` is updated in place. + """ + from cli import HermesCLI, _append_blank_panel_line, _append_panel_line, _panel_box_width, _wrap_panel_text + box_width = _panel_box_width(title, [hint] + labels, min_width=min_width, max_width=max_width) + inner_text_width = max(8, box_width - 6) + selected = state.get("selected", 0) + try: + from prompt_toolkit.application import get_app + term_rows = get_app().output.get_size().rows + except Exception: + term_rows = _term_rows() + scroll_offset, visible = HermesCLI._compute_model_picker_viewport( + selected, state.get("_scroll_offset", 0), len(labels), term_rows) + state["_scroll_offset"] = scroll_offset + + lines = [] + _panel_open(lines, 'class:clarify-border', box_width, title, 'class:clarify-title') + _append_blank_panel_line(lines, 'class:clarify-border', box_width) + _append_panel_line(lines, 'class:clarify-border', 'class:clarify-hint', hint, box_width) + _append_blank_panel_line(lines, 'class:clarify-border', box_width) + for idx in range(scroll_offset, min(scroll_offset + visible, len(labels))): + style = 'class:clarify-selected' if idx == selected else 'class:clarify-choice' + prefix = '❯ ' if idx == selected else ' ' + for wrapped in _wrap_panel_text(prefix + labels[idx], inner_text_width, subsequent_indent=indent): + _append_panel_line(lines, 'class:clarify-border', style, wrapped, box_width) + _append_blank_panel_line(lines, 'class:clarify-border', box_width) + _panel_close(lines, 'class:clarify-border', box_width) return lines def _get_model_picker_display_fragments(self): - from cli import ( - HermesCLI, - _append_blank_panel_line, - _append_panel_line, - _panel_box_width, - _wrap_panel_text, - ) state = self._model_picker_state if not state: return [] - stage = state.get("stage", "provider") - if stage == "provider": + if state.get("stage", "provider") == "provider": title = "⚙ Model Picker — Select Provider" choices = [] _providers = state.get("providers") @@ -869,10 +627,8 @@ class CLITuiMixin: provider_data = state.get("provider_data") or {} model_list = state.get("model_list") or [] title = f"⚙ Model Picker — {provider_data.get('name', provider_data.get('slug', 'Provider'))}" - # Fuzzy filter: narrow the concrete model list by the typed - # query. Selection still resolves to a real entry (see the - # filtered_pairs index mapping in the selection handler), so - # this never introduces an ambiguous model resolution. + # Fuzzy filter narrows the concrete list; selection still resolves to a real entry via + # the filtered_pairs index mapping, so this never makes model resolution ambiguous. _query = state.get("filter", "") or "" filtered_pairs = self._filter_model_picker_entries(model_list, _query) state["_filtered_pairs"] = filtered_pairs @@ -881,56 +637,14 @@ class CLITuiMixin: if _query: hint = ( f"Filter: {_query}▏ ({len(model_labels)}/{len(model_list)} match " - "— type to narrow, Backspace to clear)" - ) + "— type to narrow, Backspace to clear)") elif model_list: hint = f"Select a model ({len(model_list)} available) — type to filter" else: hint = "No models listed for this provider. Use Back or Cancel." - - box_width = _panel_box_width(title, [hint] + choices, min_width=46, max_width=84) - inner_text_width = max(8, box_width - 6) - selected = state.get("selected", 0) - - # Scrolling viewport: the panel renders into a Window with no max - # height, so without limiting visible items the bottom border and - # any items past the available terminal rows get clipped on long - # provider catalogs (e.g. Ollama Cloud's 36+ models). - try: - from prompt_toolkit.application import get_app - term_rows = get_app().output.get_size().rows - except Exception: - term_rows = shutil.get_terminal_size((100, 24)).lines - scroll_offset, visible = HermesCLI._compute_model_picker_viewport( - selected, state.get("_scroll_offset", 0), len(choices), term_rows, - ) - state["_scroll_offset"] = scroll_offset - - lines = [] - lines.append(('class:clarify-border', '╭─ ')) - lines.append(('class:clarify-title', title)) - lines.append(('class:clarify-border', ' ' + ('─' * max(0, box_width - len(title) - 3)) + '╮\n')) - _append_blank_panel_line(lines, 'class:clarify-border', box_width) - _append_panel_line(lines, 'class:clarify-border', 'class:clarify-hint', hint, box_width) - _append_blank_panel_line(lines, 'class:clarify-border', box_width) - for idx in range(scroll_offset, scroll_offset + visible): - choice = choices[idx] - style = 'class:clarify-selected' if idx == selected else 'class:clarify-choice' - prefix = '❯ ' if idx == selected else ' ' - for wrapped in _wrap_panel_text(prefix + choice, inner_text_width, subsequent_indent=' '): - _append_panel_line(lines, 'class:clarify-border', style, wrapped, box_width) - _append_blank_panel_line(lines, 'class:clarify-border', box_width) - lines.append(('class:clarify-border', '╰' + ('─' * box_width) + '╯\n')) - return lines + return self._render_scroll_list_panel(state, title, hint, choices, min_width=46, max_width=84, indent=' ') def _get_command_palette_display_fragments(self): - from cli import ( - HermesCLI, - _append_blank_panel_line, - _append_panel_line, - _panel_box_width, - _wrap_panel_text, - ) state = self._command_palette_state if not state: return [] @@ -938,156 +652,81 @@ class CLITuiMixin: state["_visible_count"] = len(rows) _query = state.get("filter", "") or "" total = len(state.get("entries") or []) - title = "⚙ Command Palette" if _query: hint = f"Filter: {_query}▏ ({len(rows)}/{total} match — Enter inserts, Esc cancels)" else: hint = f"Type to filter {total} commands — ↑/↓ then Enter inserts, Esc cancels" + labels = [f"{c} — {d}" if d else c for (c, _cat, d) in rows] or ["(no matching commands)"] + return self._render_scroll_list_panel( + state, "⚙ Command Palette", hint, labels, min_width=50, max_width=90, indent=' ') - labels = [f"{c} — {d}" if d else c for (c, _cat, d) in rows] - if not labels: - labels = ["(no matching commands)"] - box_width = _panel_box_width(title, [hint] + labels, min_width=50, max_width=90) - inner_text_width = max(8, box_width - 6) - selected = state.get("selected", 0) - try: - from prompt_toolkit.application import get_app - term_rows = get_app().output.get_size().rows - except Exception: - term_rows = shutil.get_terminal_size((100, 24)).lines - scroll_offset, visible = HermesCLI._compute_model_picker_viewport( - selected, state.get("_scroll_offset", 0), len(labels), term_rows, - ) - state["_scroll_offset"] = scroll_offset - + def _render_sudo_style_panel(self, title: str, body_lines: list[str]): + """Bordered ``sudo-*`` panel: blank, each body line, blank, body-final line, blank.""" + from cli import _append_blank_panel_line, _append_panel_line, _panel_box_width + box_width = _panel_box_width(title, body_lines) lines = [] - lines.append(('class:clarify-border', '╭─ ')) - lines.append(('class:clarify-title', title)) - lines.append(('class:clarify-border', ' ' + ('─' * max(0, box_width - len(title) - 3)) + '╮\n')) - _append_blank_panel_line(lines, 'class:clarify-border', box_width) - _append_panel_line(lines, 'class:clarify-border', 'class:clarify-hint', hint, box_width) - _append_blank_panel_line(lines, 'class:clarify-border', box_width) - for idx in range(scroll_offset, min(scroll_offset + visible, len(labels))): - label = labels[idx] - style = 'class:clarify-selected' if idx == selected else 'class:clarify-choice' - prefix = '❯ ' if idx == selected else ' ' - for wrapped in _wrap_panel_text(prefix + label, inner_text_width, subsequent_indent=' '): - _append_panel_line(lines, 'class:clarify-border', style, wrapped, box_width) - _append_blank_panel_line(lines, 'class:clarify-border', box_width) - lines.append(('class:clarify-border', '╰' + ('─' * box_width) + '╯\n')) + _panel_open(lines, 'class:sudo-border', box_width, title, 'class:sudo-title') + _append_blank_panel_line(lines, 'class:sudo-border', box_width) + for i, text in enumerate(body_lines): + if i == len(body_lines) - 1 and i > 0: + _append_blank_panel_line(lines, 'class:sudo-border', box_width) + _append_panel_line(lines, 'class:sudo-border', 'class:sudo-text', text, box_width) + _append_blank_panel_line(lines, 'class:sudo-border', box_width) + _panel_close(lines, 'class:sudo-border', box_width) return lines def _get_sudo_display_fragments(self): - from cli import _append_blank_panel_line, _append_panel_line, _panel_box_width - state = self._sudo_state - if not state: + if not self._sudo_state: return [] - title = '🔐 Sudo Password Required' - body = 'Enter password below (hidden), or press Enter to skip' - box_width = _panel_box_width(title, [body]) - lines = [] - lines.append(('class:sudo-border', '╭─ ')) - lines.append(('class:sudo-title', title)) - lines.append(('class:sudo-border', ' ' + ('─' * max(0, box_width - len(title) - 3)) + '╮\n')) - _append_blank_panel_line(lines, 'class:sudo-border', box_width) - _append_panel_line(lines, 'class:sudo-border', 'class:sudo-text', body, box_width) - _append_blank_panel_line(lines, 'class:sudo-border', box_width) - lines.append(('class:sudo-border', '╰' + ('─' * box_width) + '╯\n')) - return lines + return self._render_sudo_style_panel( + '🔐 Sudo Password Required', ['Enter password below (hidden), or press Enter to skip']) def _get_secret_display_fragments(self): - from cli import _append_blank_panel_line, _append_panel_line, _panel_box_width state = self._secret_state if not state: return [] - - title = '🔑 Skill Setup Required' prompt = state.get("prompt") or f"Enter value for {state.get('var_name', 'secret')}" - metadata = state.get("metadata") or {} - help_text = metadata.get("help") - body = 'Enter secret below (hidden), ESC or Ctrl+C to skip' - content_lines = [prompt, body] + help_text = (state.get("metadata") or {}).get("help") + content_lines = [prompt, 'Enter secret below (hidden), ESC or Ctrl+C to skip'] if help_text: content_lines.insert(1, str(help_text)) - box_width = _panel_box_width(title, content_lines) - lines = [] - lines.append(('class:sudo-border', '╭─ ')) - lines.append(('class:sudo-title', title)) - lines.append(('class:sudo-border', ' ' + ('─' * max(0, box_width - len(title) - 3)) + '╮\n')) - _append_blank_panel_line(lines, 'class:sudo-border', box_width) - _append_panel_line(lines, 'class:sudo-border', 'class:sudo-text', prompt, box_width) - if help_text: - _append_panel_line(lines, 'class:sudo-border', 'class:sudo-text', str(help_text), box_width) - _append_blank_panel_line(lines, 'class:sudo-border', box_width) - _append_panel_line(lines, 'class:sudo-border', 'class:sudo-text', body, box_width) - _append_blank_panel_line(lines, 'class:sudo-border', box_width) - lines.append(('class:sudo-border', '╰' + ('─' * box_width) + '╯\n')) - return lines + return self._render_sudo_style_panel('🔑 Skill Setup Required', content_lines) + + # (state attr, deadline attr, hint) for the modal prompts with a countdown hint row. + _TUI_MODAL_HINTS = ( + ("_sudo_state", "_sudo_deadline", ' password hidden · Enter to skip'), + ("_secret_state", "_secret_deadline", ' secret hidden · Enter to skip'), + ("_approval_state", "_approval_deadline", ' ↑/↓ to select, Enter to confirm'), + ("_slash_confirm_state", "_slash_confirm_deadline", ' type 1/2/3, or ↑/↓ to select, Enter to confirm'), + ) def _tui_hint_text(self): - if self._sudo_state: - remaining = max(0, int(self._sudo_deadline - time.monotonic())) - return [ - ('class:hint', ' password hidden · Enter to skip'), - ('class:clarify-countdown', f' ({remaining}s)'), - ] - - if self._secret_state: - remaining = max(0, int(self._secret_deadline - time.monotonic())) - return [ - ('class:hint', ' secret hidden · Enter to skip'), - ('class:clarify-countdown', f' ({remaining}s)'), - ] - - if self._approval_state: - remaining = max(0, int(self._approval_deadline - time.monotonic())) - return [ - ('class:hint', ' ↑/↓ to select, Enter to confirm'), - ('class:clarify-countdown', f' ({remaining}s)'), - ] - - if self._slash_confirm_state: - remaining = max(0, int(self._slash_confirm_deadline - time.monotonic())) - return [ - ('class:hint', ' type 1/2/3, or ↑/↓ to select, Enter to confirm'), - ('class:clarify-countdown', f' ({remaining}s)'), - ] - + for state_attr, deadline_attr, hint in self._TUI_MODAL_HINTS: + if getattr(self, state_attr): + remaining = max(0, int(getattr(self, deadline_attr) - time.monotonic())) + return [('class:hint', hint), ('class:clarify-countdown', f' ({remaining}s)')] if self._clarify_state: # None deadline = unlimited wait → hide the countdown entirely. if self._clarify_deadline is None: countdown = '' else: - remaining = max(0, int(self._clarify_deadline - time.monotonic())) - countdown = f' ({remaining}s)' + countdown = f' ({max(0, int(self._clarify_deadline - time.monotonic()))}s)' if self._clarify_freetext: - return [ - ('class:hint', ' type your answer and press Enter'), - ('class:clarify-countdown', countdown), - ] - if self._clarify_state.get("questions"): - return [ - ('class:hint', ' ↑/↓ to select, Enter to lock, Tab next question'), - ('class:clarify-countdown', countdown), - ] - return [ - ('class:hint', ' ↑/↓ to select, Enter to confirm'), - ('class:clarify-countdown', countdown), - ] - + hint = ' type your answer and press Enter' + elif self._clarify_state.get("questions"): + hint = ' ↑/↓ to select, Enter to lock, Tab next question' + else: + hint = ' ↑/↓ to select, Enter to confirm' + return [('class:hint', hint), ('class:clarify-countdown', countdown)] if self._command_running: frame = self._command_spinner_frame() detail = "input temporarily disabled" if self._command_blocks_input else "input stays active; Enter queues" - return [ - ('class:hint', f' {frame} command in progress · {detail}'), - ] - + return [('class:hint', f' {frame} command in progress · {detail}')] return [] def _tui_placeholder_text(self): if self._voice_recording: - _label = self._voice_record_key_label() - return f"recording... {_label} to stop, Ctrl+C to cancel" + return f"recording... {self._voice_record_key_label()} to stop, Ctrl+C to cancel" if self._voice_processing: return "transcribing..." if self._sudo_state: @@ -1103,310 +742,205 @@ class CLITuiMixin: if self._clarify_state: return "" if self._command_running: - frame = self._command_spinner_frame() - status = self._command_status or "Processing command..." - return f"{frame} {status}" + return f"{self._command_spinner_frame()} {self._command_status or 'Processing command...'}" if self._agent_running: return "msg=interrupt · /queue · /bg · /steer · Ctrl+C cancel" if self._voice_mode: - _label = self._voice_record_key_label() - return f"type or {_label} to record" - # Advertise a parked draft so the stash can never be silently - # forgotten — the composer itself tells you how to get it back. - _stash_hint = "" + return f"type or {self._voice_record_key_label()} to record" + # Advertise a parked draft so the stash can never be silently forgotten. try: _stash_hint = self._prompt_stash.placeholder_hint() except Exception: _stash_hint = "" if _stash_hint: return _stash_hint - # Idle + empty composer: show a rotating task-oriented example to - # nudge the user toward a high-value first action (C-09). Chosen - # once per session (self._composer_placeholder) so it stays stable - # while being read, not flickering every render. + # Idle + empty composer: a task-oriented example chosen once per session + # (self._composer_placeholder) so it stays stable while being read, not flickering. return getattr(self, "_composer_placeholder", "") or "" def _get_stash_panel_display_fragments(self): try: _stash = self._prompt_stash - return self._render_stash_panel( - _stash.panel_rows(), - _stash.panel_cursor, - self._get_tui_terminal_width(), - ) + return self._render_stash_panel(_stash.panel_rows(), _stash.panel_cursor, self._get_tui_terminal_width()) except Exception: return [] def _tui_handle_voice_record(self, event): """Toggle voice recording when voice mode is active. - IMPORTANT: This handler runs in prompt_toolkit's event-loop thread. - Any blocking call here (locks, sd.wait, disk I/O) freezes the - entire UI. All heavy work is dispatched to daemon threads. + Runs on prompt_toolkit's event-loop thread: any blocking call here (locks, sd.wait, + disk I/O) freezes the whole UI, so all heavy work goes to daemon threads. """ from cli import _DIM, _RST, _cprint, logger if not self._voice_mode: return - # Always allow STOPPING a recording (even when agent is running) if self._voice_recording: - # Manual stop via push-to-talk key: stop continuous mode + # Always allow STOPPING (even while the agent runs); manual stop ends continuous + # mode. Flag clearing happens atomically inside _voice_stop_and_transcribe. with self._voice_lock: self._voice_continuous = False - # Flag clearing is handled atomically inside _voice_stop_and_transcribe event.app.invalidate() - threading.Thread( - target=self._voice_stop_and_transcribe, - daemon=True, - ).start() - else: - # Allow disarming continuous mode even when the agent is - # running or transcribing — otherwise the user is stuck in - # an auto-restart loop until /voice off (#67545). - if self._agent_running or self._voice_processing: - with self._voice_lock: - self._voice_continuous = False - event.app.invalidate() - return - # Guard: don't START recording during interactive prompts - if self._clarify_state or self._sudo_state or self._approval_state or self._slash_confirm_state: - return - - # Interrupt TTS if playing, so user can start talking. - # stop_playback() is fast (just terminates a subprocess); - # the stop event drains the streaming pipeline if one is live. - if not self._voice_tts_done.is_set(): - try: - logger.info("TTS CUT: record key handler cutting TTS") - from tools.tts_streaming import mark_speech_interrupted - mark_speech_interrupted() - if self._voice_tts_stop is not None: - self._voice_tts_stop.set() - from tools.voice_mode import stop_playback - stop_playback() - self._voice_tts_done.set() - except Exception: - pass - + threading.Thread(target=self._voice_stop_and_transcribe, daemon=True).start() + return + # Allow disarming continuous mode while the agent runs or transcribes — otherwise the + # user is stuck in an auto-restart loop until /voice off. + if self._agent_running or self._voice_processing: with self._voice_lock: - self._voice_continuous = True - - # Dispatch to a daemon thread so play_beep(sd.wait), - # AudioRecorder.start(lock acquire), and config I/O - # never block the prompt_toolkit event loop. - def _start_recording(): - try: - self._voice_start_recording() - if hasattr(self, '_app') and self._app: - self._app.invalidate() - except Exception as e: - _cprint(f"\n{_DIM}Voice recording failed: {e}{_RST}") - - threading.Thread(target=_start_recording, daemon=True).start() + self._voice_continuous = False event.app.invalidate() + return + # Don't START recording during interactive prompts. + if self._clarify_state or self._sudo_state or self._approval_state or self._slash_confirm_state: + return + # Cut TTS so the user can start talking: stop_playback() just terminates a subprocess; + # the stop event drains the streaming pipeline if one is live. + if not self._voice_tts_done.is_set(): + try: + logger.info("TTS CUT: record key handler cutting TTS") + from tools.tts_streaming import mark_speech_interrupted + mark_speech_interrupted() + if self._voice_tts_stop is not None: + self._voice_tts_stop.set() + from tools.voice_mode import stop_playback + stop_playback() + self._voice_tts_done.set() + except Exception: + pass + with self._voice_lock: + self._voice_continuous = True - def _tui_handle_ctrl_c(self, event): - """Handle Ctrl+C - cancel interactive prompts, interrupt agent, or exit. + # play_beep(sd.wait), AudioRecorder.start(lock) and config I/O must never block the loop. + def _start_recording(): + try: + self._voice_start_recording() + if hasattr(self, '_app') and self._app: + self._app.invalidate() + except Exception as e: + _cprint(f"\n{_DIM}Voice recording failed: {e}{_RST}") - Priority: - 0. Cancel active voice recording - 1. Cancel active sudo/approval/clarify prompt - 2. Interrupt the running agent (first press) - 3. Force exit (second press within 2s, or when idle) - """ + threading.Thread(target=_start_recording, daemon=True).start() + event.app.invalidate() + + def _tui_cancel_voice_recording(self, event) -> bool: + """Cancel an active recording; True when one was cancelled (caller stops there).""" from cli import _DIM, _RST, _cprint - now = time.time() - - # Cancel active voice recording. - # Run cancel() in a background thread to prevent blocking the - # event loop if AudioRecorder._lock or CoreAudio takes time. - _should_cancel_voice = False _recorder_ref = None with self._voice_lock: if self._voice_recording and self._voice_recorder: _recorder_ref = self._voice_recorder self._voice_recording = False self._voice_continuous = False - _should_cancel_voice = True - if _should_cancel_voice: - _cprint(f"\n{_DIM}Recording cancelled.{_RST}") - threading.Thread( - target=_recorder_ref.cancel, daemon=True - ).start() - event.app.invalidate() - return + if _recorder_ref is None: + return False + _cprint(f"\n{_DIM}Recording cancelled.{_RST}") + # cancel() may block on AudioRecorder._lock / CoreAudio — keep it off the event loop. + threading.Thread(target=_recorder_ref.cancel, daemon=True).start() + event.app.invalidate() + return True - # Cancel slash confirmation prompt (foreground UI, not an - # agent-blocking overlay — cancel and stop here). - if self._slash_confirm_state: - self._submit_slash_confirm_response("cancel") + def _tui_cancel_foreground_ui(self, event, *, closers) -> bool: + """Close the first active foreground UI (slash-confirm / picker / palette); True if any.""" + for state_attr, close in closers: + if getattr(self, state_attr): + close() + event.app.current_buffer.reset() + event.app.invalidate() + return True + return False + + def _tui_clear_blocking_overlays(self, event) -> bool: + """Clear every agent-blocking overlay (approval/clarify/sudo/secret) in one shot. + + Callers must NOT return on True alone: they fall through so a stale/orphaned overlay + (left by an earlier interrupt) can't swallow the press before the agent-interrupt + branch, leaving the chat frozen (#14026). + """ + if not (self._sudo_state or self._secret_state or self._approval_state or self._clarify_state): + return False + self._clear_active_overlays_for_interrupt() + event.app.current_buffer.reset() + event.app.invalidate() + return True + + def _tui_clear_or_exit(self, event) -> None: + """Idle press: clear text/images like bash; exit when everything is already empty.""" + if event.app.current_buffer.text or self._attached_images: event.app.current_buffer.reset() + self._attached_images.clear() event.app.invalidate() + else: + self._should_exit = True + event.app.exit() + + def _tui_handle_ctrl_c(self, event): + """Ctrl+C priority: cancel voice recording → cancel foreground UI/overlay prompt → + interrupt the running agent (first press) → force exit (second press within 2s) → when + idle clear the draft or exit.""" + now = time.time() + if self._tui_cancel_voice_recording(event): return - - # Cancel /model picker (foreground UI — cancel and stop here). - if self._model_picker_state: - self._close_model_picker() - event.app.current_buffer.reset() - event.app.invalidate() + if self._tui_cancel_foreground_ui(event, closers=( + ("_slash_confirm_state", lambda: self._submit_slash_confirm_response("cancel")), + ("_model_picker_state", self._close_model_picker), + ("_command_palette_state", self._close_command_palette))): return - - # Cancel command palette (foreground UI — cancel and stop here). - if self._command_palette_state: - self._close_command_palette() - event.app.current_buffer.reset() - event.app.invalidate() + overlay_cleared = self._tui_clear_blocking_overlays(event) + if overlay_cleared and not (self._agent_running and self.agent): return - - # Clear all agent-blocking overlays (approval/clarify/sudo/secret) - # in one shot. We do NOT return after clearing — we fall through so - # that if the agent is also running we fire the interrupt on the same - # Ctrl+C press. This fixes the case where a stale/orphaned overlay - # (left behind by a previous interrupt) consumes the press without - # ever reaching the agent-interrupt branch, leaving the chat frozen - # (#14026). - _overlay_cleared = bool( - self._sudo_state - or self._secret_state - or self._approval_state - or self._clarify_state - ) - if _overlay_cleared: - self._clear_active_overlays_for_interrupt() - event.app.current_buffer.reset() - event.app.invalidate() - - # If we only cleared overlays and the agent is NOT running, stop here - # (don't fall through to the interrupt/exit path). - if _overlay_cleared and not (self._agent_running and self.agent): - return - if self._agent_running and self.agent: if now - self._last_ctrl_c_time < 2.0: print("\n⚡ Force exiting...") self._should_exit = True event.app.exit() return - self._last_ctrl_c_time = now print("\n⚡ Interrupting agent... (press Ctrl+C again to force exit)") request_hard_interrupt(self.agent) - # If there's text or images, clear them (like bash). - # If everything is already empty, exit. - elif event.app.current_buffer.text or self._attached_images: - event.app.current_buffer.reset() - self._attached_images.clear() - event.app.invalidate() else: - self._should_exit = True - event.app.exit() + self._tui_clear_or_exit(event) def _tui_handle_ctrl_q(self, event): - """Alternative interrupt/exit shortcut (Ctrl+Q). - - Behaves like Ctrl+C: cancels active prompts, interrupts the - running agent, or clears the input buffer. Does not support - the double-press 'force exit' feature of Ctrl+C. - """ - from cli import _DIM, _RST, _cprint - # Cancel active voice recording. - _should_cancel_voice = False - _recorder_ref = None - with self._voice_lock: - if self._voice_recording and self._voice_recorder: - _recorder_ref = self._voice_recorder - self._voice_recording = False - self._voice_continuous = False - _should_cancel_voice = True - if _should_cancel_voice: - _cprint(f"\n{_DIM}Recording cancelled.{_RST}") - threading.Thread( - target=_recorder_ref.cancel, daemon=True - ).start() - event.app.invalidate() + """Ctrl+Q: like Ctrl+C minus the double-press force exit (and it leaves the palette).""" + if self._tui_cancel_voice_recording(event): return - - # Cancel slash confirmation prompt (foreground UI — cancel and stop). - if self._slash_confirm_state: - self._submit_slash_confirm_response("cancel") - event.app.current_buffer.reset() - event.app.invalidate() + if self._tui_cancel_foreground_ui(event, closers=( + ("_slash_confirm_state", lambda: self._submit_slash_confirm_response("cancel")), + ("_model_picker_state", self._close_model_picker))): return - - # Cancel /model picker (foreground UI — cancel and stop). - if self._model_picker_state: - self._close_model_picker() - event.app.current_buffer.reset() - event.app.invalidate() + overlay_cleared = self._tui_clear_blocking_overlays(event) + if overlay_cleared and not (self._agent_running and self.agent): return - - # Clear all agent-blocking overlays in one shot, then fall through to - # the agent-interrupt branch so a single Ctrl+Q both clears a stale - # overlay and interrupts a still-running agent (#14026). - _overlay_cleared = bool( - self._sudo_state - or self._secret_state - or self._approval_state - or self._clarify_state - ) - if _overlay_cleared: - self._clear_active_overlays_for_interrupt() - event.app.current_buffer.reset() - event.app.invalidate() - - if _overlay_cleared and not (self._agent_running and self.agent): - return - if self._agent_running and self.agent: print("\n⚡ Interrupting agent...") request_hard_interrupt(self.agent) - elif event.app.current_buffer.text or self._attached_images: - event.app.current_buffer.reset() - self._attached_images.clear() - event.app.invalidate() else: - self._should_exit = True - event.app.exit() + self._tui_clear_or_exit(event) def _tui_make_clarify_number_handler(self, idx): def handler(event): - if self._clarify_state and not self._clarify_freetext: - choices = self._clarify_state.get("choices") or [] - # multi-select support: number keys toggle checkboxes instead of submitting - if self._clarify_state.get("multi_select"): - if idx < len(choices): - indices = self._clarify_state.get("selected_indices", set()) - if idx in indices: - indices.discard(idx) - else: - indices.add(idx) - event.app.invalidate() - elif idx == len(choices): - # Toggle "Other" in multi-select mode - indices = self._clarify_state.get("selected_indices", set()) - if idx in indices: - indices.discard(idx) - else: - indices.add(idx) - event.app.invalidate() - return - # Original single-select: number keys submit directly - # Map index to choice (treating "Other" as the last option) - if idx < len(choices): - # Batch mode: lock the numbered choice for the active - # question instead of resolving the whole prompt. - if self._clarify_state.get("questions"): - self._clarify_batch_lock(self._clarify_state, choices[idx]) - event.app.invalidate() - return - # Select a numbered choice - self._clarify_state["response_queue"].put(choices[idx]) - self._clarify_state = None - self._clarify_freetext = False - event.app.invalidate() - elif idx == len(choices): - # Select "Other" option - self._clarify_freetext = True - event.app.invalidate() + state = self._clarify_state + if not state or self._clarify_freetext: + return + choices = state.get("choices") or [] + if idx > len(choices): + return + # Multi-select: number keys toggle checkboxes (incl. "Other") instead of submitting. + if state.get("multi_select"): + indices = state.get("selected_indices", set()) + indices.symmetric_difference_update({idx}) + event.app.invalidate() + return + if idx == len(choices): + # "Other" → freetext + self._clarify_freetext = True + elif state.get("questions"): + # Batch mode: lock the numbered choice for the active question only. + self._clarify_batch_lock(state, choices[idx]) + else: + state["response_queue"].put(choices[idx]) + self._clarify_state = None + self._clarify_freetext = False + event.app.invalidate() return handler def _tui_restore_stash_payload(self, event, payload) -> None: @@ -1417,14 +951,11 @@ class CLITuiMixin: buf = event.app.current_buffer buf.text = text buf.cursor_position = len(text) - if images: - # Restore attachments the draft was carrying. Extend rather - # than replace: the user may have attached something new since - # the stash was taken and silently dropping it would be data - # loss. - for img in images: - if img not in self._attached_images: - self._attached_images.append(img) + # Extend rather than replace attachments: the user may have attached something new since + # the stash was taken and dropping it silently would be data loss. + for img in images or (): + if img not in self._attached_images: + self._attached_images.append(img) def _tui_handle_stash_panel_up(self, event): self._prompt_stash.move_cursor(-1) @@ -1444,21 +975,13 @@ class CLITuiMixin: event.app.invalidate() def _tui_handle_tab(self, event): - """Tab: accept completion, auto-suggestion, or start completions. + """Tab: accept the open completion, else the ghost auto-suggestion, else start completions. - Priority: - 1. Completion menu open → accept selected completion - 2. Ghost text suggestion available → accept auto-suggestion - 3. Otherwise → start completion menu - - After accepting a provider like 'anthropic:', the completion menu - closes and complete_while_typing doesn't fire (no keystroke). - This binding re-triggers completions so stage-2 models appear - immediately. + After accepting a provider like 'anthropic:' the menu closes and complete_while_typing + doesn't fire (no keystroke); re-triggering here makes stage-2 models appear immediately. """ buf = event.current_buffer if buf.complete_state: - # Completion menu is open — accept the selection completion = buf.complete_state.current_completion if completion is None: # Menu open but nothing selected — select first then grab it @@ -1466,32 +989,20 @@ class CLITuiMixin: completion = buf.complete_state and buf.complete_state.current_completion if completion is None: return - # Accept the selected completion buf.apply_completion(completion) elif buf.suggestion and buf.suggestion.text: - # No completion menu, but there's a ghost text auto-suggestion — accept it buf.insert_text(buf.suggestion.text) else: - # No menu and no suggestion — start completions from scratch buf.start_completion() def _tui_handle_double_escape(self, event): - """Double ESC: discard the current draft and any attached images. + """Double ESC discards the draft and attached images (Claude Code / Gemini CLI gesture). - Matches Claude Code / Gemini CLI, where double-Esc is the - clear-the-composer gesture. It works while the agent is - streaming, which is the gap Ctrl+C leaves: Ctrl+C interrupts a - running turn and only clears the draft when idle, so mid-stream - there was no way to discard a half-typed prompt. - - The draft is appended to history first, so Up recalls it — the - same undo affordance Claude Code provides, and the reason this - is safe to bind to a key pressed by reflex. - - Single ESC is the prefix for Alt sequences (escape+enter, - escape+g, escape+v), so prompt_toolkit's escape-timeout keeps - those distinct from the double press. Modal prompts bind ESC - eagerly and are excluded here so cancel still wins. + Works while the agent streams — the gap Ctrl+C leaves (it interrupts the turn and only + clears the draft when idle). The draft is appended to history first so Up recalls it, + which is what makes a reflex key safe. Single ESC is the Alt-sequence prefix + (escape+enter/g/v) so the escape-timeout keeps those distinct; modal prompts bind ESC + eagerly and are excluded so cancel still wins. """ buf = event.app.current_buffer if not (buf.text or self._attached_images): @@ -1503,17 +1014,11 @@ class CLITuiMixin: def _tui_handle_ignored_terminal_sequence(self, event): """Consume parser-level ignored terminal sequences before self-insert. - install_ignored_terminal_sequences() in hermes_cli.pt_input_extras - registers focus reports (CSI I / CSI O) as Keys.Ignore at the - VT100 parser level. Without this no-op binding the default - self-insert path would still fire and the bytes would land in - the buffer. - - Focus-in (CSI I) additionally schedules a rate-limited full - repaint: while the tab/window was hidden the emulator may have - coalesced output or repainted the surface, so prompt_toolkit's - incremental diff would stack a fresh copy of the prompt chrome - on top of the stale one (#60920 focus-regain variant, #25337). + hermes_cli.pt_input_extras registers focus reports (CSI I / CSI O) as Keys.Ignore at the + VT100 parser; without this no-op binding the default self-insert would still land the + bytes in the buffer. Focus-in additionally schedules a rate-limited full repaint: while + hidden, the emulator may have coalesced output or repainted, so prompt_toolkit's + incremental diff would stack a fresh prompt chrome on the stale one (#60920, #25337). """ try: for press in getattr(event, "key_sequence", None) or (): @@ -1525,25 +1030,22 @@ class CLITuiMixin: return None def _tui_handle_escape_modal(self, event): - """ESC cancels active secret/sudo prompts.""" + """ESC cancels active secret/sudo/slash-confirm prompts.""" if self._secret_state: self._cancel_secret_capture() event.app.current_buffer.reset() event.app.invalidate() - return - if self._sudo_state: + elif self._sudo_state: self._sudo_state["response_queue"].put("") self._sudo_state = None event.app.invalidate() - return - if self._slash_confirm_state: + elif self._slash_confirm_state: self._submit_slash_confirm_response("cancel") event.app.current_buffer.reset() event.app.invalidate() - return def _tui_handle_ctrl_z(self, event): - """Handle Ctrl+Z - suspend process to background (Unix only).""" + """Ctrl+Z suspends the process (Unix only).""" from cli import _DIM, _RST, _cprint if sys.platform == 'win32': _cprint(f"\n{_DIM}Suspend (Ctrl+Z) is not supported on Windows.{_RST}") @@ -1554,36 +1056,28 @@ class CLITuiMixin: from hermes_cli.skin_engine import get_active_skin agent_name = get_active_skin().get_branding("agent_name", "Hermes Agent") msg = f"\n{agent_name} has been suspended. Run `fg` to bring {agent_name} back." + def _suspend(): os.write(1, msg.encode()) os.kill(0, _sig.SIGTSTP) run_in_terminal(_suspend) def _tui_handle_ctrl_d(self, event): - """Ctrl+D: delete char under cursor (standard readline behaviour). - Only exit when the input is empty — same as bash/zsh. Pending - attached images count as input and block the EOF-exit so the - user doesn't lose them silently. - """ + """Ctrl+D deletes under the cursor (readline); exits only on empty input, like bash/zsh. + Pending attached images count as input so the user doesn't lose them silently.""" buf = event.app.current_buffer if buf.text: buf.delete() - elif self._attached_images: - # Empty text but pending attachments — no-op, don't exit. - return - else: + elif not self._attached_images: self._should_exit = True event.app.exit() def _tui_recall_without_recollapse(self, buf, move): - """Run a history-navigation move, suppressing paste-collapse. + """Run a history move with paste-collapse suppressed. - Recalled history can hold the full text of a paste that was - collapsed to a placeholder at submit time. Loading it back into the - buffer looks exactly like a fresh large paste to ``_on_text_changed`` - and would be re-collapsed. Set the skip flag around the move; if the - move didn't change the text (plain cursor movement), clear the flag - so a later real paste still collapses. + Recalled history can hold the full text of a paste collapsed at submit time; loading it + back looks like a fresh large paste to ``_on_text_changed``. If the move didn't change the + text (plain cursor movement) the flag is cleared so a later real paste still collapses. """ before = buf.text self._skip_paste_collapse = True @@ -1592,55 +1086,31 @@ class CLITuiMixin: self._skip_paste_collapse = False def _tui_handle_alt_v(self, event): - """Alt+V — paste image from clipboard. - - Alt key combos pass through all terminal emulators (sent as - ESC + key), unlike Ctrl+V which terminals intercept for text - paste. This is the reliable way to attach clipboard images - on WSL2, VSCode, and any terminal over SSH where Ctrl+V - can't reach the application for image-only clipboard. - """ + """Alt+V pastes an image from the clipboard. Alt combos pass through every terminal + (ESC + key), unlike Ctrl+V which terminals intercept — reliable on WSL2/VSCode/SSH. + Silent when no image (avoid noise on accidental press).""" if self._try_attach_clipboard_image(): event.app.invalidate() - else: - # No image found — show a hint - pass # silent when no image (avoid noise on accidental press) def _tui_handle_ctrl_v(self, event): - """Fallback image paste for terminals without bracketed paste. - - On Linux terminals (GNOME Terminal, Konsole, etc.), Ctrl+V - sends raw byte 0x16 instead of triggering a paste. This - binding catches that and checks the clipboard for images. - On terminals that DO intercept Ctrl+V for paste (macOS - Terminal, iTerm2, VSCode, Windows Terminal), the bracketed - paste handler fires instead and this binding never triggers. - """ + """Image paste for terminals without bracketed paste: GNOME Terminal/Konsole send raw + 0x16. Terminals that intercept Ctrl+V (macOS Terminal, iTerm2, VSCode, Windows Terminal) + fire the bracketed-paste handler instead and never reach this.""" if self._try_attach_clipboard_image(): event.app.invalidate() def _tui_handle_ctrl_l(self, event): - """Ctrl+L: force a clean full-screen repaint. - - Recovers the UI after external terminal buffer drift — tmux / - cmux tab switches, ``clear`` from a subshell, SSH window - restores, etc. — that prompt_toolkit can't detect on its own. - Matches the universal bash/zsh/fish/vim/htop convention. - """ + """Ctrl+L forces a clean repaint after terminal buffer drift (tmux/cmux tab switches, + ``clear`` from a subshell, SSH restores) that prompt_toolkit can't detect.""" self._force_full_redraw() def _tui_insert_newline(self, event): - """Insert a newline for multi-line input (Alt+Enter, and Ctrl+J/Ctrl+Enter - when multiline shortcuts are on). - - Alt+Enter works on mac/Linux/WSL. On Windows Terminal that keystroke is - intercepted at the terminal layer (toggles fullscreen) and never reaches - here — Windows users get newline via Ctrl+Enter, which WT delivers as c-j. - """ + """Newline for multi-line input (Alt+Enter; Ctrl+J/Ctrl+Enter with multiline shortcuts). + Windows Terminal intercepts Alt+Enter (fullscreen) and delivers Ctrl+Enter as c-j.""" event.current_buffer.insert_text('\n') def _tui_handle_open_in_editor(self, event): - """Ctrl+G (or Alt+G in VSCode/Cursor) opens the current draft in an external editor.""" + """Ctrl+G (or Alt+G in VSCode/Cursor) opens the draft in an external editor.""" self._open_external_editor(event.current_buffer) def _tui_model_picker_down(self, event): @@ -1652,8 +1122,7 @@ class CLITuiMixin: else: # +1 for "← Back" and Cancel over the filtered visible rows. _fp = state.get("_filtered_pairs") - _visible = len(_fp) if _fp is not None else len(state.get("model_list") or []) - max_idx = _visible + 1 + max_idx = (len(_fp) if _fp is not None else len(state.get("model_list") or [])) + 1 state["selected"] = min(max_idx, state.get("selected", 0) + 1) event.app.invalidate() @@ -1662,13 +1131,18 @@ class CLITuiMixin: self._model_picker_state["selected"] = max(0, self._model_picker_state.get("selected", 0) - 1) event.app.invalidate() + @staticmethod + def _tui_set_filter(st, value: str) -> None: + """Replace a picker/palette filter and rewind the selection + viewport.""" + st["filter"] = value + st["selected"] = 0 + st["_scroll_offset"] = 0 + def _tui_model_picker_escape(self, event): """ESC clears an active filter first, else closes the picker.""" st = self._model_picker_state if st and st.get("stage") == "model" and (st.get("filter") or ""): - st["filter"] = "" - st["selected"] = 0 - st["_scroll_offset"] = 0 + self._tui_set_filter(st, "") event.app.invalidate() return self._close_model_picker() @@ -1677,34 +1151,25 @@ class CLITuiMixin: def _tui_model_picker_filter_backspace(self, event): st = self._model_picker_state - if not st: - return - cur = st.get("filter", "") or "" - st["filter"] = cur[:-1] - st["selected"] = 0 - st["_scroll_offset"] = 0 - event.app.invalidate() + if st: + self._tui_set_filter(st, (st.get("filter", "") or "")[:-1]) + event.app.invalidate() def _tui_make_model_filter_char_handler(self, ch: str): def handler(event): st = self._model_picker_state if not st or st.get("stage") != "model": return - st["filter"] = (st.get("filter", "") or "") + ch - st["selected"] = 0 - st["_scroll_offset"] = 0 + self._tui_set_filter(st, (st.get("filter", "") or "") + ch) event.app.invalidate() return handler def _tui_make_palette_char_handler(self, ch: str): def handler(event): st = self._command_palette_state - if not st: - return - st["filter"] = (st.get("filter", "") or "") + ch - st["selected"] = 0 - st["_scroll_offset"] = 0 - event.app.invalidate() + if st: + self._tui_set_filter(st, (st.get("filter", "") or "") + ch) + event.app.invalidate() return handler def _tui_make_approval_number_handler(self, idx): @@ -1718,58 +1183,44 @@ class CLITuiMixin: def _tui_make_slash_confirm_number_handler(self, idx): def handler(event): if self._slash_confirm_state and idx < len(self._slash_confirm_state.get("choices") or []): - choice = self._slash_confirm_state["choices"][idx][0] - self._submit_slash_confirm_response(choice) + self._submit_slash_confirm_response(self._slash_confirm_state["choices"][idx][0]) event.app.current_buffer.reset() event.app.invalidate() return handler def _tui_clarify_toggle(self, event): if self._clarify_state: - selected = self._clarify_state["selected"] indices = self._clarify_state.get("selected_indices", set()) - if selected in indices: - indices.discard(selected) - else: - indices.add(selected) + indices.symmetric_difference_update({self._clarify_state["selected"]}) event.app.invalidate() def _tui_clarify_down(self, event): - """Move selection down in clarify choices.""" if self._clarify_state: - choices = self._clarify_state.get("choices") or [] - max_idx = len(choices) # last index is the "Other" option + max_idx = len(self._clarify_state.get("choices") or []) # last index is "Other" self._clarify_state["selected"] = min(max_idx, self._clarify_state["selected"] + 1) event.app.invalidate() def _tui_clarify_up(self, event): - """Move selection up in clarify choices.""" if self._clarify_state: self._clarify_state["selected"] = max(0, self._clarify_state["selected"] - 1) event.app.invalidate() - def _tui_clarify_batch_tab(self, event): + def _tui_clarify_batch_step(self, event, delta: int): state = self._clarify_state if state and state.get("questions"): - self._clarify_batch_set_active( - state, (state["active"] + 1) % len(state["questions"]) - ) + self._clarify_batch_set_active(state, (state["active"] + delta) % len(state["questions"])) event.app.invalidate() + def _tui_clarify_batch_tab(self, event): + self._tui_clarify_batch_step(event, 1) + def _tui_clarify_batch_backtab(self, event): - state = self._clarify_state - if state and state.get("questions"): - self._clarify_batch_set_active( - state, (state["active"] - 1) % len(state["questions"]) - ) - event.app.invalidate() + self._tui_clarify_batch_step(event, -1) def _tui_command_palette_backspace(self, event): st = self._command_palette_state if st: - st["filter"] = (st.get("filter", "") or "")[:-1] - st["selected"] = 0 - st["_scroll_offset"] = 0 + self._tui_set_filter(st, (st.get("filter", "") or "")[:-1]) event.app.invalidate() def _tui_command_palette_down(self, event): @@ -1798,25 +1249,27 @@ class CLITuiMixin: event.app.invalidate() def _tui_slash_confirm_down(self, event): - if self._slash_confirm_state: - max_idx = len(self._slash_confirm_state.get("choices") or []) - 1 - self._slash_confirm_state["selected"] = min(max_idx, self._slash_confirm_state.get("selected", 0) + 1) + st = self._slash_confirm_state + if st: + st["selected"] = min(len(st.get("choices") or []) - 1, st.get("selected", 0) + 1) event.app.invalidate() def _tui_slash_confirm_up(self, event): - if self._slash_confirm_state: - self._slash_confirm_state["selected"] = max(0, self._slash_confirm_state.get("selected", 0) - 1) + st = self._slash_confirm_state + if st: + st["selected"] = max(0, st.get("selected", 0) - 1) event.app.invalidate() def _tui_approval_down(self, event): - if self._approval_state: - max_idx = len(self._approval_state["choices"]) - 1 - self._approval_state["selected"] = min(max_idx, self._approval_state["selected"] + 1) + st = self._approval_state + if st: + st["selected"] = min(len(st["choices"]) - 1, st["selected"] + 1) event.app.invalidate() def _tui_approval_up(self, event): - if self._approval_state: - self._approval_state["selected"] = max(0, self._approval_state["selected"] - 1) + st = self._approval_state + if st: + st["selected"] = max(0, st["selected"] - 1) event.app.invalidate() def _tui_wake_startup(self): @@ -1829,235 +1282,173 @@ class CLITuiMixin: def _tui_suppress_closed_loop_errors(self, loop, context): exc = context.get("exception") if isinstance(exc, RuntimeError) and "Event loop is closed" in str(exc): - return # silently suppress + return if isinstance(exc, KeyError) and "is not registered" in str(exc): - return # suppress selector registration failures (#6393) + return # selector registration failures (#6393) if isinstance(exc, OSError) and getattr(exc, "errno", None) == errno.EIO: - return # suppress I/O errors from broken stdout on interrupt (#13710) - # Fall back to default handler for everything else + return # broken stdout on interrupt (#13710) loop.default_exception_handler(context) def _tui_handle_enter(self, event): - """Handle Enter key - submit input. + """Enter: submit input. - Routes to the correct queue based on active UI state: - - Sudo password prompt: password goes to sudo response queue - - Approval selection: selected choice goes to approval response queue - - Clarify freetext mode: answer goes to the clarify response queue - - Clarify choice mode: selected choice goes to the clarify response queue - - Agent running: goes to _interrupt_queue (chat() monitors this) - - Agent idle: goes to _pending_input (process_loop monitors this) - Commands (starting with /) always go to _pending_input so they're - handled as commands, not sent as interrupt text to the agent. + Modal overlays (sudo/secret/approval/slash-confirm/picker/clarify) are answered first via + ``_tui_enter_overlay``. Otherwise: agent running → busy_input_mode routing (steer / + redirect / interrupt queue / next-turn queue); idle → ``_pending_input``. Slash and bang + commands always take the local-dispatch path (never steer/interrupt text to the model). """ - from cli import ( - CLI_CONFIG, - _ACCENT, - _DIM, - _RST, - _apply_backslash_line_continuation, - _cprint, - _hermes_home, - _is_backslash_line_continuation, - _looks_like_slash_command, - ) + from cli import _apply_backslash_line_continuation, _is_backslash_line_continuation, _looks_like_slash_command if self._tui_enter_overlay(event): return - - # --- Normal input routing --- - raw_text = event.app.current_buffer.text + buf = event.app.current_buffer + raw_text = buf.text if ( self._tui_multiline_shortcuts - and event.app.current_buffer.cursor_position == len(raw_text) - and _is_backslash_line_continuation(raw_text) - ): + and buf.cursor_position == len(raw_text) + and _is_backslash_line_continuation(raw_text)): continued = _apply_backslash_line_continuation(raw_text) - event.app.current_buffer.text = continued - event.app.current_buffer.cursor_position = len(continued) + buf.text = continued + buf.cursor_position = len(continued) event.app.invalidate() return text = raw_text.strip() has_images = bool(self._attached_images) - if text or has_images: - # Handle /model directly on the UI thread so interactive pickers - # can safely use prompt_toolkit terminal handoff helpers. - if self._should_handle_model_command_inline(text, has_images=has_images): - if not self.process_command(text): - self._should_exit = True - if event.app.is_running: - event.app.exit() - event.app.current_buffer.reset(append_to_history=True) - # Force a repaint: process_command() prints through - # patch_stdout (scrolls output above the prompt) and never - # invalidates the app, so the just-cleared input area can - # keep showing the submitted text until some unrelated - # redraw fires. Every other early-return branch in this - # handler invalidates after reset — match them. - event.app.invalidate() - return + if not (text or has_images): + return + if self._tui_enter_inline_command(event, text, has_images): + return + # Snapshot and clear attached images; bundle text + images as a tuple when present. + images = list(self._attached_images) + self._attached_images.clear() + event.app.invalidate() + payload = (text, images) if images else text + # A bang command is treated like a slash command while the agent is busy: it must never + # be routed into steer/redirect (injecting `!git status` into the model's context as a + # prompt). It queues and runs locally once the loop drains. + _is_local_dispatch = bool(text) and (_looks_like_slash_command(text) or text.strip().startswith("!")) + if self._agent_running and not _is_local_dispatch: + self._tui_enter_while_busy(text, images, payload) + else: + self._pending_input.put(payload) + # History stores real pasted content, not the placeholder, so up-arrow recall restores it. + self._inline_pastes(buf) + buf.reset(append_to_history=True) - # Handle /steer while the agent is running immediately on the - # UI thread. Queuing through _pending_input would deadlock the - # steer until after the agent loop finishes (process_loop is - # blocked inside self.chat()), which turns /steer into a - # post-run next-turn message — defeating mid-run injection. - # agent.steer() is thread-safe (holds _pending_steer_lock). - if self._should_handle_steer_command_inline(text, has_images=has_images): - self.process_command(text) - event.app.current_buffer.reset(append_to_history=True) - # Force a repaint after clearing the buffer. /steer is - # dispatched mid-run while the agent streams output through - # patch_stdout; process_command() never invalidates the - # app, so without this the submitted "/steer " can - # linger in the input area (looking unsent) and invite an - # accidental re-submit. See issue #34569. - event.app.invalidate() - return + def _tui_enter_inline_command(self, event, text: str, has_images: bool) -> bool: + """Run /model, /steer, /bg, /btw directly on the UI thread; True when handled. - # Same treatment for /bg and /btw while the agent is - # running. Queuing them defeats the entire point of the - # commands: process_loop is blocked inside self.chat(), so the - # side task would only start once the foreground turn it was - # meant to run alongside has already finished (#75221). The - # foreground turn is left alone: no interrupt, no steer. - if self._should_handle_background_command_inline( - text, has_images=has_images - ): - self.process_command(text) - event.app.current_buffer.reset(append_to_history=True) - # Repaint for the same reason as the /steer branch above: - # process_command() prints through patch_stdout and never - # invalidates the app, so the submitted text can linger in - # the input area looking unsent. - event.app.invalidate() - return + /model needs the prompt_toolkit terminal-handoff helpers of the interactive pickers. + /steer, /bg and /btw while the agent runs must not queue through _pending_input: the + process loop is blocked inside self.chat(), so they would only run after the foreground + turn — turning /steer into a next-turn message (defeating mid-run injection, #34569) and + starting the /bg side task after the turn it should run alongside (#75221). The + foreground turn is left alone: no interrupt, no steer. agent.steer() is thread-safe. - # Snapshot and clear attached images - images = list(self._attached_images) - self._attached_images.clear() - event.app.invalidate() - # Bundle text + images as a tuple when images are present - payload = (text, images) if images else text - # A bang command is treated like a slash command while the - # agent is busy: it must never be routed into steer/redirect - # (which would inject `!git status` into the model's context as - # a prompt). It queues and runs locally once the loop drains. - _is_local_dispatch = bool(text) and ( - _looks_like_slash_command(text) or text.strip().startswith("!") - ) - if self._agent_running and not _is_local_dispatch: - _effective_mode = self.busy_input_mode - redirected = False - if _effective_mode == "steer": - # Route Enter through /steer — inject mid-run after the - # next tool call. Images can't ride along (steer only - # appends text), so fall back to queue when images are - # attached. If the agent lacks steer() or rejects the - # payload, also fall back to queue so nothing is lost. - if images or not text: - _effective_mode = "queue" - else: - accepted = False - try: - if self.agent is not None and hasattr(self.agent, "steer"): - accepted = bool(self.agent.steer(text)) - except Exception as exc: - _cprint(f" {_DIM}Steer failed ({exc}) — queued for next turn.{_RST}") - accepted = False - if accepted: - preview = text[:80] + ("..." if len(text) > 80 else "") - _cprint(f" {_ACCENT}⏩ Steered: '{preview}'{_RST}") - else: - _effective_mode = "queue" - if _effective_mode == "queue": - # Queue for the next turn instead of interrupting - self._pending_input.put(payload) - preview = text if text else f"[{len(images)} image{'s' if len(images) != 1 else ''} attached]" - _cprint(f" Queued for the next turn: {preview[:80]}{'...' if len(preview) > 80 else ''}") - elif _effective_mode == "interrupt": - if not images and text: - try: - if ( - self.agent is not None - and getattr( - self.agent, - "_supports_active_turn_redirect", - False, - ) - is True - and hasattr(self.agent, "redirect") - ): - redirected = bool(self.agent.redirect(text)) - except Exception: - redirected = False - if redirected: - preview = text[:80] + ("..." if len(text) > 80 else "") - _cprint(f" {_ACCENT}↪ Redirected current turn: '{preview}'{_RST}") - else: - # Compatibility path for older agents, multimodal - # follow-ups, or a turn that finished in the race. - self._interrupt_queue.put(payload) - try: - _dbg = _hermes_home / "interrupt_debug.log" - with open(_dbg, "a", encoding="utf-8") as _f: - _f.write(f"{time.strftime('%H:%M:%S')} ENTER: queued interrupt msg={str(payload)[:60]!r}, " - f"agent_running={self._agent_running}\n") - except Exception: - pass - # First-touch onboarding: on the very first busy-while-running - # event for this install, print a one-line tip explaining the - # /busy knob. Flag persists to config.yaml and never fires - # again. Guarded for exceptions so onboarding can't break - # the input loop. + Every branch invalidates after reset: process_command() prints through patch_stdout + and never invalidates the app, so the just-cleared input area would keep showing the + submitted text (looking unsent, inviting a re-submit) until some unrelated redraw. + """ + if self._should_handle_model_command_inline(text, has_images=has_images): + if not self.process_command(text): + self._should_exit = True + if event.app.is_running: + event.app.exit() + elif ( + self._should_handle_steer_command_inline(text, has_images=has_images) + or self._should_handle_background_command_inline(text, has_images=has_images)): + self.process_command(text) + else: + return False + event.app.current_buffer.reset(append_to_history=True) + event.app.invalidate() + return True + + def _tui_enter_while_busy(self, text: str, images: list, payload) -> None: + """Route a submission typed while the agent runs, per ``busy_input_mode``. + + steer → agent.steer(text) mid-run (images can't ride along and a missing/rejecting + steer() falls back to queue so nothing is lost). interrupt → agent.redirect() when the + agent supports active-turn redirect, else the legacy interrupt queue (older agents, + multimodal follow-ups, or a turn that finished in the race). queue → next turn. + """ + from cli import CLI_CONFIG, _ACCENT, _DIM, _RST, _cprint, _hermes_home + _effective_mode = self.busy_input_mode + redirected = False + if _effective_mode == "steer": + if images or not text: + _effective_mode = "queue" + else: + accepted = False try: - from agent.onboarding import ( - BUSY_INPUT_FLAG, - busy_input_hint_cli, - is_seen, - mark_seen, - ) - if not is_seen(CLI_CONFIG, BUSY_INPUT_FLAG): - _hint_mode = "redirect" if redirected else _effective_mode - _cprint(f" {_DIM}{busy_input_hint_cli(_hint_mode)}{_RST}") - mark_seen(_hermes_home / "config.yaml", BUSY_INPUT_FLAG) - CLI_CONFIG.setdefault("onboarding", {}).setdefault("seen", {})[BUSY_INPUT_FLAG] = True + if self.agent is not None and hasattr(self.agent, "steer"): + accepted = bool(self.agent.steer(text)) + except Exception as exc: + _cprint(f" {_DIM}Steer failed ({exc}) — queued for next turn.{_RST}") + accepted = False + if accepted: + preview = text[:80] + ("..." if len(text) > 80 else "") + _cprint(f" {_ACCENT}⏩ Steered: '{preview}'{_RST}") + else: + _effective_mode = "queue" + if _effective_mode == "queue": + self._pending_input.put(payload) + preview = text if text else f"[{len(images)} image{'s' if len(images) != 1 else ''} attached]" + _cprint(f" Queued for the next turn: {preview[:80]}{'...' if len(preview) > 80 else ''}") + elif _effective_mode == "interrupt": + if not images and text: + try: + if ( + self.agent is not None + and getattr(self.agent, "_supports_active_turn_redirect", False) is True + and hasattr(self.agent, "redirect")): + redirected = bool(self.agent.redirect(text)) + except Exception: + redirected = False + if redirected: + preview = text[:80] + ("..." if len(text) > 80 else "") + _cprint(f" {_ACCENT}↪ Redirected current turn: '{preview}'{_RST}") + else: + self._interrupt_queue.put(payload) + try: + with open(_hermes_home / "interrupt_debug.log", "a", encoding="utf-8") as _f: + _f.write(f"{time.strftime('%H:%M:%S')} ENTER: queued interrupt msg={str(payload)[:60]!r}, " + f"agent_running={self._agent_running}\n") except Exception: pass - else: - self._pending_input.put(payload) - # History stores real pasted content, not the placeholder, so - # up-arrow recall restores the actual text. - self._inline_pastes(event.app.current_buffer) - event.app.current_buffer.reset(append_to_history=True) + # First-touch onboarding: one-line tip about the /busy knob on the first busy-while- + # running event for this install; the flag persists to config.yaml. Guarded so + # onboarding can never break the input loop. + try: + from agent.onboarding import BUSY_INPUT_FLAG, busy_input_hint_cli, is_seen, mark_seen + if not is_seen(CLI_CONFIG, BUSY_INPUT_FLAG): + _hint_mode = "redirect" if redirected else _effective_mode + _cprint(f" {_DIM}{busy_input_hint_cli(_hint_mode)}{_RST}") + mark_seen(_hermes_home / "config.yaml", BUSY_INPUT_FLAG) + CLI_CONFIG.setdefault("onboarding", {}).setdefault("seen", {})[BUSY_INPUT_FLAG] = True + except Exception: + pass def _tui_enter_overlay(self, event) -> bool: - """Enter while a modal overlay (sudo/secret/approval/slash-confirm/model picker/clarify) is up: submit it. True when handled.""" + """Enter while a modal overlay is up: submit it. True when handled.""" from cli import _cprint - # --- Sudo password prompt: submit the typed password --- + buf = event.app.current_buffer if self._sudo_state: - text = event.app.current_buffer.text - self._sudo_state["response_queue"].put(text) + self._sudo_state["response_queue"].put(buf.text) self._sudo_state = None event.app.invalidate() return True - - # --- Secret prompt: submit the typed secret --- if self._secret_state: - text = event.app.current_buffer.text - self._submit_secret_response(text) - event.app.current_buffer.reset() + self._submit_secret_response(buf.text) + buf.reset() event.app.invalidate() return True - - # --- Approval selection: confirm the highlighted choice --- if self._approval_state: self._handle_approval_selection() event.app.invalidate() return True - - # --- Slash-command confirmation: submit typed or highlighted choice --- if self._slash_confirm_state: - text = event.app.current_buffer.text.strip() + # Typed choice wins over the highlighted one. + text = buf.text.strip() choices = self._slash_confirm_state.get("choices") or [] choice = self._normalize_slash_confirm_choice(text, choices) if text else None if choice is None: @@ -2065,149 +1456,138 @@ class CLITuiMixin: if 0 <= selected < len(choices): choice = choices[selected][0] self._submit_slash_confirm_response(choice or "cancel") - event.app.current_buffer.reset() + buf.reset() event.app.invalidate() return True - - # --- /model picker modal --- if self._model_picker_state: try: - # Picker selections follow the same session-scoped default - # as /model ; honour model.persist_switch_by_default. + # Picker selections follow the same session-scoped default as /model + # (model.persist_switch_by_default). from hermes_cli.model_switch import resolve_persist_behavior - - self._handle_model_picker_selection( - persist_global=resolve_persist_behavior(False, False) - ) + self._handle_model_picker_selection(persist_global=resolve_persist_behavior(False, False)) except Exception as _exc: _cprint(f" ✗ Model selection failed: {_exc}") self._close_model_picker() - event.app.current_buffer.reset() + buf.reset() event.app.invalidate() return True - - # --- Clarify freetext mode: user typed their own answer --- - if self._clarify_freetext and self._clarify_state: - text = event.app.current_buffer.text.strip() - if text: - state = self._clarify_state - # Batch mode: lock the typed answer for the active question - if state.get("questions"): - base = getattr(self, '_clarify_multi_base', None) - if base is not None: - # Multi-select "Other": append the typed answer to - # the checked labels as a JSON array string. - answer = json.dumps(base + [text], ensure_ascii=False) - meta = {"kind": "multi", "choices": list(base), "other_text": text} - self._clarify_multi_base = None - else: - answer = text - meta = {"kind": "other", "other_text": text} - self._clarify_freetext = False - self._clarify_prefill = "" - self._clarify_batch_lock(state, answer, meta=meta) - event.app.current_buffer.reset() - event.app.invalidate() - return True - # multi-select: prepend previously checked real choices - base = getattr(self, '_clarify_multi_base', None) - if base: - text = ", ".join(base) + ", " + text - self._clarify_multi_base = None - self._clarify_state["response_queue"].put(text) - self._clarify_state = None - self._clarify_freetext = False - event.app.current_buffer.reset() - event.app.invalidate() + if self._clarify_state and self._clarify_freetext: + self._tui_enter_clarify_freetext(event) return True - - # --- Clarify choice mode: confirm the highlighted selection --- - if self._clarify_state and not self._clarify_freetext: - state = self._clarify_state - # Batch mode: Enter locks the active question's answer and - # advances to the next unanswered question. - if state.get("questions"): - self._clarify_batch_enter(state) - # Editing an earlier "Other" answer: prefill the composer - # with the previously typed text. - if self._clarify_freetext and self._clarify_prefill: - event.app.current_buffer.text = self._clarify_prefill - event.app.current_buffer.cursor_position = len(self._clarify_prefill) - self._clarify_prefill = "" - event.app.invalidate() - return True - selected = state["selected"] - choices = state.get("choices") or [] - # multi-select support: submit comma-joined list of checked choices - if state.get("multi_select"): - indices = state.get("selected_indices") - if not indices: - # Nothing checked → submit empty string (parses to []) - state["response_queue"].put("") - self._clarify_state = None - event.app.invalidate() - return True - sorted_idx = sorted(indices) - selected_choices = [choices[i] for i in sorted_idx if i < len(choices)] - other_checked = len(choices) in sorted_idx - if other_checked and selected_choices: - # "Other" + real choices: store base choices, switch to freetext - # so the user can type a custom answer that gets appended - self._clarify_multi_base = selected_choices - self._clarify_freetext = True - event.app.invalidate() - return True - if selected_choices: - state["response_queue"].put(", ".join(selected_choices)) - self._clarify_state = None - event.app.invalidate() - return True - # Only "Other" was checked → switch to freetext - self._clarify_freetext = True - event.app.invalidate() - return True - # Original single-select behavior: submit the highlighted choice - if selected < len(choices): - state["response_queue"].put(choices[selected]) - self._clarify_state = None - event.app.invalidate() - else: - # "Other" selected → switch to freetext - self._clarify_freetext = True - event.app.invalidate() + if self._clarify_state: + self._tui_enter_clarify_choice(event) return True return False + def _tui_enter_clarify_freetext(self, event) -> None: + """Clarify "Other": submit the typed answer (empty input is ignored).""" + buf = event.app.current_buffer + text = buf.text.strip() + if not text: + return + state = self._clarify_state + base = getattr(self, '_clarify_multi_base', None) + if state.get("questions"): + # Batch mode: lock the typed answer for the active question. Multi-select "Other" + # appends the typed answer to the checked labels as a JSON array string. + if base is not None: + answer = json.dumps(base + [text], ensure_ascii=False) + meta = {"kind": "multi", "choices": list(base), "other_text": text} + self._clarify_multi_base = None + else: + answer = text + meta = {"kind": "other", "other_text": text} + self._clarify_freetext = False + self._clarify_prefill = "" + self._clarify_batch_lock(state, answer, meta=meta) + else: + # Multi-select: prepend the previously checked real choices. + if base: + text = ", ".join(base) + ", " + text + self._clarify_multi_base = None + state["response_queue"].put(text) + self._clarify_state = None + self._clarify_freetext = False + buf.reset() + event.app.invalidate() + + def _tui_enter_clarify_choice(self, event) -> None: + """Clarify choice mode: confirm the highlighted selection.""" + state = self._clarify_state + if state.get("questions"): + # Batch mode: lock the active question's answer and advance to the next unanswered. + self._clarify_batch_enter(state) + # Editing an earlier "Other" answer: prefill the composer with the previous text. + if self._clarify_freetext and self._clarify_prefill: + event.app.current_buffer.text = self._clarify_prefill + event.app.current_buffer.cursor_position = len(self._clarify_prefill) + self._clarify_prefill = "" + event.app.invalidate() + return + selected = state["selected"] + choices = state.get("choices") or [] + if state.get("multi_select"): + indices = state.get("selected_indices") + if not indices: + # Nothing checked → submit empty string (parses to []). + state["response_queue"].put("") + self._clarify_state = None + else: + sorted_idx = sorted(indices) + selected_choices = [choices[i] for i in sorted_idx if i < len(choices)] + if len(choices) in sorted_idx and selected_choices: + # "Other" + real choices: remember the base, switch to freetext so the typed + # custom answer gets appended. + self._clarify_multi_base = selected_choices + self._clarify_freetext = True + elif selected_choices: + state["response_queue"].put(", ".join(selected_choices)) + self._clarify_state = None + else: + self._clarify_freetext = True # only "Other" checked + elif selected < len(choices): + state["response_queue"].put(choices[selected]) + self._clarify_state = None + else: + self._clarify_freetext = True # "Other" selected + event.app.invalidate() + + def _tui_collapse_paste(self, text: str, line_count: int, *, fallback: bool) -> str: + """Save a large paste under ~/.hermes/pastes and return the placeholder for the buffer.""" + from cli import _hermes_home, datetime, logger + self._tui_paste_counter += 1 + paste_dir = _hermes_home / "pastes" + paste_dir.mkdir(parents=True, exist_ok=True) + paste_file = paste_dir / f"paste_{self._tui_paste_counter}_{datetime.now().strftime('%H%M%S')}.txt" + paste_file.write_text(text, encoding="utf-8") + logger.info( + "Collapsed paste #%d: %d lines, %d chars -> %s" + (" (fallback)" if fallback else ""), + self._tui_paste_counter, line_count + 1, len(text), paste_file) + self._tui_paste_just_collapsed = True + return f"[Pasted text #{self._tui_paste_counter}: {line_count + 1} lines \u2192 {paste_file}]" + + def _tui_paste_over_threshold(self, text: str, line_count: int, threshold_key: str) -> bool: + threshold = self.config.get(threshold_key, 5) + char_threshold = self.config.get("paste_collapse_char_threshold", 2000) + lines_hit = threshold > 0 and line_count >= threshold + chars_hit = char_threshold > 0 and len(text) >= char_threshold + return lines_hit or chars_hit + def _tui_handle_paste(self, event): - """Handle terminal paste — detect clipboard images. - - When the terminal supports bracketed paste, Ctrl+V / Cmd+V - triggers this with the pasted text. We only auto-attach a - clipboard image for image-only/empty paste gestures so text - pastes and dictation do not accidentally attach stale images. - - Large pastes (5+ lines) are collapsed to a file reference - placeholder while preserving any existing user text in the - buffer. - """ + """Bracketed paste: strip leaked terminal responses, auto-attach a clipboard image only + for image-only/empty gestures (so text pastes and dictation never attach stale images), + and collapse large pastes to a file-reference placeholder, preserving existing text.""" from cli import ( - _hermes_home, _should_auto_attach_clipboard_image_on_paste, _strip_leaked_bracketed_paste_wrappers, _strip_leaked_terminal_responses_with_meta, - datetime, - logger, - ) - # Diagnostic canary: measure how long the paste handler blocks - # the prompt_toolkit event loop. If this exceeds ~500ms we log - # it so recurring "CLI freezes on paste" reports (issue #16263, - # macOS Tahoe 26 + iTerm2/Ghostty) arrive with data attached. + logger) + # Diagnostic canary: log when the handler blocks the event loop >500ms so recurring + # "CLI freezes on paste" reports (#16263, macOS Tahoe + iTerm2/Ghostty) arrive with data. _paste_handler_start = time.perf_counter() _paste_raw_size = len(event.data or "") - pasted_text = event.data or "" - # Normalise line endings — Windows \r\n and old Mac \r both become \n - # so the 5-line collapse threshold and display are consistent. - pasted_text = pasted_text.replace('\r\n', '\n').replace('\r', '\n') + # Normalise line endings so the collapse threshold and display are consistent. + pasted_text = (event.data or "").replace('\r\n', '\n').replace('\r', '\n') pasted_text = _strip_leaked_bracketed_paste_wrappers(pasted_text) pasted_text, _had_mouse_reports = _strip_leaked_terminal_responses_with_meta(pasted_text) if _had_mouse_reports: @@ -2215,27 +1595,16 @@ class CLITuiMixin: if _should_auto_attach_clipboard_image_on_paste(pasted_text) and self._try_attach_clipboard_image(): event.app.invalidate() if pasted_text: - # Sanitize surrogate characters (e.g. from Word/Google Docs paste) before writing + # Sanitize surrogates (Word/Google Docs paste) before writing. from run_agent import _sanitize_surrogates pasted_text = _sanitize_surrogates(pasted_text) line_count = pasted_text.count('\n') buf = event.current_buffer - threshold = self.config.get("paste_collapse_threshold", 5) - char_threshold = self.config.get("paste_collapse_char_threshold", 2000) - lines_hit = threshold > 0 and line_count >= threshold - chars_hit = char_threshold > 0 and len(pasted_text) >= char_threshold - if (lines_hit or chars_hit) and not buf.text.strip().startswith('/'): - self._tui_paste_counter[0] += 1 - paste_dir = _hermes_home / "pastes" - paste_dir.mkdir(parents=True, exist_ok=True) - paste_file = paste_dir / f"paste_{self._tui_paste_counter[0]}_{datetime.now().strftime('%H%M%S')}.txt" - paste_file.write_text(pasted_text, encoding="utf-8") - logger.info("Collapsed paste #%d: %d lines, %d chars -> %s", self._tui_paste_counter[0], line_count + 1, len(pasted_text), paste_file) - placeholder = f"[Pasted text #{self._tui_paste_counter[0]}: {line_count + 1} lines \u2192 {paste_file}]" - prefix = "" - if buf.cursor_position > 0 and buf.text[buf.cursor_position - 1] != '\n': - prefix = "\n" - self._tui_paste_just_collapsed[0] = True + if ( + self._tui_paste_over_threshold(pasted_text, line_count, "paste_collapse_threshold") + and not buf.text.strip().startswith('/')): + placeholder = self._tui_collapse_paste(pasted_text, line_count, fallback=False) + prefix = "\n" if buf.cursor_position > 0 and buf.text[buf.cursor_position - 1] != '\n' else "" buf.insert_text(prefix + placeholder) else: buf.insert_text(pasted_text) @@ -2248,118 +1617,76 @@ class CLITuiMixin: _paste_handler_elapsed_ms, _paste_raw_size, pasted_text.count('\n') + 1 if pasted_text else 0, - sys.platform, - ) + sys.platform) def _tui_on_text_changed(self, buf): - """Detect large pastes and collapse them to a file reference. + """Fallback paste collapse for terminals without bracketed paste. - When bracketed paste is available, handle_paste collapses - large pastes directly. This handler is a fallback for - terminals without bracketed paste support. - - Two heuristics (either triggers collapse): - 1. Many characters added at once (chars_added > 1) — works - when the terminal delivers the paste in one event-loop tick. - 2. Newline count jumped by 4+ in a single text-change event — - catches terminals that feed characters individually but - still batch newlines. Alt+Enter only adds 1 newline per - event so it never triggers this. + Either heuristic triggers: many characters added in one event (paste delivered in one + tick), or the newline count jumped by 4+ (terminals that feed characters individually + but batch newlines; Alt+Enter adds 1 newline per event so never trips it). """ - from cli import ( - _hermes_home, - _strip_leaked_bracketed_paste_wrappers, - _strip_leaked_terminal_responses_with_meta, - datetime, - logger, - ) + from cli import _strip_leaked_bracketed_paste_wrappers, _strip_leaked_terminal_responses_with_meta text = _strip_leaked_bracketed_paste_wrappers(buf.text) text, _had_mouse_reports = _strip_leaked_terminal_responses_with_meta(text) if _had_mouse_reports: self._recover_terminal_input_modes(reason="mouse reports leaked into prompt buffer") if text != buf.text: cursor = min(buf.cursor_position, len(text)) - self._tui_paste_just_collapsed[0] = True + self._tui_paste_just_collapsed = True buf.text = text buf.cursor_position = cursor - self._tui_prev_text_len[0] = len(text) - self._tui_prev_newline_count[0] = text.count('\n') + self._tui_prev_text_len = len(text) + self._tui_prev_newline_count = text.count('\n') return - chars_added = len(text) - self._tui_prev_text_len[0] - self._tui_prev_text_len[0] = len(text) - if self._tui_paste_just_collapsed[0] or self._skip_paste_collapse: - self._tui_paste_just_collapsed[0] = False + chars_added = len(text) - self._tui_prev_text_len + self._tui_prev_text_len = len(text) + if self._tui_paste_just_collapsed or self._skip_paste_collapse: + self._tui_paste_just_collapsed = False self._skip_paste_collapse = False - self._tui_prev_newline_count[0] = text.count('\n') + self._tui_prev_newline_count = text.count('\n') return line_count = text.count('\n') - newlines_added = line_count - self._tui_prev_newline_count[0] - self._tui_prev_newline_count[0] = line_count + newlines_added = line_count - self._tui_prev_newline_count + self._tui_prev_newline_count = line_count is_paste = chars_added > 1 or newlines_added >= 4 - threshold = self.config.get("paste_collapse_threshold_fallback", 5) - char_threshold = self.config.get("paste_collapse_char_threshold", 2000) - lines_hit = threshold > 0 and line_count >= threshold - chars_hit = char_threshold > 0 and len(text) >= char_threshold - if (lines_hit or chars_hit) and is_paste and not text.startswith('/'): - self._tui_paste_counter[0] += 1 - paste_dir = _hermes_home / "pastes" - paste_dir.mkdir(parents=True, exist_ok=True) - paste_file = paste_dir / f"paste_{self._tui_paste_counter[0]}_{datetime.now().strftime('%H%M%S')}.txt" - paste_file.write_text(text, encoding="utf-8") - logger.info("Collapsed paste #%d: %d lines, %d chars -> %s (fallback)", self._tui_paste_counter[0], line_count + 1, len(text), paste_file) - self._tui_paste_just_collapsed[0] = True - buf.text = f"[Pasted text #{self._tui_paste_counter[0]}: {line_count + 1} lines \u2192 {paste_file}]" + if ( + self._tui_paste_over_threshold(text, line_count, "paste_collapse_threshold_fallback") + and is_paste + and not text.startswith('/')): + buf.text = self._tui_collapse_paste(text, line_count, fallback=True) buf.cursor_position = len(buf.text) def _tui_handle_prompt_stash(self, event): - """Ctrl+S: stash the current draft, or restore/browse a stashed one. + """Ctrl+S: composer has content → push onto the stash and clear; empty + one stashed → + pop it back; empty + several → open the browse panel; panel open → close it. - - Composer has content → push it onto the stash and clear the input. - - Composer empty, one stashed draft → pop it straight back. - - Composer empty, several stashed → open the browse panel. - - Browse panel open → close it. - - Pushing onto a stack (rather than a single slot) is what makes - repeated Ctrl+S safe: a second stash never silently overwrites the - first, both stay reachable in the panel. + A stack (not a single slot) is what makes repeated Ctrl+S safe: a second stash never + silently overwrites the first, both stay reachable in the panel. """ - from hermes_cli.prompt_stash import ( - ACTION_OPEN_PANEL, - ACTION_RESTORED, - ACTION_STASHED, - resolve_ctrl_s, - ) - + from hermes_cli.prompt_stash import ACTION_RESTORED, ACTION_STASHED, resolve_ctrl_s buf = event.app.current_buffer - action, payload = resolve_ctrl_s( - self._prompt_stash, buf.text, self._attached_images - ) - + action, payload = resolve_ctrl_s(self._prompt_stash, buf.text, self._attached_images) if action == ACTION_STASHED: - # reset() (not `text = ""`) so completion state, selection, and - # the undo stack are cleared along with the text. + # reset() (not `text = ""`) also clears completion state, selection and the undo stack. buf.reset() self._attached_images.clear() elif action == ACTION_RESTORED: self._tui_restore_stash_payload(event, payload) - elif action == ACTION_OPEN_PANEL: - pass # resolve_ctrl_s already flipped panel_open - + # ACTION_OPEN_PANEL: resolve_ctrl_s already flipped panel_open. event.app.invalidate() def _tui_handle_stash_panel_restore(self, event): """Enter in the browse panel restores the highlighted draft.""" - payload = self._prompt_stash.restore_at_cursor() - self._tui_restore_stash_payload(event, payload) + self._tui_restore_stash_payload(event, self._prompt_stash.restore_at_cursor()) event.app.invalidate() def _tui_history_up(self, event): - """Up arrow: browse history when on first line, else move cursor up.""" + """Up: browse history when on the first line, else move the cursor up.""" buf = event.app.current_buffer self._tui_recall_without_recollapse(buf, lambda: buf.auto_up(count=event.arg)) def _tui_history_down(self, event): - """Down arrow: browse history when on last line, else move cursor down.""" buf = event.app.current_buffer self._tui_recall_without_recollapse(buf, lambda: buf.auto_down(count=event.arg)) @@ -2367,10 +1694,7 @@ class CLITuiMixin: from cli import _format_image_attachment_badges if not self._attached_images: return [] - badges = _format_image_attachment_badges( - self._attached_images, - self._image_counter, - ) + badges = _format_image_attachment_badges(self._attached_images, self._image_counter) return [("class:image-badge", f" {badges} ")] def _tui_voice_status_fragments(self): @@ -2378,97 +1702,80 @@ class CLITuiMixin: def _tui_spinner_text(self): spinner_line = self._render_spinner_text() - if not spinner_line: - return [] - return [('class:hint', spinner_line)] + return [('class:hint', spinner_line)] if spinner_line else [] def _tui_spinner_height(self): return self._spinner_widget_height() def _tui_hint_height(self): - if self._sudo_state or self._secret_state or self._approval_state or self._slash_confirm_state or self._clarify_state or self._command_running: + if ( + self._sudo_state or self._secret_state or self._approval_state + or self._slash_confirm_state or self._clarify_state or self._command_running): return 1 - # Keep a spacer while the agent runs on roomy terminals, but reclaim - # the row on narrow/mobile screens where every line matters. + # Keep a spacer while the agent runs on roomy terminals; reclaim the row on narrow screens. return self._agent_spacer_height() def _tui_init_run_state(self): """Reset the per-run REPL state (queues, modal states, voice state, config watcher).""" - # State for async operation self._agent_running = False - self._pending_input = queue.Queue() # For normal input (commands + new queries) - self._interrupt_queue = queue.Queue() # For messages typed while agent is running - # Seeded -q handoff: main() can't put directly into _pending_input - # (this reinit would discard it), so the seeded first message rides - # in on an attribute and is enqueued into the fresh queue here. + self._pending_input = queue.Queue() # normal input (commands + new queries) + self._interrupt_queue = queue.Queue() # messages typed while the agent is running + # Seeded -q handoff: main() can't put directly into _pending_input (this reinit would + # discard it), so the seeded first message rides in on an attribute and is enqueued here. _seed_msg = getattr(self, "_seeded_first_message", None) if _seed_msg is not None: self._seeded_first_message = None self._pending_input.put(_seed_msg) - # See constructor note. Mirrored here for the run() path that skips - # the earlier __init__ branch. + # See constructor note; mirrored for the run() path that skips the earlier __init__ branch. self._last_turn_interrupted = False self._should_exit = False - self._last_ctrl_c_time = 0 # Track double Ctrl+C for force exit + self._last_ctrl_c_time = 0 # double Ctrl+C force-exit tracking - # Give plugin manager a CLI reference so plugins can inject messages + # Plugins get a CLI reference so they can inject messages. from hermes_cli.plugins import get_plugin_manager get_plugin_manager()._cli_ref = self - # Config file watcher — detect mcp_servers changes and auto-reload + # Config file watcher — detect mcp_servers changes and auto-reload. from hermes_cli.config import get_config_path as _get_config_path _cfg_path = _get_config_path() self._config_mtime: float = _cfg_path.stat().st_mtime if _cfg_path.exists() else 0.0 self._config_mcp_servers: dict = self.config.get("mcp_servers") or {} self._last_config_check: float = 0.0 # monotonic time of last check - # Clarify tool state: interactive question/answer with the user. - # When the agent calls the clarify tool, _clarify_state is set and - # the prompt_toolkit UI switches to a selection mode. - self._clarify_state = None # dict with question, choices, selected, response_queue - self._clarify_freetext = False # True when user chose "Other" and is typing - self._clarify_deadline = 0 # monotonic timestamp when the clarify times out - - # Sudo password prompt state (similar mechanism to clarify) - self._sudo_state = None # dict with response_queue when active + # Modal overlay states: each is a dict (with a response_queue) while active, else None. + # The prompt_toolkit UI switches to the matching selection/input mode. + self._clarify_state = None + self._clarify_freetext = False # True when the user chose "Other" and is typing + self._clarify_deadline = 0 # monotonic timeout + self._sudo_state = None self._sudo_deadline = 0 self._modal_input_snapshot = None - - # Dangerous command approval state (similar mechanism to clarify) - self._approval_state = None # dict with command, description, choices, selected, response_queue + self._approval_state = None self._approval_deadline = 0 - self._approval_lock = threading.Lock() # serialize concurrent approval prompts (delegation race fix) - - # Destructive slash-command confirmation state (/new, /clear, /undo). - # These prompts are answered through the prompt_toolkit composer, not - # raw input(), so the option labels stay visible and Enter does not EOF - # the whole app. + self._approval_lock = threading.Lock() # serialize concurrent approval prompts (delegation race) + # Destructive slash-command confirmations (/new, /clear, /undo) are answered through the + # composer, not raw input(), so the labels stay visible and Enter can't EOF the app. self._slash_confirm_state = None self._slash_confirm_deadline = 0 - - # Slash command loading state self._command_running = False self._command_blocks_input = False self._command_status = "" - - # Secure secret capture state for skill setup - self._secret_state = None # dict with var_name, prompt, metadata, response_queue + self._secret_state = None # skill-setup secret capture self._secret_deadline = 0 - # Clipboard image attachments (paste images into the CLI) - self._attached_images: list[Path] = [] + self._attached_images: list[Path] = [] # clipboard image attachments self._image_counter = 0 - # Voice mode state (protected by _voice_lock for cross-thread access) + # Voice mode state (protected by _voice_lock for cross-thread access). self._voice_lock = threading.Lock() - self._voice_mode = False # Whether voice mode is enabled - self._voice_tts = False # Whether TTS output is enabled - self._voice_recorder = None # AudioRecorder instance (lazy init) - self._voice_recording = False # Whether currently recording - self._voice_processing = False # Whether STT is in progress - self._voice_continuous = False # Whether to auto-restart after agent responds - self._voice_tts_done = threading.Event() # Signals TTS playback finished - self._voice_tts_done.set() # Initially "done" (no TTS pending) + self._voice_mode = False + self._voice_tts = False + self._voice_recorder = None # AudioRecorder (lazy init) + self._voice_recording = False + self._voice_processing = False # STT in progress + self._voice_continuous = False # auto-restart after the agent responds + self._voice_tts_done = threading.Event() # TTS playback finished + self._voice_tts_done.set() # initially "done" (no TTS pending) self._voice_tts_stop = None # active streaming pipeline's stop event self._voice_barge_capture = threading.Event() # barge monitor is capturing the interruption self._voice_last_tts_text = "" # most recently spoken TTS text (echo guard, #75780) @@ -2476,480 +1783,273 @@ class CLITuiMixin: if os.environ.get("HERMES_DEFER_AGENT_STARTUP") != "1": self._install_tool_callbacks() - - if os.environ.get("HERMES_DEFER_AGENT_STARTUP") != "1": self._ensure_tirith_security() def _tui_build_key_bindings(self): - """Build the prompt_toolkit KeyBindings for the REPL input area.""" - from cli import ( - CLI_CONFIG, - _bind_prompt_submit_keys, - _cli_multiline_shortcuts_enabled, - _preserve_ctrl_enter_newline, - logger, - ) - # Key bindings for the input area - kb = KeyBindings() + """Build the prompt_toolkit KeyBindings for the REPL input area. + Registration ORDER matters: for the same key, prompt_toolkit picks the last matching + binding, so the generic handlers (Tab, history Up/Down) are registered before or after + their filtered modal overrides deliberately. + """ + from cli import CLI_CONFIG, _bind_prompt_submit_keys, _cli_multiline_shortcuts_enabled, _preserve_ctrl_enter_newline + from prompt_toolkit.keys import Keys + kb = KeyBindings() _multiline_shortcuts_enabled = _cli_multiline_shortcuts_enabled(self.config or CLI_CONFIG) self._tui_multiline_shortcuts = _multiline_shortcuts_enabled - from prompt_toolkit.keys import Keys as _IgnoreKeys - - kb.add(_IgnoreKeys.Ignore, eager=True)(self._tui_handle_ignored_terminal_sequence) - - _bind_prompt_submit_keys( - kb, - self._tui_handle_enter, - multiline_shortcuts_enabled=_multiline_shortcuts_enabled, - ) - + kb.add(Keys.Ignore, eager=True)(self._tui_handle_ignored_terminal_sequence) + _bind_prompt_submit_keys(kb, self._tui_handle_enter, multiline_shortcuts_enabled=_multiline_shortcuts_enabled) kb.add('escape', 'enter')(self._tui_insert_newline) - - # Ctrl+J inserts a newline (matches Claude Code / Codex / OpenCode). - # Windows Terminal delivers Ctrl+Enter as the same c-j code, so this - # covers Ctrl+Enter there. display.cli_multiline_shortcuts: false - # restores legacy c-j submit on unusual POSIX PTYs where Enter is LF. + # Ctrl+J inserts a newline (Claude Code / Codex / OpenCode). Windows Terminal delivers + # Ctrl+Enter as the same c-j code. display.cli_multiline_shortcuts: false restores legacy + # c-j submit on unusual POSIX PTYs where Enter is LF. if _multiline_shortcuts_enabled or _preserve_ctrl_enter_newline(): kb.add('c-j')(self._tui_insert_newline) - # VSCode/Cursor bind Ctrl+G to "Find Next" at the editor level, so - # the keystroke never reaches the embedded terminal. Alt+G is unbound - # in those IDEs and arrives here as ('escape', 'g') — register it as - # a fallback so the editor handoff works inside Cursor/VSCode too. + self._tui_bind_editor_and_stash(kb) + self._tui_bind_overlay_navigation(kb) + + # History: the TextArea is multiline so Up/Down alone only move the cursor; + # Buffer.auto_up/auto_down browse history when on the first/last line. + _normal_input = Condition( + lambda: not self._clarify_state and not self._approval_state and not self._slash_confirm_state + and not self._sudo_state and not self._secret_state and not self._model_picker_state + and not self._command_palette_state) + kb.add('up', filter=_normal_input)(self._tui_history_up) + kb.add('down', filter=_normal_input)(self._tui_history_down) + kb.add('c-l')(self._tui_handle_ctrl_l) + kb.add('c-c')(self._tui_handle_ctrl_c) + # No Ctrl+Shift+C binding: terminal emulators intercept it before stdin, and + # prompt_toolkit's key parser doesn't recognise 'c-S-c' anyway (#19884/#19895). + kb.add('c-q')(self._tui_handle_ctrl_q) + kb.add('c-d')(self._tui_handle_ctrl_d) + _modal_prompt_active = Condition(lambda: bool(self._secret_state or self._sudo_state or self._slash_confirm_state)) + kb.add('escape', filter=_modal_prompt_active, eager=True)(self._tui_handle_escape_modal) + kb.add('escape', 'escape', filter=~_modal_prompt_active)(self._tui_handle_double_escape) + kb.add('c-z')(self._tui_handle_ctrl_z) + + kb.add(*self._tui_voice_record_key_sequence())(self._tui_handle_voice_record) + kb.add(Keys.BracketedPaste, eager=True)(self._tui_handle_paste) + kb.add('c-v')(self._tui_handle_ctrl_v) + kb.add('escape', 'v')(self._tui_handle_alt_v) + return kb + + def _tui_bind_editor_and_stash(self, kb) -> None: + # VSCode/Cursor bind Ctrl+G to "Find Next" so it never reaches the terminal; Alt+G is + # unbound there and arrives as ('escape', 'g') — register it as a fallback. _editor_filter = Condition( lambda: not self._clarify_state and not self._approval_state and not self._sudo_state and not self._secret_state ) - kb.add('c-g', filter=_editor_filter)(kb.add('escape', 'g', filter=_editor_filter)(self._tui_handle_open_in_editor)) - - # --- Ctrl+S prompt stash ------------------------------------------- - # Park a half-written draft, send something else, then bring the draft - # back. Suppressed while a modal prompt owns the composer (sudo / - # secret / approval / clarify) so Ctrl+S can't stash a password. + # Ctrl+S prompt stash: park a draft, send something else, bring it back. Suppressed while + # a modal prompt owns the composer so Ctrl+S can't stash a password. _stash_filter = Condition( - lambda: not self._clarify_state - and not self._approval_state - and not self._sudo_state - and not self._secret_state - and not self._slash_confirm_state - and not self._model_picker_state + lambda: not self._clarify_state and not self._approval_state and not self._sudo_state + and not self._secret_state and not self._slash_confirm_state and not self._model_picker_state ) - _stash_panel_filter = Condition( - lambda: self._prompt_stash.panel_open and bool(len(self._prompt_stash)) - ) - + _stash_panel_filter = Condition(lambda: self._prompt_stash.panel_open and bool(len(self._prompt_stash))) kb.add('c-s', filter=_stash_filter)(self._tui_handle_prompt_stash) - kb.add('up', filter=_stash_panel_filter, eager=True)(self._tui_handle_stash_panel_up) - kb.add('down', filter=_stash_panel_filter, eager=True)(self._tui_handle_stash_panel_down) - kb.add('enter', filter=_stash_panel_filter, eager=True)(self._tui_handle_stash_panel_restore) - - kb.add('d', filter=_stash_panel_filter, eager=True)(kb.add('D', filter=_stash_panel_filter, eager=True)(self._tui_handle_stash_panel_delete)) - + kb.add('d', filter=_stash_panel_filter, eager=True)( + kb.add('D', filter=_stash_panel_filter, eager=True)(self._tui_handle_stash_panel_delete) + ) kb.add('escape', filter=_stash_panel_filter, eager=True)(self._tui_handle_stash_panel_close) - kb.add('tab', eager=True)(self._tui_handle_tab) - # --- Clarify tool: arrow-key navigation for multiple-choice questions --- - - kb.add('up', filter=Condition(lambda: bool(self._clarify_state) and not self._clarify_freetext))(self._tui_clarify_up) - - kb.add('down', filter=Condition(lambda: bool(self._clarify_state) and not self._clarify_freetext))(self._tui_clarify_down) - - # multi-select support: Space toggles the checkbox at the current cursor position - kb.add('space', filter=Condition(lambda: bool(self._clarify_state) and not self._clarify_freetext and self._clarify_state.get("multi_select")))(self._tui_clarify_toggle) - - # Batch clarify: Tab cycles the active question (any-order answering; - # moving onto an answered question lets the user re-answer it before - # the batch completes). Registered after the generic tab handler so - # this filtered binding wins while the batch panel is open. - kb.add('tab', filter=Condition(lambda: bool(self._clarify_state) and bool(self._clarify_state.get("questions")) and not self._clarify_freetext), eager=True)(self._tui_clarify_batch_tab) - - # Shift-Tab walks backwards through the questions. - kb.add('s-tab', filter=Condition(lambda: bool(self._clarify_state) and bool(self._clarify_state.get("questions")) and not self._clarify_freetext), eager=True)(self._tui_clarify_batch_backtab) - - # Number keys for quick clarify selection (1-9, 0 for 10th item) - + def _tui_bind_overlay_navigation(self, kb) -> None: + """Clarify / approval / slash-confirm / model picker / command palette navigation keys.""" + _clarify_nav = Condition(lambda: bool(self._clarify_state) and not self._clarify_freetext) + _clarify_batch = Condition( + lambda: bool(self._clarify_state) and bool(self._clarify_state.get("questions")) and not self._clarify_freetext + ) + kb.add('up', filter=_clarify_nav)(self._tui_clarify_up) + kb.add('down', filter=_clarify_nav)(self._tui_clarify_down) + # Multi-select: Space toggles the checkbox under the cursor. + kb.add('space', filter=Condition( + lambda: bool(self._clarify_state) and not self._clarify_freetext and self._clarify_state.get("multi_select") + ))(self._tui_clarify_toggle) + # Batch clarify: Tab / Shift-Tab cycle the active question (any-order answering; moving + # onto an answered question lets the user re-answer it). Registered after the generic + # tab handler so this filtered binding wins while the batch panel is open. + kb.add('tab', filter=_clarify_batch, eager=True)(self._tui_clarify_batch_tab) + kb.add('s-tab', filter=_clarify_batch, eager=True)(self._tui_clarify_batch_backtab) + # Number keys: 1-9 select items 0-8, 0 selects item 9 (10th). for _num in range(10): - # 1-9 select items 0-8, 0 selects item 9 (10thitem) _idx = 9 if _num == 0 else _num - 1 - kb.add(str(_num), filter=Condition(lambda: bool(self._clarify_state) and not self._clarify_freetext))(self._tui_make_clarify_number_handler(_idx)) + kb.add(str(_num), filter=_clarify_nav)(self._tui_make_clarify_number_handler(_idx)) - # --- Dangerous command approval: arrow-key navigation --- - - kb.add('up', filter=Condition(lambda: bool(self._approval_state)))(self._tui_approval_up) - - kb.add('down', filter=Condition(lambda: bool(self._approval_state)))(self._tui_approval_down) - - # --- Slash-command confirmation: arrow-key navigation --- - kb.add('up', filter=Condition(lambda: bool(self._slash_confirm_state)))(self._tui_slash_confirm_up) - - kb.add('down', filter=Condition(lambda: bool(self._slash_confirm_state)))(self._tui_slash_confirm_down) - - # --- /model picker: arrow-key navigation --- - kb.add('up', filter=Condition(lambda: bool(self._model_picker_state)))(self._tui_model_picker_up) - - kb.add('down', filter=Condition(lambda: bool(self._model_picker_state)))(self._tui_model_picker_down) + _approval = Condition(lambda: bool(self._approval_state)) + _slash_confirm = Condition(lambda: bool(self._slash_confirm_state)) + _picker = Condition(lambda: bool(self._model_picker_state)) + kb.add('up', filter=_approval)(self._tui_approval_up) + kb.add('down', filter=_approval)(self._tui_approval_down) + kb.add('up', filter=_slash_confirm)(self._tui_slash_confirm_up) + kb.add('down', filter=_slash_confirm)(self._tui_slash_confirm_down) + kb.add('up', filter=_picker)(self._tui_model_picker_up) + kb.add('down', filter=_picker)(self._tui_model_picker_down) def _model_picker_typing_active() -> bool: # Type-to-filter is only live on the model stage (concrete list). st = self._model_picker_state return bool(st) and st.get("stage") == "model" - # Printable ASCII (space through ~) narrows the model list as you type. - import string as _string - for _ch in _string.digits + _string.ascii_letters + "-_.:/ ": - kb.add(_ch, filter=Condition(_model_picker_typing_active))( - self._tui_make_model_filter_char_handler(_ch) - ) + _picker_typing = Condition(_model_picker_typing_active) + for _ch in _TYPING_CHARS: + kb.add(_ch, filter=_picker_typing)(self._tui_make_model_filter_char_handler(_ch)) + kb.add('backspace', filter=_picker_typing)(self._tui_model_picker_filter_backspace) + kb.add('escape', filter=_picker, eager=True)(self._tui_model_picker_escape) - kb.add('backspace', filter=Condition(_model_picker_typing_active))(self._tui_model_picker_filter_backspace) - - kb.add('escape', filter=Condition(lambda: bool(self._model_picker_state)), eager=True)(self._tui_model_picker_escape) - - # --- Ctrl+P command palette keybindings --- - def _palette_active() -> bool: - return bool(self._command_palette_state) - - kb.add('c-p', filter=Condition(lambda: not self._command_palette_state and not self._model_picker_state and not self._clarify_state and not self._approval_state and not self._slash_confirm_state and not self._sudo_state and not self._secret_state))(self._tui_open_command_palette) - - kb.add('up', filter=Condition(_palette_active))(self._tui_command_palette_up) - - kb.add('down', filter=Condition(_palette_active))(self._tui_command_palette_down) - - kb.add('enter', filter=Condition(_palette_active))(self._tui_command_palette_enter) - - kb.add('backspace', filter=Condition(_palette_active))(self._tui_command_palette_backspace) - - kb.add('escape', filter=Condition(_palette_active), eager=True)(self._tui_command_palette_escape) - - import string as _pstring - for _pch in _pstring.digits + _pstring.ascii_letters + "-_.:/ ": - kb.add(_pch, filter=Condition(_palette_active))(self._tui_make_palette_char_handler(_pch)) - - # Number keys for quick approval selection (1-9, 0 for 10th item) - - for _num in range(10): - # 1-9 select items 0-8, 0 selects item 9 (10th item) - _idx = 9 if _num == 0 else _num - 1 - kb.add(str(_num), filter=Condition(lambda: bool(self._approval_state)))(self._tui_make_approval_number_handler(_idx)) - - # Number keys for quick slash-confirm selection (1-9, 0 for 10th item) + _palette = Condition(lambda: bool(self._command_palette_state)) + kb.add('c-p', filter=Condition( + lambda: not self._command_palette_state and not self._model_picker_state and not self._clarify_state + and not self._approval_state and not self._slash_confirm_state and not self._sudo_state + and not self._secret_state + ))(self._tui_open_command_palette) + kb.add('up', filter=_palette)(self._tui_command_palette_up) + kb.add('down', filter=_palette)(self._tui_command_palette_down) + kb.add('enter', filter=_palette)(self._tui_command_palette_enter) + kb.add('backspace', filter=_palette)(self._tui_command_palette_backspace) + kb.add('escape', filter=_palette, eager=True)(self._tui_command_palette_escape) + for _pch in _TYPING_CHARS: + kb.add(_pch, filter=_palette)(self._tui_make_palette_char_handler(_pch)) for _num in range(10): _idx = 9 if _num == 0 else _num - 1 - kb.add(str(_num), filter=Condition(lambda: bool(self._slash_confirm_state)))(self._tui_make_slash_confirm_number_handler(_idx)) + kb.add(str(_num), filter=_approval)(self._tui_make_approval_number_handler(_idx)) + for _num in range(10): + _idx = 9 if _num == 0 else _num - 1 + kb.add(str(_num), filter=_slash_confirm)(self._tui_make_slash_confirm_number_handler(_idx)) - # --- History navigation: up/down browse history in normal input mode --- - # The TextArea is multiline, so by default up/down only move the cursor. - # Buffer.auto_up/auto_down handle both: cursor movement when multi-line, - # history browsing when on the first/last line (or single-line input). - _normal_input = Condition( - lambda: not self._clarify_state and not self._approval_state and not self._slash_confirm_state and not self._sudo_state and not self._secret_state and not self._model_picker_state and not self._command_palette_state - ) + def _tui_voice_record_key_sequence(self) -> tuple: + """Resolve the push-to-talk key (voice.record_key, default Ctrl+B) to a prompt_toolkit + key sequence and cache the UI label. - kb.add('up', filter=_normal_input)(self._tui_history_up) - - kb.add('down', filter=_normal_input)(self._tui_history_down) - - kb.add('c-l')(self._tui_handle_ctrl_l) - - kb.add('c-c')(self._tui_handle_ctrl_c) - - # Ctrl+Shift+C: no binding needed. Terminal emulators (GNOME Terminal, - # iTerm2, kitty, Windows Terminal, etc.) intercept Ctrl+Shift+C before - # the keystroke reaches the application's stdin — prompt_toolkit never - # sees it, and prompt_toolkit's key spec parser doesn't even recognise - # 'c-S-c' anyway (the Shift modifier is meaningless on control-sequence - # keys). #19884 added a handler for this; #19895 patched the resulting - # startup crash with try/except. Both were based on a misreading of how - # terminal key events propagate. Deleting the dead handler outright. - - kb.add('c-q')(self._tui_handle_ctrl_q) - - kb.add('c-d')(self._tui_handle_ctrl_d) - - _modal_prompt_active = Condition( - lambda: bool(self._secret_state or self._sudo_state or self._slash_confirm_state) - ) - - kb.add('escape', filter=_modal_prompt_active, eager=True)(self._tui_handle_escape_modal) - - kb.add('escape', 'escape', filter=~_modal_prompt_active)(self._tui_handle_double_escape) - - kb.add('c-z')(self._tui_handle_ctrl_z) - - # Voice push-to-talk key: configurable via config.yaml (voice.record_key) - # Default: Ctrl+B (avoids conflict with Ctrl+R readline reverse-search). - # Config spellings (ctrl/control/alt/option/opt) are normalized to - # prompt_toolkit's c-x / a-x format via ``normalize_voice_record_key_for_prompt_toolkit`` - # so the same config value binds identically in the TUI and CLI - # (Copilot round-9 review on #19835). ``super``/``win``/``windows`` - # configs silently fall back to the default here since prompt_toolkit - # has no super modifier — log a warning so users notice the - # TUI/CLI split instead of a silent mismatch (round-11). + Config spellings (ctrl/control/alt/option/opt) are normalized to c-x / a-x so the same + value binds identically in TUI and CLI. super/win/windows silently fall back to the + default (prompt_toolkit has no super modifier) — warn so users notice the split. The + label cache uses the same ``_raw_key`` that drives the binding, so status/placeholder/ + recording-hint renders can never drift from the live key even if the config is edited + mid-session. + """ + from cli import logger _raw_key: object = "ctrl+b" try: from hermes_cli.config import load_config from hermes_cli.voice import ( normalize_voice_record_key_for_prompt_toolkit, pt_key_to_sequence, - voice_record_key_from_config, - ) + voice_record_key_from_config) _raw_key = voice_record_key_from_config(load_config()) _voice_key = normalize_voice_record_key_for_prompt_toolkit(_raw_key) if ( isinstance(_raw_key, str) and _raw_key.strip().lower().split("+", 1)[0].strip() in {"super", "win", "windows"} - and _voice_key == "c-b" - ): + and _voice_key == "c-b"): logger.warning( "voice.record_key %r uses a TUI-only modifier (super/win); " "CLI fell back to Ctrl+B. Use ctrl+ or alt+ for " "cross-runtime parity.", - _raw_key, - ) + _raw_key) except Exception: _voice_key = "c-b" - - # Cache the UI label here — same ``_raw_key`` that drives the - # prompt_toolkit binding below. Every status / placeholder / - # recording-hint render reads this cached value so display can - # never drift from the live keybinding even if the user edits - # voice.record_key mid-session (Copilot round-13 on #19835). self.set_voice_record_key_cache(_raw_key) + return pt_key_to_sequence(_voice_key) - kb.add(*pt_key_to_sequence(_voice_key))(self._tui_handle_voice_record) - from prompt_toolkit.keys import Keys - - kb.add(Keys.BracketedPaste, eager=True)(self._tui_handle_paste) - - kb.add('c-v')(self._tui_handle_ctrl_v) - - kb.add('escape', 'v')(self._tui_handle_alt_v) - return kb + def _tui_overlay_widget(self, fragments_fn, state_attr: str): + """Wrapped, auto-sized panel shown while ``self.`` is not None.""" + return ConditionalContainer( + Window(FormattedTextControl(fragments_fn), wrap_lines=True), + filter=Condition(lambda: getattr(self, state_attr) is not None)) def _tui_build_layout(self, kb): """Build the TUI widgets, Layout and Style; registers wrapper keybindings on ``kb``.""" cli_ref = self input_area = self._tui_build_input_area() - - # Hint line above input: shown only for interactive prompts that need - # extra instructions (sudo countdown, approval navigation, clarify). - # The agent-running interrupt hint is now an inline placeholder above. - spinner_widget = Window( - content=FormattedTextControl(self._tui_spinner_text), - height=self._tui_spinner_height, - wrap_lines=True, + content=FormattedTextControl(self._tui_spinner_text), height=self._tui_spinner_height, wrap_lines=True, ) - - # Petdex mascot — right-aligned Kitty placeholder or half-block sprite - # above the prompt. Collapses to height 0 when no pet is enabled. - # The animation thread queues virtual Kitty frames; after_render - # writes them out-of-band while prompt_toolkit owns the placeholder grid. + # Petdex mascot — right-aligned Kitty placeholder or half-block sprite above the prompt; + # height 0 when no pet is enabled. The animation thread queues virtual Kitty frames; + # after_render writes them out-of-band while prompt_toolkit owns the placeholder grid. self._pet_widget = Window( - content=FormattedTextControl(self._pet_fragments), - height=self._pet_widget_height, - align=WindowAlign.RIGHT, + content=FormattedTextControl(self._pet_fragments), height=self._pet_widget_height, align=WindowAlign.RIGHT, ) - - spacer = Window( - content=FormattedTextControl(self._tui_hint_text), - height=self._tui_hint_height, - ) - - # --- Clarify tool: dynamic display widget for questions + choices --- - - clarify_widget = ConditionalContainer( - Window( - FormattedTextControl(self._get_clarify_display_fragments), - wrap_lines=True, - ), - filter=Condition(lambda: cli_ref._clarify_state is not None), - ) - - # --- Sudo password: display widget --- - - sudo_widget = ConditionalContainer( - Window( - FormattedTextControl(self._get_sudo_display_fragments), - wrap_lines=True, - ), - filter=Condition(lambda: cli_ref._sudo_state is not None), - ) - - secret_widget = ConditionalContainer( - Window( - FormattedTextControl(self._get_secret_display_fragments), - wrap_lines=True, - ), - filter=Condition(lambda: cli_ref._secret_state is not None), - ) - - # --- Dangerous command approval: display widget --- - - approval_widget = ConditionalContainer( - Window( - FormattedTextControl(self._get_approval_display_fragments), - wrap_lines=True, - ), - filter=Condition(lambda: cli_ref._approval_state is not None), - ) - - slash_confirm_widget = ConditionalContainer( - Window( - FormattedTextControl(self._get_slash_confirm_display_fragments), - wrap_lines=True, - ), - filter=Condition(lambda: cli_ref._slash_confirm_state is not None), - ) - - # --- /model picker: display widget --- - - model_picker_widget = ConditionalContainer( - Window( - FormattedTextControl(self._get_model_picker_display_fragments), - wrap_lines=True, - ), - filter=Condition(lambda: cli_ref._model_picker_state is not None), - ) - - # --- Ctrl+P command palette: display widget --- - - command_palette_widget = ConditionalContainer( - Window( - FormattedTextControl(self._get_command_palette_display_fragments), - wrap_lines=True, - ), - filter=Condition(lambda: cli_ref._command_palette_state is not None), - ) - - # Horizontal rules above and below the input. - # On narrow/mobile terminals we keep the top separator for structure but - # hide the bottom one to recover a full row for conversation content. - input_rule_top = Window( - char='─', - height=lambda: cli_ref._tui_input_rule_height("top"), - style='class:input-rule', - ) - input_rule_bot = Window( - char='─', - height=lambda: cli_ref._tui_input_rule_height("bottom"), - style='class:input-rule', - ) - - # Image attachment indicator — shows badges like [📎 Image #1] above input - cli_ref = self - + # Hint line above the input: only for interactive prompts that need extra instructions + # (sudo countdown, approval navigation, clarify); the agent-running hint is the placeholder. + spacer = Window(content=FormattedTextControl(self._tui_hint_text), height=self._tui_hint_height) + clarify_widget = self._tui_overlay_widget(self._get_clarify_display_fragments, "_clarify_state") + sudo_widget = self._tui_overlay_widget(self._get_sudo_display_fragments, "_sudo_state") + secret_widget = self._tui_overlay_widget(self._get_secret_display_fragments, "_secret_state") + approval_widget = self._tui_overlay_widget(self._get_approval_display_fragments, "_approval_state") + slash_confirm_widget = self._tui_overlay_widget(self._get_slash_confirm_display_fragments, "_slash_confirm_state") + model_picker_widget = self._tui_overlay_widget(self._get_model_picker_display_fragments, "_model_picker_state") + command_palette_widget = self._tui_overlay_widget( + self._get_command_palette_display_fragments, "_command_palette_state") + # Rules above/below the input; narrow terminals hide the bottom one to recover a row. + input_rule_top = Window(char='─', height=lambda: cli_ref._tui_input_rule_height("top"), style='class:input-rule') + input_rule_bot = Window(char='─', height=lambda: cli_ref._tui_input_rule_height("bottom"), style='class:input-rule') image_bar = Window( content=FormattedTextControl(self._tui_image_bar_fragments), - height=Condition(lambda: bool(cli_ref._attached_images)), - ) - - # Persistent voice mode status bar (visible only when voice mode is on) - + height=Condition(lambda: bool(cli_ref._attached_images))) voice_status_bar = ConditionalContainer( - Window( - FormattedTextControl(self._tui_voice_status_fragments), - height=1, - ), - filter=Condition(lambda: cli_ref._voice_mode), - ) - + Window(FormattedTextControl(self._tui_voice_status_fragments), height=1), + filter=Condition(lambda: cli_ref._voice_mode)) status_bar = ConditionalContainer( Window( content=FormattedTextControl(lambda: cli_ref._get_status_bar_fragments()), height=1, - # Prevent fragments that overflow the terminal width from - # wrapping onto a second line, which causes the status bar to - # appear duplicated (one full + one partial row) during long - # sessions, especially on SSH where shutil.get_terminal_size - # may return stale values. _get_status_bar_fragments now reads - # width from prompt_toolkit's own output object, so fragments - # will always fit; wrap_lines=False is the belt-and-suspenders - # guard against any future width mismatch. - wrap_lines=False, - ), + # wrap_lines=False: fragments overflowing the width must never wrap onto a second + # row (looked like a duplicated status bar on long SSH sessions with stale + # shutil sizes). _get_status_bar_fragments reads prompt_toolkit's own width, so + # this is the belt-and-suspenders guard. + wrap_lines=False), filter=Condition( lambda: cli_ref._status_bar_visible - and not getattr(cli_ref, "_status_bar_suppressed_after_resize", False) - ), - ) - - # Stash browse panel — appears just above the status bar when the user - # presses Ctrl+S on an empty composer with 2+ stashed drafts. - + and not getattr(cli_ref, "_status_bar_suppressed_after_resize", False))) + # Stash browse panel — just above the status bar, Ctrl+S on an empty composer with 2+ drafts. self._stash_panel_widget = ConditionalContainer( - Window( - FormattedTextControl(self._get_stash_panel_display_fragments), - wrap_lines=False, - ), - filter=Condition( - lambda: cli_ref._prompt_stash.panel_open - and bool(len(cli_ref._prompt_stash)) - ), + Window(FormattedTextControl(self._get_stash_panel_display_fragments), wrap_lines=False), + filter=Condition(lambda: cli_ref._prompt_stash.panel_open and bool(len(cli_ref._prompt_stash))), ) - - # Allow wrapper CLIs to register extra keybindings. self._register_extra_tui_keybindings(kb, input_area=input_area) - - # Layout: interactive prompt widgets + ruled input at bottom. - # The sudo, approval, and clarify widgets appear above the input when - # the corresponding interactive prompt is active. - completions_menu = CompletionsMenu(max_height=12, scroll_offset=1) - - layout = Layout( - HSplit( - self._build_tui_layout_children( - sudo_widget=sudo_widget, - secret_widget=secret_widget, - approval_widget=approval_widget, - slash_confirm_widget=slash_confirm_widget, - clarify_widget=clarify_widget, - model_picker_widget=model_picker_widget, - command_palette_widget=command_palette_widget, - spinner_widget=spinner_widget, - spacer=spacer, - status_bar=status_bar, - input_rule_top=input_rule_top, - image_bar=image_bar, - input_area=input_area, - input_rule_bot=input_rule_bot, - voice_status_bar=voice_status_bar, - completions_menu=completions_menu, - ) - ) - ) - + layout = Layout(HSplit(self._build_tui_layout_children( + sudo_widget=sudo_widget, + secret_widget=secret_widget, + approval_widget=approval_widget, + slash_confirm_widget=slash_confirm_widget, + clarify_widget=clarify_widget, + model_picker_widget=model_picker_widget, + command_palette_widget=command_palette_widget, + spinner_widget=spinner_widget, + spacer=spacer, + status_bar=status_bar, + input_rule_top=input_rule_top, + image_bar=image_bar, + input_area=input_area, + input_rule_bot=input_rule_bot, + voice_status_bar=voice_status_bar, + completions_menu=CompletionsMenu(max_height=12, scroll_offset=1)))) self._tui_set_base_style() - style = PTStyle.from_dict(self._build_tui_style_dict()) - return (layout, style) + return layout, PTStyle.from_dict(self._build_tui_style_dict()) def _tui_build_input_area(self): - """Build the multi-line prompt TextArea with slash completion, paste-collapse tracking, and placeholder processors.""" + """Multi-line prompt TextArea with slash completion, paste-collapse tracking and + placeholder/password processors.""" from cli import _estimate_tui_input_height, get_skill_bundles, get_skill_commands - # Dynamic prompt: shows Hermes symbol when agent is working, - # or answer prompt when clarify freetext mode is active. + from prompt_toolkit.auto_suggest import AutoSuggestFromHistory + from prompt_toolkit.completion import ThreadedCompleter cli_ref = self def get_prompt(): return cli_ref._get_tui_prompt_fragments() - # Create the input area with multiline (Alt+Enter), autocomplete, and paste handling - from prompt_toolkit.auto_suggest import AutoSuggestFromHistory - from prompt_toolkit.completion import ThreadedCompleter - - _completer = SlashCommandCompleter( skill_commands_provider=lambda: get_skill_commands(), command_filter=cli_ref._command_available, - skill_bundles_provider=lambda: get_skill_bundles(), - ) + skill_bundles_provider=lambda: get_skill_bundles()) input_area = TextArea( height=Dimension(min=1, max=8, preferred=1), prompt=get_prompt, @@ -2958,66 +2058,43 @@ class CLITuiMixin: wrap_lines=True, read_only=Condition(lambda: bool(cli_ref._command_blocks_input)), history=FileHistory(str(self._history_file)), - # complete_while_typing fires the completer on every keystroke. The - # completer does blocking work — fuzzy @-file indexing shells out to - # rg/fd (up to a 2s timeout) and path completion hits os.listdir/stat - # — so running it inline would stall the render loop on each key (very - # noticeable on WSL2/slow filesystems). ThreadedCompleter moves it off - # the UI event loop, keeping typing responsive. + # The completer does blocking work (fuzzy @-file indexing shells out to rg/fd with a + # 2s timeout; path completion hits os.listdir/stat), so complete_while_typing inline + # would stall the render loop per keystroke (WSL2/slow FS). ThreadedCompleter moves + # it off the UI event loop. completer=ThreadedCompleter(_completer), complete_while_typing=True, - auto_suggest=SlashCommandAutoSuggest( - history_suggest=AutoSuggestFromHistory(), - completer=_completer, - ), + auto_suggest=SlashCommandAutoSuggest(history_suggest=AutoSuggestFromHistory(), completer=_completer), ) - # Keep prompt_toolkit on its simple tempfile path. Setting - # buffer.tempfile = "prompt.md" triggers its complex-tempfile branch, - # which tries to mkdir() the mkdtemp() directory again and raises - # EEXIST. The suffix keeps markdown highlighting without that bug. + # Keep prompt_toolkit on its simple tempfile path: buffer.tempfile = "prompt.md" takes + # the complex-tempfile branch that re-mkdir()s the mkdtemp() dir and raises EEXIST. input_area.buffer.tempfile_suffix = '.md' - # Dynamic height: accounts for both explicit newlines AND visual - # wrapping of long lines so the input area always fits its content. def _input_height(): + # Accounts for explicit newlines AND visual wrapping so the area fits its content. try: from prompt_toolkit.application import get_app - doc = input_area.buffer.document try: terminal_columns = get_app().output.get_size().columns except Exception: terminal_columns = shutil.get_terminal_size((80, 24)).columns - return _estimate_tui_input_height( - doc.lines, - self._get_tui_prompt_text(), - terminal_columns, - ) + return _estimate_tui_input_height(doc.lines, self._get_tui_prompt_text(), terminal_columns) except Exception: return 1 input_area.window.height = _input_height - - # Paste collapsing: detect large pastes and save to temp file - self._tui_paste_counter = [0] - self._tui_prev_text_len = [0] - self._tui_prev_newline_count = [0] - self._tui_paste_just_collapsed = [False] + # Paste collapsing state (large pastes are saved to a file and replaced by a placeholder). + self._tui_paste_counter = 0 + self._tui_prev_text_len = 0 + self._tui_prev_newline_count = 0 + self._tui_paste_just_collapsed = False self._skip_paste_collapse = False - input_area.buffer.on_text_changed += self._tui_on_text_changed - - # --- Input processors for password masking and inline placeholder --- - - # Mask input with '*' when the sudo password prompt is active - input_area.control.input_processors.append( - ConditionalProcessor( - PasswordProcessor(), - filter=Condition( - lambda: bool(cli_ref._sudo_state) or bool(cli_ref._secret_state) - ), - ) - ) + # Mask input with '*' while a sudo/secret prompt is active. + input_area.control.input_processors.append(ConditionalProcessor( + PasswordProcessor(), + filter=Condition(lambda: bool(cli_ref._sudo_state) or bool(cli_ref._secret_state)))) class _PlaceholderProcessor(Processor): """Render grayed-out placeholder text inside the input when empty.""" @@ -3028,7 +2105,7 @@ class CLITuiMixin: if not ti.document.text and ti.lineno == 0: text = self._get_text() if text: - # Append after existing fragments (preserves the ❯ prompt) + # Append after existing fragments (preserves the ❯ prompt). return Transformation(fragments=ti.fragments + [('class:placeholder', text)]) return Transformation(fragments=ti.fragments) @@ -3037,13 +2114,9 @@ class CLITuiMixin: def _tui_set_base_style(self): """Populate ``self._tui_style_base`` (skin-aware defaults the style dict is built from).""" - # Style for the application self._tui_style_base = { - # Input area / prompt: empty style strings inherit the - # terminal's default foreground/background, so the typed - # text is readable in both light and dark Terminal.app - # color schemes. (Hardcoding a near-white #FFF8DC made - # input invisible on light backgrounds.) + # Empty input/prompt styles inherit the terminal's own fg/bg so typed text is readable + # in both light and dark schemes (a hardcoded near-white was invisible on light). 'input-area': '', 'placeholder': '#888888 italic', 'prompt': '', @@ -3058,16 +2131,13 @@ class CLITuiMixin: 'status-bar-critical': 'bg:#1a1a2e #FF6B6B bold', 'status-bar-yolo': 'bg:#1a1a2e #FF4444 bold', 'status-bar-session-title': 'bg:#FFD700 #1a1a2e bold', - # Bronze horizontal rules around the input area 'input-rule': '#CD7F32', - # Clipboard image attachment badges 'image-badge': '#87CEEB bold', 'completion-menu': 'bg:#1a1a2e #FFF8DC', 'completion-menu.completion': 'bg:#1a1a2e #FFF8DC', 'completion-menu.completion.current': 'bg:#333355 #FFD700', 'completion-menu.meta.completion': 'bg:#1a1a2e #888888', 'completion-menu.meta.completion.current': 'bg:#333355 #FFBF00', - # Clarify question panel 'clarify-border': '#CD7F32', 'clarify-title': '#FFD700 bold', 'clarify-question': '#FFF8DC bold', @@ -3076,22 +2146,18 @@ class CLITuiMixin: 'clarify-active-other': '#FFD700 italic', 'clarify-answer': '#98FB98', 'clarify-countdown': '#CD7F32', - # Sudo password panel 'sudo-prompt': '#FF6B6B bold', 'sudo-border': '#CD7F32', 'sudo-title': '#FF6B6B bold', 'sudo-text': '#FFF8DC', - # Dangerous command approval panel 'approval-border': '#CD7F32', 'approval-title': '#FF8C00 bold', 'approval-desc': '#FFF8DC bold', 'approval-cmd': '#AAAAAA italic', 'approval-choice': '#AAAAAA', 'approval-selected': '#FFD700 bold', - # Voice mode 'voice-prompt': '#87CEEB', 'voice-recording': '#FF4444 bold', 'voice-processing': '#FFA500 italic', 'voice-status': 'bg:#1a1a2e #87CEEB', - 'voice-status-recording': 'bg:#1a1a2e #FF4444 bold', - } + 'voice-status-recording': 'bg:#1a1a2e #FF4444 bold'} diff --git a/tests/cli/test_compress_type_ahead.py b/tests/cli/test_compress_type_ahead.py index cb4e492f46..0bf1dba3ad 100644 --- a/tests/cli/test_compress_type_ahead.py +++ b/tests/cli/test_compress_type_ahead.py @@ -131,15 +131,14 @@ def test_handle_enter_never_gates_on_command_running(): cli_path = Path(__file__).resolve().parents[2] / "hermes_cli" / "cli_tui_mixin.py" tree = ast.parse(cli_path.read_text(encoding="utf-8")) - target = None - for node in ast.walk(tree): - if isinstance(node, ast.FunctionDef) and node.name == "_tui_handle_enter": - target = node - break - assert target is not None, "_tui_handle_enter not found in cli_tui_mixin.py" + handlers = ("_tui_handle_enter", "_tui_enter_inline_command", "_tui_enter_overlay", + "_tui_enter_clarify_freetext", "_tui_enter_clarify_choice", "_tui_enter_while_busy") + targets = [n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef) and n.name in handlers] + assert {n.name for n in targets} == set(handlers), "Enter handlers not found in cli_tui_mixin.py" offenders = [ node.attr + for target in targets for node in ast.walk(target) if isinstance(node, ast.Attribute) and node.attr in {"_command_running", "_command_blocks_input"} diff --git a/tests/cli/test_steer_inline_repaint_34569.py b/tests/cli/test_steer_inline_repaint_34569.py index 2eebff8c68..3d2fe3d7c1 100644 --- a/tests/cli/test_steer_inline_repaint_34569.py +++ b/tests/cli/test_steer_inline_repaint_34569.py @@ -25,18 +25,17 @@ import ast from pathlib import Path -def _load_handle_enter_node() -> ast.FunctionDef: - """Extract the ``handle_enter`` nested function node from cli.py.""" +ENTER_HANDLERS = ("_tui_handle_enter", "_tui_enter_inline_command", "_tui_enter_overlay", + "_tui_enter_clarify_freetext", "_tui_enter_clarify_choice") + + +def _load_handle_enter_node() -> ast.Module: + """Return a synthetic module holding the Enter handler and its phase helpers.""" cli_path = Path(__file__).resolve().parents[2] / "hermes_cli" / "cli_tui_mixin.py" tree = ast.parse(cli_path.read_text(encoding="utf-8")) - - target = None - for node in ast.walk(tree): - if isinstance(node, ast.FunctionDef) and node.name == "_tui_handle_enter": - target = node - break - assert target is not None, "_tui_handle_enter not found in cli_tui_mixin.py" - return target + found = [n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef) and n.name in ENTER_HANDLERS] + assert {n.name for n in found} == set(ENTER_HANDLERS), "Enter handlers not found in cli_tui_mixin.py" + return ast.Module(body=found, type_ignores=[]) def _is_buffer_reset(node: ast.stmt) -> bool: @@ -61,7 +60,7 @@ def _is_invalidate(node: ast.stmt) -> bool: return isinstance(func, ast.Attribute) and func.attr == "invalidate" -def _collect_reset_blocks(func: ast.FunctionDef) -> list[list[ast.stmt]]: +def _collect_reset_blocks(func: ast.AST) -> list[list[ast.stmt]]: """Find every statement sequence (a block body/orelse/finalbody) within ``handle_enter`` that contains a ``buffer.reset()`` call.""" blocks: list[list[ast.stmt]] = [] From db0d5341bc7de6512dfd5bb82423d85cc64f1bcf Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:45:19 -0700 Subject: [PATCH 2/2] refactor(hermes_cli): _Panel fragment accumulator replaces per-line border boilerplate in TUI overlays --- hermes_cli/cli_tui_mixin.py | 240 +++++++++++++++++++++--------------- 1 file changed, 143 insertions(+), 97 deletions(-) diff --git a/hermes_cli/cli_tui_mixin.py b/hermes_cli/cli_tui_mixin.py index 8ca107f7a6..a75739931a 100644 --- a/hermes_cli/cli_tui_mixin.py +++ b/hermes_cli/cli_tui_mixin.py @@ -60,18 +60,30 @@ def _term_rows() -> int: return shutil.get_terminal_size((100, 24)).lines -def _panel_open(lines, border: str, box_width: int, title: str = "", title_style: str = "") -> None: - """Top border; with ``title`` the title is inlined into the rule (``╭─ Title ───╮``).""" - if title: - lines.append((border, "╭─ ")) - lines.append((title_style, title)) - lines.append((border, " " + ("─" * max(0, box_width - len(title) - 3)) + "╮\n")) - else: - lines.append((border, "╭" + ("─" * box_width) + "╮\n")) +class _Panel: + """Fragment accumulator for one bordered overlay panel (``(style, text)`` tuples).""" + def __init__(self, border: str, box_width: int, title: str = "", title_style: str = ""): + from cli import _append_blank_panel_line, _append_panel_line + self.lines, self.border, self.width = [], border, box_width + self._row, self._blank = _append_panel_line, _append_blank_panel_line + if title: + # Title inlined into the top rule: ``╭─ Title ───╮``. + self.lines.append((border, "╭─ ")) + self.lines.append((title_style, title)) + self.lines.append((border, " " + ("─" * max(0, box_width - len(title) - 3)) + "╮\n")) + else: + self.lines.append((border, "╭" + ("─" * box_width) + "╮\n")) -def _panel_close(lines, border: str, box_width: int) -> None: - lines.append((border, "╰" + ("─" * box_width) + "╯\n")) + def row(self, style: str, text: str) -> None: + self._row(self.lines, self.border, style, text, self.width) + + def blank(self) -> None: + self._blank(self.lines, self.border, self.width) + + def close(self) -> list: + self.lines.append((self.border, "╰" + ("─" * self.width) + "╯\n")) + return self.lines def _wrap_rows(wrap, items, width, indent) -> list[tuple[int, str]]: @@ -94,7 +106,7 @@ class CLITuiMixin: def _get_slash_confirm_display_fragments(self): """Render the /new-/clear-style confirmation panel.""" - from cli import _append_blank_panel_line, _append_panel_line, _panel_box_width, _wrap_panel_text_keep_ws + from cli import _panel_box_width, _wrap_panel_text_keep_ws state = self._slash_confirm_state if not state: return [] @@ -122,20 +134,18 @@ class CLITuiMixin: if len(detail_wrapped) > max_detail_rows: detail_wrapped = detail_wrapped[:max(1, max_detail_rows - 1)] + ["… (detail truncated)"] - lines = [] - _panel_open(lines, 'class:approval-border', box_width) - _append_panel_line(lines, 'class:approval-border', 'class:approval-title', title, box_width) - _append_blank_panel_line(lines, 'class:approval-border', box_width) + panel = _Panel('class:approval-border', box_width) + panel.row('class:approval-title', title) + panel.blank() for wrapped in detail_wrapped: - _append_panel_line(lines, 'class:approval-border', 'class:approval-desc', wrapped, box_width) - _append_blank_panel_line(lines, 'class:approval-border', box_width) + panel.row('class:approval-desc', wrapped) + panel.blank() for idx, wrapped in choice_wrapped: style = 'class:approval-selected' if idx == selected else 'class:approval-choice' - _append_panel_line(lines, 'class:approval-border', style, wrapped, box_width) - _append_blank_panel_line(lines, 'class:approval-border', box_width) - _append_panel_line(lines, 'class:approval-border', 'class:approval-cmd', footer, box_width) - _panel_close(lines, 'class:approval-border', box_width) - return lines + panel.row(style, wrapped) + panel.blank() + panel.row('class:approval-cmd', footer) + return panel.close() def _get_approval_display_fragments(self): """Render the dangerous-command approval panel. @@ -144,7 +154,7 @@ class CLITuiMixin: or with a long (tirith multi-paragraph) description. The description sits at the bottom and is truncated to the remaining row budget, so HSplit never clips approve/deny off-screen. """ - from cli import _append_blank_panel_line, _append_panel_line, _panel_box_width, _wrap_panel_text_keep_ws + from cli import _panel_box_width, _wrap_panel_text_keep_ws state = self._approval_state if not state: return [] @@ -160,7 +170,8 @@ class CLITuiMixin: preview_lines.extend(wrap(command, 60)) for i, choice in enumerate(choices): prefix = '❯ ' if i == selected else ' ' - preview_lines.extend(wrap(f"{prefix}{_APPROVAL_CHOICE_LABELS.get(choice, choice)}", 60, subsequent_indent=" ")) + label = _APPROVAL_CHOICE_LABELS.get(choice, choice) + preview_lines.extend(wrap(f"{prefix}{label}", 60, subsequent_indent=" ")) box_width = _panel_box_width(title, preview_lines) inner_text_width = max(8, box_width - 2) @@ -201,25 +212,23 @@ class CLITuiMixin: # Render title → command → choices → description; description last so any overflow # clips the least-critical content, never the command or choices. - lines = [] - _panel_open(lines, 'class:approval-border', box_width) - _append_panel_line(lines, 'class:approval-border', 'class:approval-title', title, box_width) + panel = _Panel('class:approval-border', box_width) + panel.row('class:approval-title', title) if not use_compact_chrome: - _append_blank_panel_line(lines, 'class:approval-border', box_width) + panel.blank() for wrapped in cmd_wrapped: - _append_panel_line(lines, 'class:approval-border', 'class:approval-cmd', wrapped, box_width) + panel.row('class:approval-cmd', wrapped) if not use_compact_chrome: - _append_blank_panel_line(lines, 'class:approval-border', box_width) + panel.blank() for i, wrapped in choice_wrapped: style = 'class:approval-selected' if i == selected else 'class:approval-choice' - _append_panel_line(lines, 'class:approval-border', style, wrapped, box_width) + panel.row(style, wrapped) if desc_wrapped: if not use_compact_chrome: - _append_blank_panel_line(lines, 'class:approval-border', box_width) + panel.blank() for wrapped in desc_wrapped: - _append_panel_line(lines, 'class:approval-border', 'class:approval-desc', wrapped, box_width) - _panel_close(lines, 'class:approval-border', box_width) - return lines + panel.row('class:approval-desc', wrapped) + return panel.close() def _get_tui_prompt_symbols(self) -> tuple[str, str]: """Return ``(normal_prompt, state_suffix)`` for the active skin. @@ -406,7 +415,7 @@ class CLITuiMixin: """Batch (multi-question) clarify panel: "N questions" header, one status line per question (✓ answered → answer / ▸ active / · pending), and the active question's numbered choices (+ Other) expanded beneath its status line.""" - from cli import _append_panel_line, _panel_box_width, _wrap_panel_text + from cli import _panel_box_width, _wrap_panel_text questions_list = state.get("questions") or [] answers = state.get("answers") or {} answer_meta = state.get("answer_meta") or {} @@ -429,7 +438,8 @@ class CLITuiMixin: rows.append((row_style, wrapped)) if answered: # Locked answer on its own line/color so it stays readable while Tab-walking. - for wrapped in _wrap_panel_text(f" {answers[entry['qid']]}", width, subsequent_indent=" "): + answer = f" {answers[entry['qid']]}" + for wrapped in _wrap_panel_text(answer, width, subsequent_indent=" "): rows.append(('class:clarify-answer', wrapped)) if idx != active: continue @@ -437,7 +447,8 @@ class CLITuiMixin: cursor = "❯" if i == selected and not freetext else " " cb = ("[x] " if i in selected_indices else "[ ] ") if multi_select else "" style = 'class:clarify-selected' if i == selected and not freetext else 'class:clarify-choice' - for wrapped in _wrap_panel_text(f" {cursor} {cb}{_num_prefix(i)}. {choice}", width, subsequent_indent=" "): + label = f" {cursor} {cb}{_num_prefix(i)}. {choice}" + for wrapped in _wrap_panel_text(label, width, subsequent_indent=" "): rows.append((style, wrapped)) if choices: other_idx = len(choices) @@ -460,7 +471,8 @@ class CLITuiMixin: for wrapped in _wrap_panel_text(other_label, width, subsequent_indent=" "): rows.append((other_style, wrapped)) elif freetext: - for wrapped in _wrap_panel_text(" Type your answer in the prompt below, then press Enter.", width): + guidance = " Type your answer in the prompt below, then press Enter." + for wrapped in _wrap_panel_text(guidance, width): rows.append(('class:clarify-active-other', wrapped)) return rows @@ -468,13 +480,11 @@ class CLITuiMixin: box_width = _panel_box_width(title, [header] + [text for _, text in preview_rows]) rows = _status_rows(max(8, box_width - 2)) - lines = [] - _panel_open(lines, 'class:clarify-border', box_width, title, 'class:clarify-title') - _append_panel_line(lines, 'class:clarify-border', 'class:clarify-question', header, box_width) + panel = _Panel('class:clarify-border', box_width, title, 'class:clarify-title') + panel.row('class:clarify-question', header) for style, text in rows: - _append_panel_line(lines, 'class:clarify-border', style, text, box_width) - _panel_close(lines, 'class:clarify-border', box_width) - return lines + panel.row(style, text) + return panel.close() def _get_clarify_display_fragments(self): """Clarify question/choices panel. @@ -482,7 +492,7 @@ class CLITuiMixin: Layout priority: choices + the Other option must always render even for a very long question; the question is budgeted to the rows left over and truncated with a marker. """ - from cli import _append_blank_panel_line, _append_panel_line, _panel_box_width, _wrap_panel_text + from cli import _panel_box_width, _wrap_panel_text state = self._clarify_state if not state: return [] @@ -543,23 +553,22 @@ class CLITuiMixin: # rendered question never exceeds max_question_rows. question_wrapped = question_wrapped[:max(0, max_question_rows - 1)] + ["… (question truncated)"] - lines = [] - _panel_open(lines, 'class:clarify-border', box_width, title, 'class:clarify-title') + panel = _Panel('class:clarify-border', box_width, title, 'class:clarify-title') if not use_compact_chrome: - _append_blank_panel_line(lines, 'class:clarify-border', box_width) + panel.blank() for wrapped in question_wrapped: - _append_panel_line(lines, 'class:clarify-border', 'class:clarify-question', wrapped, box_width) + panel.row('class:clarify-question', wrapped) if not use_compact_chrome: - _append_blank_panel_line(lines, 'class:clarify-border', box_width) + panel.blank() if freetext and not choices: for wrapped in other_wrapped: - _append_panel_line(lines, 'class:clarify-border', 'class:clarify-choice', wrapped, box_width) + panel.row('class:clarify-choice', wrapped) if not use_compact_chrome: - _append_blank_panel_line(lines, 'class:clarify-border', box_width) + panel.blank() if choices: for i, wrapped in choice_wrapped: style = 'class:clarify-selected' if i == selected and not freetext else 'class:clarify-choice' - _append_panel_line(lines, 'class:clarify-border', style, wrapped, box_width) + panel.row(style, wrapped) if selected == other_idx and not freetext: other_style = 'class:clarify-selected' elif freetext: @@ -567,11 +576,10 @@ class CLITuiMixin: else: other_style = 'class:clarify-choice' for wrapped in other_wrapped: - _append_panel_line(lines, 'class:clarify-border', other_style, wrapped, box_width) + panel.row(other_style, wrapped) if not use_compact_chrome: - _append_blank_panel_line(lines, 'class:clarify-border', box_width) - _panel_close(lines, 'class:clarify-border', box_width) - return lines + panel.blank() + return panel.close() def _render_scroll_list_panel(self, state, title, hint, labels, *, min_width, max_width, indent): """Titled panel with a hint row and a scrolling selectable list (model picker, palette). @@ -580,7 +588,7 @@ class CLITuiMixin: the terminal rows or the bottom border and trailing items get clipped on long lists (e.g. Ollama Cloud's 36+ models). ``state["_scroll_offset"]`` is updated in place. """ - from cli import HermesCLI, _append_blank_panel_line, _append_panel_line, _panel_box_width, _wrap_panel_text + from cli import HermesCLI, _panel_box_width, _wrap_panel_text box_width = _panel_box_width(title, [hint] + labels, min_width=min_width, max_width=max_width) inner_text_width = max(8, box_width - 6) selected = state.get("selected", 0) @@ -593,19 +601,17 @@ class CLITuiMixin: selected, state.get("_scroll_offset", 0), len(labels), term_rows) state["_scroll_offset"] = scroll_offset - lines = [] - _panel_open(lines, 'class:clarify-border', box_width, title, 'class:clarify-title') - _append_blank_panel_line(lines, 'class:clarify-border', box_width) - _append_panel_line(lines, 'class:clarify-border', 'class:clarify-hint', hint, box_width) - _append_blank_panel_line(lines, 'class:clarify-border', box_width) + panel = _Panel('class:clarify-border', box_width, title, 'class:clarify-title') + panel.blank() + panel.row('class:clarify-hint', hint) + panel.blank() for idx in range(scroll_offset, min(scroll_offset + visible, len(labels))): style = 'class:clarify-selected' if idx == selected else 'class:clarify-choice' prefix = '❯ ' if idx == selected else ' ' for wrapped in _wrap_panel_text(prefix + labels[idx], inner_text_width, subsequent_indent=indent): - _append_panel_line(lines, 'class:clarify-border', style, wrapped, box_width) - _append_blank_panel_line(lines, 'class:clarify-border', box_width) - _panel_close(lines, 'class:clarify-border', box_width) - return lines + panel.row(style, wrapped) + panel.blank() + return panel.close() def _get_model_picker_display_fragments(self): state = self._model_picker_state @@ -622,7 +628,10 @@ class CLITuiMixin: label += " ← current" choices.append(label) choices.append("Cancel") - hint = f"Current: {state.get('current_model', 'unknown')} on {state.get('current_provider', 'unknown')}" + hint = ( + f"Current: {state.get('current_model', 'unknown')} " + f"on {state.get('current_provider', 'unknown')}" + ) else: provider_data = state.get("provider_data") or {} model_list = state.get("model_list") or [] @@ -642,7 +651,9 @@ class CLITuiMixin: hint = f"Select a model ({len(model_list)} available) — type to filter" else: hint = "No models listed for this provider. Use Back or Cancel." - return self._render_scroll_list_panel(state, title, hint, choices, min_width=46, max_width=84, indent=' ') + return self._render_scroll_list_panel( + state, title, hint, choices, min_width=46, max_width=84, indent=' ', + ) def _get_command_palette_display_fragments(self): state = self._command_palette_state @@ -662,18 +673,16 @@ class CLITuiMixin: def _render_sudo_style_panel(self, title: str, body_lines: list[str]): """Bordered ``sudo-*`` panel: blank, each body line, blank, body-final line, blank.""" - from cli import _append_blank_panel_line, _append_panel_line, _panel_box_width + from cli import _panel_box_width box_width = _panel_box_width(title, body_lines) - lines = [] - _panel_open(lines, 'class:sudo-border', box_width, title, 'class:sudo-title') - _append_blank_panel_line(lines, 'class:sudo-border', box_width) + panel = _Panel('class:sudo-border', box_width, title, 'class:sudo-title') + panel.blank() for i, text in enumerate(body_lines): if i == len(body_lines) - 1 and i > 0: - _append_blank_panel_line(lines, 'class:sudo-border', box_width) - _append_panel_line(lines, 'class:sudo-border', 'class:sudo-text', text, box_width) - _append_blank_panel_line(lines, 'class:sudo-border', box_width) - _panel_close(lines, 'class:sudo-border', box_width) - return lines + panel.blank() + panel.row('class:sudo-text', text) + panel.blank() + return panel.close() def _get_sudo_display_fragments(self): if not self._sudo_state: @@ -720,7 +729,10 @@ class CLITuiMixin: return [('class:hint', hint), ('class:clarify-countdown', countdown)] if self._command_running: frame = self._command_spinner_frame() - detail = "input temporarily disabled" if self._command_blocks_input else "input stays active; Enter queues" + if self._command_blocks_input: + detail = "input temporarily disabled" + else: + detail = "input stays active; Enter queues" return [('class:hint', f' {frame} command in progress · {detail}')] return [] @@ -761,7 +773,9 @@ class CLITuiMixin: def _get_stash_panel_display_fragments(self): try: _stash = self._prompt_stash - return self._render_stash_panel(_stash.panel_rows(), _stash.panel_cursor, self._get_tui_terminal_width()) + return self._render_stash_panel( + _stash.panel_rows(), _stash.panel_cursor, self._get_tui_terminal_width(), + ) except Exception: return [] @@ -1297,7 +1311,11 @@ class CLITuiMixin: redirect / interrupt queue / next-turn queue); idle → ``_pending_input``. Slash and bang commands always take the local-dispatch path (never steer/interrupt text to the model). """ - from cli import _apply_backslash_line_continuation, _is_backslash_line_continuation, _looks_like_slash_command + from cli import ( + _apply_backslash_line_continuation, + _is_backslash_line_continuation, + _looks_like_slash_command, + ) if self._tui_enter_overlay(event): return buf = event.app.current_buffer @@ -1411,8 +1429,10 @@ class CLITuiMixin: self._interrupt_queue.put(payload) try: with open(_hermes_home / "interrupt_debug.log", "a", encoding="utf-8") as _f: - _f.write(f"{time.strftime('%H:%M:%S')} ENTER: queued interrupt msg={str(payload)[:60]!r}, " - f"agent_running={self._agent_running}\n") + _f.write( + f"{time.strftime('%H:%M:%S')} ENTER: queued interrupt msg={str(payload)[:60]!r}, " + f"agent_running={self._agent_running}\n" + ) except Exception: pass # First-touch onboarding: one-line tip about the /busy knob on the first busy-while- @@ -1792,14 +1812,21 @@ class CLITuiMixin: binding, so the generic handlers (Tab, history Up/Down) are registered before or after their filtered modal overrides deliberately. """ - from cli import CLI_CONFIG, _bind_prompt_submit_keys, _cli_multiline_shortcuts_enabled, _preserve_ctrl_enter_newline + from cli import ( + CLI_CONFIG, + _bind_prompt_submit_keys, + _cli_multiline_shortcuts_enabled, + _preserve_ctrl_enter_newline, + ) from prompt_toolkit.keys import Keys kb = KeyBindings() _multiline_shortcuts_enabled = _cli_multiline_shortcuts_enabled(self.config or CLI_CONFIG) self._tui_multiline_shortcuts = _multiline_shortcuts_enabled kb.add(Keys.Ignore, eager=True)(self._tui_handle_ignored_terminal_sequence) - _bind_prompt_submit_keys(kb, self._tui_handle_enter, multiline_shortcuts_enabled=_multiline_shortcuts_enabled) + _bind_prompt_submit_keys( + kb, self._tui_handle_enter, multiline_shortcuts_enabled=_multiline_shortcuts_enabled, + ) kb.add('escape', 'enter')(self._tui_insert_newline) # Ctrl+J inserts a newline (Claude Code / Codex / OpenCode). Windows Terminal delivers # Ctrl+Enter as the same c-j code. display.cli_multiline_shortcuts: false restores legacy @@ -1824,7 +1851,9 @@ class CLITuiMixin: # prompt_toolkit's key parser doesn't recognise 'c-S-c' anyway (#19884/#19895). kb.add('c-q')(self._tui_handle_ctrl_q) kb.add('c-d')(self._tui_handle_ctrl_d) - _modal_prompt_active = Condition(lambda: bool(self._secret_state or self._sudo_state or self._slash_confirm_state)) + _modal_prompt_active = Condition( + lambda: bool(self._secret_state or self._sudo_state or self._slash_confirm_state) + ) kb.add('escape', filter=_modal_prompt_active, eager=True)(self._tui_handle_escape_modal) kb.add('escape', 'escape', filter=~_modal_prompt_active)(self._tui_handle_double_escape) kb.add('c-z')(self._tui_handle_ctrl_z) @@ -1839,9 +1868,12 @@ class CLITuiMixin: # VSCode/Cursor bind Ctrl+G to "Find Next" so it never reaches the terminal; Alt+G is # unbound there and arrives as ('escape', 'g') — register it as a fallback. _editor_filter = Condition( - lambda: not self._clarify_state and not self._approval_state and not self._sudo_state and not self._secret_state + lambda: not self._clarify_state and not self._approval_state + and not self._sudo_state and not self._secret_state + ) + kb.add('c-g', filter=_editor_filter)( + kb.add('escape', 'g', filter=_editor_filter)(self._tui_handle_open_in_editor) ) - kb.add('c-g', filter=_editor_filter)(kb.add('escape', 'g', filter=_editor_filter)(self._tui_handle_open_in_editor)) # Ctrl+S prompt stash: park a draft, send something else, bring it back. Suppressed while # a modal prompt owns the composer so Ctrl+S can't stash a password. _stash_filter = Condition( @@ -1863,13 +1895,15 @@ class CLITuiMixin: """Clarify / approval / slash-confirm / model picker / command palette navigation keys.""" _clarify_nav = Condition(lambda: bool(self._clarify_state) and not self._clarify_freetext) _clarify_batch = Condition( - lambda: bool(self._clarify_state) and bool(self._clarify_state.get("questions")) and not self._clarify_freetext + lambda: bool(self._clarify_state) and bool(self._clarify_state.get("questions")) + and not self._clarify_freetext ) kb.add('up', filter=_clarify_nav)(self._tui_clarify_up) kb.add('down', filter=_clarify_nav)(self._tui_clarify_down) # Multi-select: Space toggles the checkbox under the cursor. kb.add('space', filter=Condition( - lambda: bool(self._clarify_state) and not self._clarify_freetext and self._clarify_state.get("multi_select") + lambda: bool(self._clarify_state) and not self._clarify_freetext + and self._clarify_state.get("multi_select") ))(self._tui_clarify_toggle) # Batch clarify: Tab / Shift-Tab cycle the active question (any-order answering; moving # onto an answered question lets the user re-answer it). Registered after the generic @@ -1969,13 +2003,17 @@ class CLITuiMixin: cli_ref = self input_area = self._tui_build_input_area() spinner_widget = Window( - content=FormattedTextControl(self._tui_spinner_text), height=self._tui_spinner_height, wrap_lines=True, + content=FormattedTextControl(self._tui_spinner_text), + height=self._tui_spinner_height, + wrap_lines=True, ) # Petdex mascot — right-aligned Kitty placeholder or half-block sprite above the prompt; # height 0 when no pet is enabled. The animation thread queues virtual Kitty frames; # after_render writes them out-of-band while prompt_toolkit owns the placeholder grid. self._pet_widget = Window( - content=FormattedTextControl(self._pet_fragments), height=self._pet_widget_height, align=WindowAlign.RIGHT, + content=FormattedTextControl(self._pet_fragments), + height=self._pet_widget_height, + align=WindowAlign.RIGHT, ) # Hint line above the input: only for interactive prompts that need extra instructions # (sudo countdown, approval navigation, clarify); the agent-running hint is the placeholder. @@ -1984,13 +2022,21 @@ class CLITuiMixin: sudo_widget = self._tui_overlay_widget(self._get_sudo_display_fragments, "_sudo_state") secret_widget = self._tui_overlay_widget(self._get_secret_display_fragments, "_secret_state") approval_widget = self._tui_overlay_widget(self._get_approval_display_fragments, "_approval_state") - slash_confirm_widget = self._tui_overlay_widget(self._get_slash_confirm_display_fragments, "_slash_confirm_state") - model_picker_widget = self._tui_overlay_widget(self._get_model_picker_display_fragments, "_model_picker_state") + slash_confirm_widget = self._tui_overlay_widget( + self._get_slash_confirm_display_fragments, "_slash_confirm_state", + ) + model_picker_widget = self._tui_overlay_widget( + self._get_model_picker_display_fragments, "_model_picker_state", + ) command_palette_widget = self._tui_overlay_widget( self._get_command_palette_display_fragments, "_command_palette_state") # Rules above/below the input; narrow terminals hide the bottom one to recover a row. - input_rule_top = Window(char='─', height=lambda: cli_ref._tui_input_rule_height("top"), style='class:input-rule') - input_rule_bot = Window(char='─', height=lambda: cli_ref._tui_input_rule_height("bottom"), style='class:input-rule') + input_rule_top = Window( + char='─', height=lambda: cli_ref._tui_input_rule_height("top"), style='class:input-rule', + ) + input_rule_bot = Window( + char='─', height=lambda: cli_ref._tui_input_rule_height("bottom"), style='class:input-rule', + ) image_bar = Window( content=FormattedTextControl(self._tui_image_bar_fragments), height=Condition(lambda: bool(cli_ref._attached_images)))