Skip to content
Open
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
31 changes: 31 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,32 @@ def _assistant_copy_text(content: Any) -> str:
return _strip_reasoning_tags(_assistant_content_as_text(content))


def _print_code_copy_hint(response: Any) -> None:
"""Print copy hint if the response contains code blocks.

Called after a response completes to let the user know they can
use ``/cc`` (short for ``/copy-code``) to copy code blocks.
"""
from hermes_cli.code_fences import parse_code_fences

if response is None:
return
text = _assistant_content_as_text(response) if not isinstance(response, str) else response
fences = parse_code_fences(text)
closed_fences = [f for f in fences if f["closed"]]
if not closed_fences:
return
if len(closed_fences) == 1:
_cprint(
f"\n{_DIM}💡 Single code block — run /cc to copy it{_RST}"
)
else:
_cprint(
f"\n{_DIM}💡 {len(closed_fences)} code blocks — "
f"/cc to list, /cc N to copy block N{_RST}"
)


# =============================================================================
# Configuration Loading
# =============================================================================
Expand Down Expand Up @@ -10490,6 +10516,8 @@ def process_command(self, command: str) -> bool:
self._show_insights(cmd_original)
elif canonical == "copy":
self._handle_copy_command(cmd_original)
elif canonical == "copy-code":
self._handle_copy_code_command(cmd_original)
elif canonical == "debug":
self._handle_debug_command(cmd_original)
elif canonical == "update":
Expand Down Expand Up @@ -14820,6 +14848,9 @@ def run_agent():
if self._voice_tts and response and not use_streaming_tts:
self._voice_speak_response_async(response)

# Show code copy hint after responses containing fenced code blocks
_print_code_copy_hint(response)


# Re-queue the interrupt message (and any that arrived while we were
# processing the first) as the next prompt for process_loop.
Expand Down
78 changes: 78 additions & 0 deletions hermes_cli/cli_commands_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -3598,6 +3598,84 @@ def _handle_voice_command(self, command: str):
_cprint(f"Unknown voice subcommand: {subcommand}")
_cprint("Usage: /voice [on|off|tts|status]")

def _handle_copy_code_command(self, cmd_original: str) -> None:
"""Handle /copy-code [block_number] for the latest fenced code block."""
from cli import _assistant_content_as_text, _cprint
from hermes_cli.code_fences import parse_code_fences

parts = cmd_original.split(maxsplit=1)
raw_arg = parts[1].strip() if len(parts) > 1 else ""
if raw_arg and (not raw_arg.isdecimal() or int(raw_arg) < 1):
_cprint(" Usage: /copy-code [block_number]")
return

assistant_messages = [
message for message in self.conversation_history
if message.get("role") == "assistant"
]
if not assistant_messages:
_cprint(" Nothing to copy yet — no assistant responses.")
return

closed_fences: list[dict] = []
for message in reversed(assistant_messages):
content = message.get("content", "")
if isinstance(content, list):
content = _assistant_content_as_text(content) if content else ""
elif content is None:
content = ""
closed_fences = [fence for fence in parse_code_fences(content) if fence["closed"]]
if closed_fences:
break

if not closed_fences:
_cprint(" No complete code blocks found in assistant responses.")
return

if raw_arg:
block_index = int(raw_arg) - 1
if block_index >= len(closed_fences):
_cprint(f" Invalid block number. Use 1-{len(closed_fences)}.")
return
self._copy_fence(closed_fences[block_index], block_index + 1)
return

if len(closed_fences) == 1:
self._copy_fence(closed_fences[0], 1)
return

self._code_block_preview(closed_fences)

def _copy_fence(self, fence: dict, block_num: int) -> None:
"""Copy raw fenced content using the same policy as /copy."""
from cli import _cprint
from hermes_cli.clipboard import is_remote_shell_session, write_clipboard_text

text = fence["raw_content"]
try:
if is_remote_shell_session():
self._write_osc52_clipboard(text)
method = " via OSC 52 (terminal support required)"
elif write_clipboard_text(text):
method = ""
else:
self._write_osc52_clipboard(text)
method = " via OSC 52 (terminal support required)"
_cprint(f" Copied code block #{block_num} ({fence['language'] or 'text'}) to clipboard{method}")
except Exception as exc:
_cprint(f" Failed to copy code block #{block_num} to clipboard: {exc}")

def _code_block_preview(self, fences: list[dict]) -> None:
"""Print a numbered list of fences for a follow-up /cc selection."""
from cli import _DIM, _RST, _cprint

_cprint(" Code blocks in the last response:")
for index, fence in enumerate(fences, start=1):
first_line = fence["raw_content"].split("\n", 1)[0].strip()
preview = first_line[:47] + "..." if len(first_line) > 50 else first_line
_cprint(f" {index} {fence['language'] or 'text':12s} {preview}")
_cprint(f"\n {_DIM}Run /cc <1-{len(fences)}> to copy a block{_RST}")

def _handle_wake_command(self, command: str):
"""Handle /wake [on|off|status] — the 'Hey Hermes' hotword listener.

Expand Down
119 changes: 119 additions & 0 deletions hermes_cli/code_fences.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""Pure fenced-code-block parser for Markdown.

Equivalent to the TypeScript ``codeFence.ts`` used by CopyBlox.
Recognises backtick and tilde fences (length ≥ 3), requires matching
closer character and minimum length, extracts info string / language,
and returns the raw content between the fences.
"""

from __future__ import annotations

import re

_FENCE_OPENER_RE = re.compile(r'^\s*(`{3,}|~{3,})(.*)$')
_FENCE_CLOSER_RE = re.compile(r'^\s*(`{3,}|~{3,})\s*$')


