mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
fix(cli): flush-left responses + native clipboard /copy for clean copy/paste
Streamed response text carried a 4-space _STREAM_PAD indent and the final-response Rich Panel used padding=(1, 4), so every line selected out of the terminal came with leading whitespace. Both now render flush-left (pad empty, panel padding=(1, 0)); the table-realignment width budgets were widened to match. /copy now writes the ORIGINAL message text through native clipboard tools (pbcopy / PowerShell Set-Clipboard via base64 / wl-copy / xclip / xsel — same fallback chain as the TUI's writeClipboardText), falling back to OSC 52 only when no native backend succeeds. This is the TUI-equivalent answer to soft-wrap mangling: the clipboard gets the raw text, not the rendered layout.
This commit is contained in:
parent
f98b223b58
commit
a0770d0954
6 changed files with 242 additions and 19 deletions
31
cli.py
31
cli.py
|
|
@ -2439,7 +2439,9 @@ def _prune_orphaned_branches(repo_root: str) -> None:
|
|||
_ACCENT_ANSI_DEFAULT = "\033[1;38;2;255;215;0m" # True-color #FFD700 bold — fallback
|
||||
_BOLD = "\033[1m"
|
||||
_RST = "\033[0m"
|
||||
_STREAM_PAD = " " # 4-space indent for streamed response text (matches Panel padding)
|
||||
_STREAM_PAD = "" # No indent for streamed response text — leading whitespace pollutes
|
||||
# terminal copy/paste (every selected line carried 4 spaces). Matches the
|
||||
# response Panel's flush-left padding.
|
||||
|
||||
|
||||
def _hex_to_ansi(hex_color: str, *, bold: bool = False) -> str:
|
||||
|
|
@ -2851,12 +2853,13 @@ def _preserve_windows_dot_segments_for_markdown(text: str) -> str:
|
|||
def _terminal_width_for_streaming() -> int:
|
||||
"""Display cells available inside the streamed response box.
|
||||
|
||||
The streaming path indents every line by ``_STREAM_PAD`` (4 cells)
|
||||
inside an open response panel. The realigner uses this number as
|
||||
its budget when deciding whether to keep a horizontal table or
|
||||
fall back to vertical key-value rendering. We subtract a small
|
||||
safety margin so terminal-resize races don't push a borderline
|
||||
table into mid-cell soft-wrap.
|
||||
The streaming path prefixes every line with ``_STREAM_PAD`` (now
|
||||
empty — flush-left so copy/paste stays clean) inside an open
|
||||
response panel. The realigner uses this number as its budget when
|
||||
deciding whether to keep a horizontal table or fall back to
|
||||
vertical key-value rendering. We subtract a small safety margin
|
||||
so terminal-resize races don't push a borderline table into
|
||||
mid-cell soft-wrap.
|
||||
"""
|
||||
|
||||
try:
|
||||
|
|
@ -2871,16 +2874,16 @@ def _render_final_assistant_content(text: str, mode: str = "render"):
|
|||
from rich.markdown import Markdown
|
||||
|
||||
# Estimate the cells available to the rendered table. The Panel
|
||||
# used by the background-task / final-response path has 4 cells of
|
||||
# left+right padding plus 1 cell of border on each side, plus the
|
||||
# _STREAM_PAD indent that streamed content uses. Subtract a small
|
||||
# safety margin so resize races don't push a borderline table into
|
||||
# soft-wrap.
|
||||
# used by the background-task / final-response path renders
|
||||
# flush-left (no horizontal padding — leading spaces pollute
|
||||
# terminal copy/paste) with 1 cell of border on each side.
|
||||
# Subtract a small safety margin so resize races don't push a
|
||||
# borderline table into soft-wrap.
|
||||
try:
|
||||
cols = shutil.get_terminal_size((80, 24)).columns
|
||||
except Exception:
|
||||
cols = 80
|
||||
panel_width = max(20, cols - 12)
|
||||
panel_width = max(20, cols - 4)
|
||||
|
||||
normalized_mode = str(mode or "render").strip().lower()
|
||||
if normalized_mode == "strip":
|
||||
|
|
@ -13759,7 +13762,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
border_style=_resp_color,
|
||||
style=_resp_text,
|
||||
box=rich_box.HORIZONTALS,
|
||||
padding=(1, 4),
|
||||
padding=(1, 0),
|
||||
width=self._scrollback_box_width(),
|
||||
))
|
||||
|
||||
|
|
|
|||
|
|
@ -559,8 +559,17 @@ class CLICommandsMixin:
|
|||
return
|
||||
|
||||
try:
|
||||
from hermes_cli.clipboard import write_clipboard_text
|
||||
if write_clipboard_text(text):
|
||||
_cprint(f" Copied assistant response #{idx + 1} to clipboard")
|
||||
return
|
||||
# Native tools unavailable/failed — fall back to OSC 52 so
|
||||
# SSH/tmux sessions can still copy via the terminal emulator.
|
||||
self._write_osc52_clipboard(text)
|
||||
_cprint(f" Copied assistant response #{idx + 1} to clipboard")
|
||||
_cprint(
|
||||
f" Copied assistant response #{idx + 1} via OSC 52 "
|
||||
"(terminal support required)"
|
||||
)
|
||||
except Exception as e:
|
||||
_cprint(f" Clipboard copy failed: {e}")
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,66 @@ def has_clipboard_image() -> bool:
|
|||
return _xclip_has_image()
|
||||
|
||||
|
||||
# ── Text write (native tools, mirrors ui-tui/src/lib/clipboard.ts) ──────
|
||||
|
||||
def _powershell_write_script(b64: str) -> str:
|
||||
# PowerShell decodes piped stdin with the system ANSI code page (e.g.
|
||||
# CP936), not UTF-8, so stdin-based writes mangle CJK/emoji. Base64 the
|
||||
# UTF-8 bytes and decode inside PowerShell instead — same approach as
|
||||
# the TUI's writeClipboardText.
|
||||
return (
|
||||
"Set-Clipboard -Value ([System.Text.Encoding]::UTF8.GetString("
|
||||
f"[System.Convert]::FromBase64String('{b64}')))"
|
||||
)
|
||||
|
||||
|
||||
def _write_clipboard_commands() -> list:
|
||||
"""Return (cmd_argv, use_stdin) candidates in platform fallback order."""
|
||||
if sys.platform == "darwin":
|
||||
return [(["pbcopy"], True)]
|
||||
if sys.platform == "win32":
|
||||
return [(["powershell", "-NoProfile", "-NonInteractive"], False)]
|
||||
attempts = []
|
||||
if _is_wsl():
|
||||
attempts.append((["powershell.exe", "-NoProfile", "-NonInteractive"], False))
|
||||
if os.environ.get("WAYLAND_DISPLAY"):
|
||||
attempts.append((["wl-copy", "--type", "text/plain"], True))
|
||||
attempts.append((["xclip", "-selection", "clipboard", "-in"], True))
|
||||
attempts.append((["xsel", "--clipboard", "--input"], True))
|
||||
return attempts
|
||||
|
||||
|
||||
def write_clipboard_text(text: str) -> bool:
|
||||
"""Write *text* to the system clipboard via native platform tools.
|
||||
|
||||
Fallback order matches the TUI (ui-tui/src/lib/clipboard.ts):
|
||||
macOS pbcopy → Windows/WSL PowerShell Set-Clipboard → wl-copy →
|
||||
xclip → xsel. Returns True if any backend succeeded; callers should
|
||||
fall back to OSC 52 on False.
|
||||
"""
|
||||
for argv, use_stdin in _write_clipboard_commands():
|
||||
try:
|
||||
if use_stdin:
|
||||
proc = subprocess.run(
|
||||
argv, input=text.encode("utf-8"),
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
timeout=10,
|
||||
)
|
||||
else:
|
||||
b64 = base64.b64encode(text.encode("utf-8")).decode("ascii")
|
||||
proc = subprocess.run(
|
||||
argv + ["-Command", _powershell_write_script(b64)],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
timeout=10,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
return True
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
# ── macOS ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _macos_save(dest: Path) -> bool:
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ def test_copy_copies_latest_assistant_message():
|
|||
{"role": "assistant", "content": "latest"},
|
||||
]
|
||||
|
||||
with patch.object(cli_obj, "_write_osc52_clipboard") as mock_copy:
|
||||
with patch("hermes_cli.clipboard.write_clipboard_text", return_value=True) as mock_copy:
|
||||
result = cli_obj.process_command("/copy")
|
||||
|
||||
assert result is True
|
||||
|
|
@ -39,7 +39,7 @@ def test_copy_with_index_uses_requested_assistant_message():
|
|||
{"role": "assistant", "content": "two"},
|
||||
]
|
||||
|
||||
with patch.object(cli_obj, "_write_osc52_clipboard") as mock_copy:
|
||||
with patch("hermes_cli.clipboard.write_clipboard_text", return_value=True) as mock_copy:
|
||||
cli_obj.process_command("/copy 1")
|
||||
|
||||
mock_copy.assert_called_once_with("one")
|
||||
|
|
@ -54,18 +54,32 @@ def test_copy_strips_reasoning_blocks_before_copy():
|
|||
}
|
||||
]
|
||||
|
||||
with patch.object(cli_obj, "_write_osc52_clipboard") as mock_copy:
|
||||
with patch("hermes_cli.clipboard.write_clipboard_text", return_value=True) as mock_copy:
|
||||
cli_obj.process_command("/copy")
|
||||
|
||||
mock_copy.assert_called_once_with("Visible answer")
|
||||
|
||||
|
||||
def test_copy_falls_back_to_osc52_when_native_tools_fail():
|
||||
cli_obj = _make_cli()
|
||||
cli_obj.conversation_history = [{"role": "assistant", "content": "hello"}]
|
||||
|
||||
with patch("hermes_cli.clipboard.write_clipboard_text", return_value=False), \
|
||||
patch.object(cli_obj, "_write_osc52_clipboard") as mock_osc52:
|
||||
cli_obj.process_command("/copy")
|
||||
|
||||
mock_osc52.assert_called_once_with("hello")
|
||||
|
||||
|
||||
def test_copy_invalid_index_does_not_copy():
|
||||
cli_obj = _make_cli()
|
||||
cli_obj.conversation_history = [{"role": "assistant", "content": "only"}]
|
||||
|
||||
with patch.object(cli_obj, "_write_osc52_clipboard") as mock_copy, patch("cli._cprint") as mock_print:
|
||||
with patch("hermes_cli.clipboard.write_clipboard_text") as mock_copy, \
|
||||
patch.object(cli_obj, "_write_osc52_clipboard") as mock_osc52, \
|
||||
patch("cli._cprint") as mock_print:
|
||||
cli_obj.process_command("/copy 99")
|
||||
|
||||
mock_copy.assert_not_called()
|
||||
mock_osc52.assert_not_called()
|
||||
assert any("Invalid response number" in str(call) for call in mock_print.call_args_list)
|
||||
|
|
|
|||
64
tests/cli/test_stream_flush_left.py
Normal file
64
tests/cli/test_stream_flush_left.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"""Streamed response lines must be flush-left (no leading indent).
|
||||
|
||||
The 4-space ``_STREAM_PAD`` indent made every copied line carry leading
|
||||
whitespace, so pasting a response out of the terminal produced broken
|
||||
text. The pad is now empty and the final-response Rich Panel uses zero
|
||||
horizontal padding for the same reason (July 2026).
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
|
||||
def _strip_ansi(s: str) -> str:
|
||||
return re.sub(r"\x1b\[[0-9;]*m", "", s)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cli_stub(monkeypatch):
|
||||
from cli import HermesCLI
|
||||
import cli as climod
|
||||
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli.show_reasoning = False
|
||||
cli.final_response_markdown = "raw"
|
||||
cli.show_timestamps = False
|
||||
cli._reset_stream_state()
|
||||
|
||||
emitted = []
|
||||
monkeypatch.setattr(climod, "_cprint", lambda s: emitted.append(s))
|
||||
monkeypatch.setattr(climod, "_terminal_width_for_streaming", lambda: 74)
|
||||
return cli, emitted
|
||||
|
||||
|
||||
def test_stream_pad_is_empty():
|
||||
import cli as climod
|
||||
|
||||
assert climod._STREAM_PAD == ""
|
||||
|
||||
|
||||
def test_streamed_content_lines_have_no_leading_whitespace(cli_stub):
|
||||
cli, emitted = cli_stub
|
||||
cli._stream_delta("First streamed line of text.\nSecond streamed line.\n")
|
||||
cli._flush_stream()
|
||||
content = [
|
||||
_strip_ansi(e)
|
||||
for e in emitted
|
||||
if "streamed line" in _strip_ansi(e)
|
||||
]
|
||||
assert content, "no content lines captured"
|
||||
for line in content:
|
||||
assert not line.startswith(" "), f"leading whitespace leaked: {line!r}"
|
||||
|
||||
|
||||
def test_intentional_markdown_indentation_is_preserved(cli_stub):
|
||||
cli, emitted = cli_stub
|
||||
cli._stream_delta("- item\n - nested item\n")
|
||||
cli._flush_stream()
|
||||
plain = [_strip_ansi(e) for e in emitted]
|
||||
assert any(line == "- item" for line in plain)
|
||||
assert any(line == " - nested item" for line in plain)
|
||||
73
tests/hermes_cli/test_clipboard_text_write.py
Normal file
73
tests/hermes_cli/test_clipboard_text_write.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
"""Tests for native clipboard text write (hermes_cli/clipboard.py).
|
||||
|
||||
Mirrors the TUI's writeClipboardText fallback chain: pbcopy /
|
||||
PowerShell Set-Clipboard / wl-copy / xclip / xsel, with OSC 52 left to
|
||||
the caller when every backend fails.
|
||||
"""
|
||||
import base64
|
||||
import subprocess
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import clipboard as clip
|
||||
|
||||
|
||||
def _completed(returncode=0):
|
||||
return subprocess.CompletedProcess(args=[], returncode=returncode)
|
||||
|
||||
|
||||
def test_darwin_uses_pbcopy():
|
||||
with patch.object(clip.sys, "platform", "darwin"), \
|
||||
patch.object(clip.subprocess, "run", return_value=_completed()) as run:
|
||||
assert clip.write_clipboard_text("hello") is True
|
||||
argv = run.call_args[0][0]
|
||||
assert argv == ["pbcopy"]
|
||||
assert run.call_args[1]["input"] == b"hello"
|
||||
|
||||
|
||||
def test_windows_uses_powershell_base64():
|
||||
with patch.object(clip.sys, "platform", "win32"), \
|
||||
patch.object(clip.subprocess, "run", return_value=_completed()) as run:
|
||||
assert clip.write_clipboard_text("héllo 🎉") is True
|
||||
argv = run.call_args[0][0]
|
||||
assert argv[0] == "powershell"
|
||||
script = argv[-1]
|
||||
b64 = base64.b64encode("héllo 🎉".encode("utf-8")).decode("ascii")
|
||||
assert b64 in script
|
||||
assert "Set-Clipboard" in script
|
||||
|
||||
|
||||
def test_linux_falls_through_backends_until_success():
|
||||
calls = []
|
||||
|
||||
def fake_run(argv, **kwargs):
|
||||
calls.append(argv[0])
|
||||
# xclip fails, xsel succeeds
|
||||
return _completed(returncode=0 if argv[0] == "xsel" else 1)
|
||||
|
||||
with patch.object(clip.sys, "platform", "linux"), \
|
||||
patch.object(clip, "_is_wsl", return_value=False), \
|
||||
patch.dict(clip.os.environ, {}, clear=False), \
|
||||
patch.object(clip.os.environ, "get", lambda k, d=None: None), \
|
||||
patch.object(clip.subprocess, "run", side_effect=fake_run):
|
||||
assert clip.write_clipboard_text("x") is True
|
||||
assert calls == ["xclip", "xsel"]
|
||||
|
||||
|
||||
def test_returns_false_when_all_backends_fail():
|
||||
with patch.object(clip.sys, "platform", "linux"), \
|
||||
patch.object(clip, "_is_wsl", return_value=False), \
|
||||
patch.object(clip.os.environ, "get", lambda k, d=None: None), \
|
||||
patch.object(clip.subprocess, "run", side_effect=FileNotFoundError):
|
||||
assert clip.write_clipboard_text("x") is False
|
||||
|
||||
|
||||
def test_wayland_prefers_wl_copy():
|
||||
with patch.object(clip.sys, "platform", "linux"), \
|
||||
patch.object(clip, "_is_wsl", return_value=False), \
|
||||
patch.object(clip.os.environ, "get",
|
||||
lambda k, d=None: ":0" if k == "WAYLAND_DISPLAY" else None), \
|
||||
patch.object(clip.subprocess, "run", return_value=_completed()) as run:
|
||||
assert clip.write_clipboard_text("x") is True
|
||||
assert run.call_args[0][0][0] == "wl-copy"
|
||||
Loading…
Add table
Add a link
Reference in a new issue