diff --git a/cli.py b/cli.py index 3b1ecd8ae42f..7b3c75ce10db 100644 --- a/cli.py +++ b/cli.py @@ -66,6 +66,10 @@ format_token_count_compact, ) from hermes_cli.banner import _format_context_length, format_banner_version_label +from hermes_cli.paste_collapse import ( + expand_paste_references, + materialize_paste_for_insertion, +) _COMMAND_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏") @@ -9171,26 +9175,25 @@ def handle_paste(event): pasted_text = pasted_text.replace('\r\n', '\n').replace('\r', '\n') 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 - from run_agent import _sanitize_surrogates - pasted_text = _sanitize_surrogates(pasted_text) - line_count = pasted_text.count('\n') - buf = event.current_buffer - if line_count >= 5 and not buf.text.strip().startswith('/'): - _paste_counter[0] += 1 - paste_dir = _hermes_home / "pastes" - paste_dir.mkdir(parents=True, exist_ok=True) - paste_file = paste_dir / f"paste_{_paste_counter[0]}_{datetime.now().strftime('%H%M%S')}.txt" - paste_file.write_text(pasted_text, encoding="utf-8") - placeholder = f"[Pasted text #{_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" - _paste_just_collapsed[0] = True - buf.insert_text(prefix + placeholder) - else: - buf.insert_text(pasted_text) + if not pasted_text: + return + + # Sanitize surrogate characters (e.g. from Word/Google Docs paste) before writing + from run_agent import _sanitize_surrogates + pasted_text = _sanitize_surrogates(pasted_text) + + next_counter = _paste_counter[0] + 1 + inserted_text, collapsed = materialize_paste_for_insertion( + pasted_text, + current_buffer_text=event.current_buffer.text or "", + paste_dir=_hermes_home / "pastes", + counter=next_counter, + now=datetime.now(), + ) + if collapsed: + _paste_counter[0] = next_counter + _paste_just_collapsed[0] = True + event.current_buffer.insert_text(inserted_text) @kb.add('c-v') def handle_ctrl_v(event): @@ -9283,7 +9286,9 @@ def _input_height(): input_area.window.height = _input_height - # Paste collapsing: detect large pastes and save to temp file + # Paste collapsing: large bracketed pastes are collapsed to file references. + # The decision now happens in the paste handler so we only ever collapse + # the pasted chunk, not the whole draft buffer. _paste_counter = [0] _prev_text_len = [0] _prev_newline_count = [0] @@ -10039,40 +10044,29 @@ def process_loop(): app.exit() continue - # Expand paste references back to full content - import re as _re - _paste_ref_re = _re.compile(r'\[Pasted text #\d+: \d+ lines \u2192 (.+?)\]') - paste_refs = list(_paste_ref_re.finditer(user_input)) if isinstance(user_input, str) else [] - if paste_refs: - def _expand_ref(m): - p = Path(m.group(1)) - return p.read_text(encoding="utf-8") if p.exists() else m.group(0) - expanded = _paste_ref_re.sub(_expand_ref, user_input) - total_lines = expanded.count('\n') + 1 - n_pastes = len(paste_refs) - _user_bar = f"[{_accent_hex()}]{'─' * 40}[/]" + # Expand paste references back to full content for the + # actual agent payload, but keep the compact placeholders in + # the local echo so large pasted chunks do not flood the TUI. + display_input = user_input + expanded_user_input = ( + expand_paste_references(user_input) + if isinstance(user_input, str) + else user_input + ) + + _user_bar = f"[{_accent_hex()}]{'─' * 40}[/]" + if isinstance(display_input, str) and display_input != expanded_user_input: + expanded_line_count = expanded_user_input.count('\n') + 1 if isinstance(expanded_user_input, str) else 1 print() ChatConsole().print(_user_bar) - # Show any surrounding user text alongside the paste summary - split_parts = _paste_ref_re.split(user_input) - visible_user_text = " ".join( - split_parts[i].strip() for i in range(0, len(split_parts), 2) if split_parts[i].strip() + ChatConsole().print( + f"[bold {_accent_hex()}]●[/] [bold]{_escape(f'[Pasted text: {expanded_line_count} lines]')}[/]" ) - if visible_user_text: - ChatConsole().print( - f"[bold {_accent_hex()}]\u25cf[/] [bold]{_escape(visible_user_text)}[/] " - f"[dim]({n_pastes} pasted block{'s' if n_pastes > 1 else ''}, {total_lines} lines total)[/]" - ) - else: - ChatConsole().print( - f"[bold {_accent_hex()}]\u25cf[/] [bold]{_escape(f'[Pasted text: {total_lines} lines]')}[/]" - ) - user_input = expanded else: _user_bar = f"[{_accent_hex()}]{'─' * 40}[/]" - if '\n' in user_input: - first_line = user_input.split('\n')[0] - line_count = user_input.count('\n') + 1 + if '\n' in display_input: + first_line = display_input.split('\n')[0] + line_count = display_input.count('\n') + 1 print() ChatConsole().print(_user_bar) ChatConsole().print( @@ -10082,7 +10076,7 @@ def _expand_ref(m): else: print() ChatConsole().print(_user_bar) - ChatConsole().print(f"[bold {_accent_hex()}]●[/] [bold]{_escape(user_input)}[/]") + ChatConsole().print(f"[bold {_accent_hex()}]●[/] [bold]{_escape(display_input)}[/]") # Show image attachment count if submit_images: @@ -10094,7 +10088,7 @@ def _expand_ref(m): app.invalidate() # Refresh status line try: - self.chat(user_input, images=submit_images or None) + self.chat(expanded_user_input, images=submit_images or None) finally: self._agent_running = False self._spinner_text = "" diff --git a/hermes_cli/paste_collapse.py b/hermes_cli/paste_collapse.py new file mode 100644 index 000000000000..45cfaa7adebd --- /dev/null +++ b/hermes_cli/paste_collapse.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +import re + +PASTE_REF_RE = re.compile(r"\[Pasted text #(\d+): \d+ lines → (.+?)\]") + + +def should_collapse_pasted_text(pasted_text: str, *, min_lines: int = 5) -> bool: + if not pasted_text: + return False + return pasted_text.count("\n") >= min_lines + + +def write_pasted_text_reference( + pasted_text: str, + *, + paste_dir: Path, + counter: int, + now: datetime, +) -> str: + paste_dir.mkdir(parents=True, exist_ok=True) + paste_file = paste_dir / f"paste_{counter}_{now.strftime('%H%M%S')}.txt" + paste_file.write_text(pasted_text, encoding="utf-8") + line_count = pasted_text.count("\n") + 1 + return f"[Pasted text #{counter}: {line_count} lines → {paste_file}]" + + +def materialize_paste_for_insertion( + pasted_text: str, + *, + current_buffer_text: str, + paste_dir: Path, + counter: int, + now: datetime, + min_lines: int = 5, +) -> tuple[str, bool]: + if not pasted_text: + return "", False + if (current_buffer_text or "").startswith("/"): + return pasted_text, False + if not should_collapse_pasted_text(pasted_text, min_lines=min_lines): + return pasted_text, False + return ( + write_pasted_text_reference( + pasted_text, + paste_dir=paste_dir, + counter=counter, + now=now, + ), + True, + ) + + +def expand_paste_references(text: str) -> str: + if not text: + return text + + def repl(match: re.Match[str]) -> str: + paste_path = Path(match.group(2)) + if not paste_path.exists(): + return match.group(0) + return paste_path.read_text(encoding="utf-8") + + return PASTE_REF_RE.sub(repl, text) diff --git a/tests/test_cli_paste_collapse.py b/tests/test_cli_paste_collapse.py new file mode 100644 index 000000000000..4a2c6f0c1ef9 --- /dev/null +++ b/tests/test_cli_paste_collapse.py @@ -0,0 +1,132 @@ +from datetime import datetime + +from hermes_cli.paste_collapse import ( + expand_paste_references, + materialize_paste_for_insertion, + should_collapse_pasted_text, + write_pasted_text_reference, +) + + +class FakeBuffer: + def __init__(self, text: str, cursor_position: int): + self.text = text + self.cursor_position = cursor_position + + def insert_text(self, value: str): + self.text = self.text[: self.cursor_position] + value + self.text[self.cursor_position :] + self.cursor_position += len(value) + + +def test_should_not_collapse_small_single_word_paste(): + assert should_collapse_pasted_text("hello") is False + + +def test_should_not_collapse_short_multiline_paste_under_threshold(): + assert should_collapse_pasted_text("a\nb\nc\nd") is False + + +def test_should_collapse_large_multiline_paste_at_threshold(): + pasted = "1\n2\n3\n4\n5\n6" + assert should_collapse_pasted_text(pasted) is True + + +def test_write_reference_creates_file_and_returns_placeholder(tmp_path): + ref = write_pasted_text_reference( + "alpha\nbeta\ngamma\ndelta\nepsilon\nzeta", + paste_dir=tmp_path, + counter=1, + now=datetime(2026, 3, 22, 12, 34, 56), + ) + assert ref.startswith("[Pasted text #1: 6 lines → ") + created = next(tmp_path.iterdir()) + assert created.read_text(encoding="utf-8") == "alpha\nbeta\ngamma\ndelta\nepsilon\nzeta" + + +def test_expand_paste_references_expands_exact_reference(tmp_path): + ref = write_pasted_text_reference( + "big\nchunk\nof\ntext\nfor\nagent", + paste_dir=tmp_path, + counter=2, + now=datetime(2026, 3, 22, 12, 34, 56), + ) + assert expand_paste_references(ref) == "big\nchunk\nof\ntext\nfor\nagent" + + +def test_expand_paste_references_expands_inline_reference(tmp_path): + ref = write_pasted_text_reference( + "embedded\npaste\ncontent\nline4\nline5\nline6", + paste_dir=tmp_path, + counter=3, + now=datetime(2026, 3, 22, 12, 34, 56), + ) + text = f"Intro before\n{ref}\nOutro after" + expanded = expand_paste_references(text) + assert "embedded\npaste\ncontent" in expanded + assert "Intro before" in expanded + assert "Outro after" in expanded + + +def test_expand_paste_references_leaves_missing_file_reference_literal(): + text = "before [Pasted text #9: 6 lines → /no/such/file.txt] after" + assert expand_paste_references(text) == text + + +def test_small_paste_into_large_existing_draft_does_not_collapse(tmp_path): + existing = "line1\nline2\nline3\nline4\nline5\nline6" + paste_dir = tmp_path / "pastes" + inserted, collapsed = materialize_paste_for_insertion( + "word", + current_buffer_text=existing, + paste_dir=paste_dir, + counter=1, + now=datetime(2026, 3, 22, 12, 34, 56), + ) + assert collapsed is False + assert inserted == "word" + assert paste_dir.exists() is False + + +def test_large_paste_is_inserted_as_reference_without_replacing_surrounding_text(tmp_path): + before = "intro\nmore intro\n" + after = "\noutro" + buf = FakeBuffer(before + after, cursor_position=len(before)) + inserted, collapsed = materialize_paste_for_insertion( + "a\nb\nc\nd\ne\nf", + current_buffer_text=buf.text, + paste_dir=tmp_path, + counter=1, + now=datetime(2026, 3, 22, 12, 34, 56), + ) + assert collapsed is True + buf.insert_text(inserted) + assert buf.text.startswith(before) + assert buf.text.endswith(after) + assert "[Pasted text #1:" in buf.text + + +def test_large_paste_is_not_collapsed_when_current_buffer_is_slash_command(tmp_path): + pasted = "a\nb\nc\nd\ne\nf" + inserted, collapsed = materialize_paste_for_insertion( + pasted, + current_buffer_text="/plan ", + paste_dir=tmp_path, + counter=1, + now=datetime(2026, 3, 22, 12, 34, 56), + ) + assert collapsed is False + assert inserted == pasted + + +def test_inline_paste_reference_expands_inside_larger_message(tmp_path): + ref = write_pasted_text_reference( + "pasted\nchunk\nline3\nline4\nline5\nline6", + paste_dir=tmp_path, + counter=1, + now=datetime(2026, 3, 22, 12, 34, 56), + ) + raw = f"Please summarize this:\n{ref}\nThanks" + expanded = expand_paste_references(raw) + assert "Please summarize this:" in expanded + assert "pasted\nchunk\nline3" in expanded + assert "Thanks" in expanded