refactor(hermes_cli): _Panel fragment accumulator replaces per-line border boilerplate in TUI overlays

This commit is contained in:
Teknium
2026-09-02 21:45:19 -07:00
parent 59ee2ad3b5
commit db0d5341bc
+143 -97
View File
@@ -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)))