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
150 changes: 146 additions & 4 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,19 @@ def _assistant_copy_text(content: Any) -> str:
return _strip_reasoning_tags(_assistant_content_as_text(content))


_CODE_BLOCK_RE = re.compile(
r"```(\w*)\n(.*?)```", re.DOTALL
)


def _extract_code_blocks(text: str) -> list[tuple[str, str]]:
"""Return ``[(lang, code), ...]`` for every fenced code block in *text*."""
return [
(m.group(1) or "text", m.group(2).rstrip("\n"))
Comment on lines +120 to +127

Copilot AI Apr 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fenced-code regex _CODE_BLOCK_RE only matches language tags made of \w* and requires the info string to be immediately followed by \n. This will fail to extract many real-world fenced blocks like shell-session, c++, or ```python linenos (and any fence using ~~~). Consider loosening the info-string capture (e.g., capture and trim everything up to the newline) and/or sharing the same fence parsing rules used elsewhere in the project so `/copy` reliably detects code blocks.

Suggested change
r"```(\w*)\n(.*?)```", re.DOTALL
)
def _extract_code_blocks(text: str) -> list[tuple[str, str]]:
"""Return ``[(lang, code), ...]`` for every fenced code block in *text*."""
return [
(m.group(1) or "text", m.group(2).rstrip("\n"))
r"^(?P<fence>`{3,}|~{3,})(?P<info>[^\n]*)\n(?P<code>.*?)(?:^(?P=fence)[ \t]*$)",
re.DOTALL | re.MULTILINE,
)
def _extract_code_blocks(text: str) -> list[tuple[str, str]]:
"""Return ``[(lang, code), ...]`` for every fenced code block in *text*."""
return [
(
(m.group("info").strip().split(None, 1)[0] if m.group("info").strip() else "text"),
m.group("code").rstrip("\n"),
)

Copilot uses AI. Check for mistakes.
for m in _CODE_BLOCK_RE.finditer(text)
]


# =============================================================================
# Configuration Loading
# =============================================================================
Expand Down Expand Up @@ -3600,7 +3613,16 @@ def _handle_paste_command(self):
_cprint(f" {_DIM}(._.) No image found in clipboard{_RST}")

def _write_osc52_clipboard(self, text: str) -> None:
"""Copy *text* to terminal clipboard via OSC 52."""
"""Copy *text* to clipboard โ€” native first, OSC 52 fallback.

Tries ``pbcopy`` (macOS) / ``xclip`` / ``xsel`` / ``wl-copy``
(Linux) before sending an OSC 52 escape sequence. Over SSH the
native tools are skipped (they'd write to the remote clipboard)
and OSC 52 is used instead.
"""
if self._copy_to_system_clipboard(text):
return

payload = base64.b64encode(text.encode("utf-8")).decode("ascii")
seq = f"\x1b]52;c;{payload}\x07"
out = getattr(self, "_app", None)
Expand All @@ -3616,8 +3638,51 @@ def _write_osc52_clipboard(self, text: str) -> None:
sys.stdout.write(seq)
sys.stdout.flush()

def _copy_to_system_clipboard(self, text: str) -> bool:
"""Try native clipboard commands, return True on success.

Uses ``pbcopy`` on macOS, ``wl-copy`` / ``xclip`` / ``xsel`` on
Linux. Over SSH (``SSH_CONNECTION`` set) native tools would write
to the *remote* clipboard, so this returns False and the caller
should fall back to OSC 52.
"""
import shutil
import subprocess as _sp

if os.environ.get("SSH_CONNECTION"):
return False

if sys.platform == "darwin":
if shutil.which("pbcopy"):
try:
_sp.run(["pbcopy"], input=text.encode(), check=True, timeout=2)
return True
except Exception:
return False

if sys.platform == "linux":
for cmd, args in [
("wl-copy", []),
("xclip", ["-selection", "clipboard"]),
("xsel", ["--clipboard", "--input"]),
]:
if shutil.which(cmd):
try:
_sp.run([cmd, *args], input=text.encode(), check=True, timeout=2)
return True
except Exception:
continue

return False