def parse_code_fences(source: str) -> list[dict]:
"""Parse all fenced code blocks from raw source text.

Returns a list of dicts with keys:

* ``closed`` (bool) — whether a matching closer was found
* ``open_line_index`` (int) — 0-based line index of the opener
* ``end_line_index`` (int) — line index of the closer, or ``-1``
* ``fence_char`` (``'`'`` or ``'~'``)
* ``fence_length`` (int)
* ``info_string`` (str) — raw info from the opener line
* ``language`` (str) — normalised display language
* ``raw_content`` (str) — exact text between fences
"""
lines = _line_boundaries(source)
fences: list[dict] = []
i = 0

while i < len(lines):
line = lines[i]
match = _FENCE_OPENER_RE.match(line)

if not match:
i += 1
continue

fence_char = match.group(1)[0]
fence_length = len(match.group(1))
info_string = match.group(2).strip()
open_line_index = i
i += 1

content_parts: list[str] = []
closer_line = -1

for scan in range(i, len(lines)):
close_match = _FENCE_CLOSER_RE.match(lines[scan])
if (
close_match
and close_match.group(1)[0] == fence_char
and len(close_match.group(1)) >= fence_length
):
closer_line = scan
break
# Collect content lines
content_parts.append(lines[scan])

closed = closer_line >= 0

if closed:
i = closer_line + 1 # skip past the closer
else:
i = len(lines) # reached end of source

raw_content = '\n'.join(content_parts)
language = _parse_language(info_string, fence_char, raw_content)

fences.append({
'closed': closed,
'open_line_index': open_line_index,
'end_line_index': closer_line,
'fence_char': fence_char,
'fence_length': fence_length,
'info_string': info_string,
'language': language,
'raw_content': raw_content,
})

return fences


def _line_boundaries(source: str) -> list[str]:
"""Split *source* into lines on ``\\n``."""
lines: list[str] = []
start = 0

for idx, ch in enumerate(source):
if ch == '\n':
lines.append(source[start:idx])
start = idx + 1

# Last line (no trailing newline)
if start <= len(source):
lines.append(source[start:])

return lines


def _parse_language(info_string: str, _fence_char: str, raw_content: str) -> str:
"""Extract normalised display language from the info string.

Falls back to ``'diff'`` when content looks like a unified diff,
otherwise returns ``'text'``.
"""
if info_string:
first_token = info_string.split()[0]
normalised = first_token.lower().replace('language:', '').replace('language=', '')
return normalised

if raw_content.startswith('--- ') or raw_content.startswith('+++ '):
return 'diff'

return 'text'
2 changes: 2 additions & 0 deletions hermes_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,8 @@ class CommandDef:
gateway_only=True, args_hint="<pause|resume|list> [name]"),
CommandDef("copy", "Copy the last assistant response to clipboard", "Info",
cli_only=True, args_hint="[number]"),
CommandDef("copy-code", "Pick or copy a fenced code block from the last response", "Info",
cli_only=True, args_hint="[block_number]", aliases=("cc", "copyblock")),
CommandDef("paste", "Attach clipboard image from your clipboard", "Info",
cli_only=True),
CommandDef("image", "Attach a local image file for your next prompt", "Info",
Expand Down
63 changes: 63 additions & 0 deletions tests/cli/test_cli_copy_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,66 @@ def test_copy_native_first_when_local():
mock_osc52.assert_not_called()


def _make_cli_with_code_block(code: str, language: str = "python") -> HermesCLI:
cli_obj = _make_cli()
cli_obj.conversation_history = [{"role": "assistant", "content": f"```{language}\n{code}\n```"}]
return cli_obj


def test_copy_code_uses_native_clipboard_locally():
cli_obj = _make_cli_with_code_block("x = 1")

with patch("hermes_cli.clipboard.is_remote_shell_session", return_value=False), \
patch("hermes_cli.clipboard.write_clipboard_text", return_value=True) as mock_native, \
patch.object(cli_obj, "_write_osc52_clipboard") as mock_osc52:
cli_obj.process_command("/copy-code 1")

mock_native.assert_called_once_with("x = 1")
mock_osc52.assert_not_called()


def test_copy_code_uses_osc52_over_ssh():
cli_obj = _make_cli_with_code_block("x = 1")

with patch("hermes_cli.clipboard.is_remote_shell_session", return_value=True), \
patch("hermes_cli.clipboard.write_clipboard_text") as mock_native, \
patch.object(cli_obj, "_write_osc52_clipboard") as mock_osc52:
cli_obj.process_command("/cc 1")

mock_osc52.assert_called_once_with("x = 1")
mock_native.assert_not_called()


def test_copy_code_falls_back_to_osc52_when_native_copy_fails():
cli_obj = _make_cli_with_code_block("x = 1")

with patch("hermes_cli.clipboard.is_remote_shell_session", return_value=False), \
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-code")

mock_osc52.assert_called_once_with("x = 1")


def test_copy_code_skips_unclosed_fences_and_searches_backward():
cli_obj = _make_cli()
cli_obj.conversation_history = [
{"role": "assistant", "content": "```python\nearlier = True\n```"},
{"role": "assistant", "content": "```python\npartial = True"},
]

with patch("hermes_cli.clipboard.is_remote_shell_session", return_value=False), \
patch("hermes_cli.clipboard.write_clipboard_text", return_value=True) as mock_native:
cli_obj.process_command("/copy-code 1")

mock_native.assert_called_once_with("earlier = True")


def test_copy_code_rejects_non_integer_block_number():
cli_obj = _make_cli_with_code_block("x = 1")

with patch("cli._cprint") as mock_print:
cli_obj.process_command("/copy-code 2abc")

assert any("Usage: /copy-code" in str(call) for call in mock_print.call_args_list)

Loading