Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 46 additions & 52 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand All @@ -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 = ""
Expand Down
66 changes: 66 additions & 0 deletions hermes_cli/paste_collapse.py
Original file line number Diff line number Diff line change
@@ -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)
132 changes: 132 additions & 0 deletions tests/test_cli_paste_collapse.py
Original file line number Diff line number Diff line change
@@ -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
Loading