def _handle_copy_command(self, cmd_original: str) -> None:
"""Handle /copy [number] โ€” copy assistant output to clipboard."""
"""Handle /copy [number] โ€” copy assistant output to clipboard.

When the selected response contains fenced code blocks, shows an
interactive picker letting the user choose individual blocks or
the full response. Press ``w`` in the picker to write the
selection to a file instead of copying to clipboard.
"""
parts = cmd_original.split(maxsplit=1)
arg = parts[1].strip() if len(parts) > 1 else ""

Expand Down Expand Up @@ -3648,12 +3713,89 @@ def _handle_copy_command(self, cmd_original: str) -> None:
_cprint(" Nothing to copy in that assistant response.")
return

blocks = _extract_code_blocks(text)
if blocks:
self._copy_with_picker(text, blocks, idx)
else:
self._copy_text_to_clipboard(text, f"assistant response #{idx + 1}")

def _copy_with_picker(
self, full_text: str, blocks: list[tuple[str, str]], response_idx: int,
) -> None:
"""Show an interactive picker for code blocks inside a response."""
from hermes_cli.curses_ui import curses_copy_picker

labels: list[str] = ["Full response"]
for i, (lang, code) in enumerate(blocks):
preview = code.split("\n", 1)[0][:60]
if len(preview) < len(code.split("\n", 1)[0]):
preview += "โ€ฆ"
labels.append(f"Block {i + 1} ({lang}): {preview}")

payloads: list[str] = [full_text] + [code for _, code in blocks]

choice, write_to_file = curses_copy_picker(labels)
if choice is None:
_cprint(" Cancelled.")
return

selected = payloads[choice]

if write_to_file:
self._write_copy_to_file(selected, blocks[choice - 1][0] if choice > 0 else "")
else:
label = labels[choice]
self._copy_text_to_clipboard(selected, label)

def _copy_text_to_clipboard(self, text: str, label: str) -> None:
"""Copy *text* to clipboard and print confirmation.

Tries native clipboard commands first (``pbcopy`` / ``xclip`` /
``xsel``). Falls back to OSC 52 over SSH or when no native
tool is available.
"""
try:
self._write_osc52_clipboard(text)
_cprint(f" Copied assistant response #{idx + 1} to clipboard")
if self._copy_to_system_clipboard(text):
_cprint(f" Copied {label} to clipboard")
else:
self._write_osc52_clipboard(text)
_cprint(f" Copied {label} to clipboard (via OSC 52)")
except Exception as e:
_cprint(f" Clipboard copy failed: {e}")

def _write_copy_to_file(self, text: str, lang: str) -> None:
"""Prompt for a file path and write *text* to it."""
_EXT_MAP = {
"python": ".py", "py": ".py", "javascript": ".js", "js": ".js",
"typescript": ".ts", "ts": ".ts", "tsx": ".tsx", "jsx": ".jsx",
"rust": ".rs", "go": ".go", "java": ".java", "c": ".c",
"cpp": ".cpp", "cs": ".cs", "ruby": ".rb", "rb": ".rb",
"shell": ".sh", "bash": ".sh", "sh": ".sh", "zsh": ".sh",
"sql": ".sql", "html": ".html", "css": ".css", "json": ".json",
"yaml": ".yaml", "yml": ".yaml", "toml": ".toml", "xml": ".xml",
"markdown": ".md", "md": ".md",
}
ext = _EXT_MAP.get(lang.lower(), ".txt") if lang else ".txt"
default_name = f"copied_block{ext}"

try:
from prompt_toolkit import prompt as _pt_prompt
dest = _pt_prompt(f" Write to [{default_name}]: ").strip()
except (KeyboardInterrupt, EOFError):
_cprint(" Cancelled.")
return

dest = dest or default_name
dest_path = os.path.abspath(os.path.expanduser(dest))
try:
with open(dest_path, "w", encoding="utf-8") as f:
f.write(text)
if not text.endswith("\n"):
f.write("\n")
_cprint(f" Written to {dest_path}")
except Exception as e:
_cprint(f" Write failed: {e}")

