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:
Teknium 2026-07-28 23:14:51 -07:00
parent f98b223b58
commit a0770d0954
6 changed files with 242 additions and 19 deletions

31
cli.py
View file

@ -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(),
))

View file

@ -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}")

View file

@ -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:

View file

@ -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)

View 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)

View 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"