From a0770d0954994e404ef2a16270de3b41e26e38bd Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:14:51 -0700 Subject: [PATCH] fix(cli): flush-left responses + native clipboard /copy for clean copy/paste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cli.py | 31 ++++---- hermes_cli/cli_commands_mixin.py | 11 ++- hermes_cli/clipboard.py | 60 +++++++++++++++ tests/cli/test_cli_copy_command.py | 22 +++++- tests/cli/test_stream_flush_left.py | 64 ++++++++++++++++ tests/hermes_cli/test_clipboard_text_write.py | 73 +++++++++++++++++++ 6 files changed, 242 insertions(+), 19 deletions(-) create mode 100644 tests/cli/test_stream_flush_left.py create mode 100644 tests/hermes_cli/test_clipboard_text_write.py diff --git a/cli.py b/cli.py index 696321daacc7..521bc2608a3e 100644 --- a/cli.py +++ b/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(), )) diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index f8c592522a33..102dee196794 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -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}") diff --git a/hermes_cli/clipboard.py b/hermes_cli/clipboard.py index 78a9a43c3d75..db87421194ab 100644 --- a/hermes_cli/clipboard.py +++ b/hermes_cli/clipboard.py @@ -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: diff --git a/tests/cli/test_cli_copy_command.py b/tests/cli/test_cli_copy_command.py index 6cd010df37f0..965551b496e3 100644 --- a/tests/cli/test_cli_copy_command.py +++ b/tests/cli/test_cli_copy_command.py @@ -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) diff --git a/tests/cli/test_stream_flush_left.py b/tests/cli/test_stream_flush_left.py new file mode 100644 index 000000000000..c8a8df523d63 --- /dev/null +++ b/tests/cli/test_stream_flush_left.py @@ -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) diff --git a/tests/hermes_cli/test_clipboard_text_write.py b/tests/hermes_cli/test_clipboard_text_write.py new file mode 100644 index 000000000000..8939e0e0584c --- /dev/null +++ b/tests/hermes_cli/test_clipboard_text_write.py @@ -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"