def _handle_image_command(self, cmd_original: str):
"""Handle /image <path> โ€” attach a local image file for the next prompt."""
raw_args = (cmd_original.split(None, 1)[1].strip() if " " in cmd_original else "")
Expand Down
2 changes: 1 addition & 1 deletion hermes_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ class CommandDef:
args_hint="[days]"),
CommandDef("platforms", "Show gateway/messaging platform status", "Info",
cli_only=True, aliases=("gateway",)),
CommandDef("copy", "Copy the last assistant response to clipboard", "Info",
CommandDef("copy", "Copy assistant response to clipboard (interactive picker for code blocks)", "Info",
cli_only=True, args_hint="[number]"),
CommandDef("paste", "Attach clipboard image from your clipboard", "Info",
cli_only=True),
Expand Down
130 changes: 130 additions & 0 deletions hermes_cli/curses_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,136 @@ def _numbered_single_fallback(
return None


def curses_copy_picker(
items: List[str],
*,
cancel_label: str = "Cancel",
) -> tuple[int | None, bool]:
"""Curses picker for /copy code-block selection.

Each item is a label shown in the list. Returns ``(index, write)``
where *index* is the selected item (``None`` on cancel) and *write*
is ``True`` when the user pressed ``w`` instead of Enter (meaning
"write to file" rather than "copy to clipboard").
"""
if not sys.stdin.isatty():
return (None, False)

try:
import curses
result_holder: list = [None, False]

all_items = list(items) + [cancel_label]
cancel_idx = len(items)

def _draw(stdscr):
curses.curs_set(0)
if curses.has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_GREEN, -1)
curses.init_pair(2, curses.COLOR_YELLOW, -1)
curses.init_pair(3, curses.COLOR_CYAN, -1)
cursor = 0
scroll_offset = 0

while True:
stdscr.clear()
max_y, max_x = stdscr.getmaxyx()

try:
hattr = curses.A_BOLD
if curses.has_colors():
hattr |= curses.color_pair(2)
stdscr.addnstr(0, 0, "Select content to copy", max_x - 1, hattr)
hint = " \u2191\u2193 navigate ENTER copy w write to file ESC cancel"
stdscr.addnstr(1, 0, hint, max_x - 1, curses.A_DIM)
except curses.error:
pass

visible_rows = max_y - 3
if cursor < scroll_offset:
scroll_offset = cursor
elif cursor >= scroll_offset + visible_rows:
scroll_offset = cursor - visible_rows + 1

for draw_i, i in enumerate(
range(scroll_offset, min(len(all_items), scroll_offset + visible_rows))
):
y = draw_i + 3
if y >= max_y - 1:
break
arrow = "\u2192" if i == cursor else " "
line = f" {arrow} {all_items[i]}"
attr = curses.A_NORMAL
if i == cursor:
attr = curses.A_BOLD
if curses.has_colors():
attr |= curses.color_pair(1)
try:
stdscr.addnstr(y, 0, line, max_x - 1, attr)
except curses.error:
pass

stdscr.refresh()
key = stdscr.getch()

if key in (curses.KEY_UP, ord("k")):
cursor = (cursor - 1) % len(all_items)
elif key in (curses.KEY_DOWN, ord("j")):
cursor = (cursor + 1) % len(all_items)
elif key in (curses.KEY_ENTER, 10, 13):
result_holder[0] = cursor
result_holder[1] = False
return
elif key == ord("w"):
result_holder[0] = cursor
result_holder[1] = True
return
elif key in (27, ord("q")):
result_holder[0] = None
result_holder[1] = False
return

curses.wrapper(_draw)
flush_stdin()
idx = result_holder[0]
if idx is not None and idx >= cancel_idx:
return (None, False)
return (idx, result_holder[1])

except Exception:
return _copy_picker_numbered_fallback(items, cancel_label)


def _copy_picker_numbered_fallback(
items: List[str],
cancel_label: str,
) -> tuple[int | None, bool]:
"""Text-based numbered fallback for the copy picker."""
from hermes_cli.colors import Colors as _C, color as _clr

print(_clr("\n Select content to copy", _C.YELLOW))
print(_clr(" Enter number to copy, prefix with w to write to file.\n", _C.DIM))
for i, label in enumerate(items, 1):
print(f" {i}. {label}")
print(f" {len(items) + 1}. {cancel_label}")
print()
try:
val = input(_clr(" Choice: ", _C.DIM)).strip()
if not val:
return (None, False)
write = val.lower().startswith("w")
if write:
val = val[1:].strip()
idx = int(val) - 1
if 0 <= idx < len(items):
return (idx, write)
except (ValueError, KeyboardInterrupt, EOFError):
pass
return (None, False)


def _numbered_fallback(
title: str,
items: List[str],
Expand Down
Loading