diff --git a/cli.py b/cli.py index 2e822be3746a..d2fef70341eb 100644 --- a/cli.py +++ b/cli.py @@ -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 # ============================================================================= @@ -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": @@ -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. diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index d4accf472cc9..c421dd6bf5e2 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -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. diff --git a/hermes_cli/code_fences.py b/hermes_cli/code_fences.py new file mode 100644 index 000000000000..486877282ce8 --- /dev/null +++ b/hermes_cli/code_fences.py @@ -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' diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 1f9e70e43089..06f0383066b3 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -328,6 +328,8 @@ class CommandDef: gateway_only=True, args_hint=" [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", diff --git a/tests/cli/test_cli_copy_command.py b/tests/cli/test_cli_copy_command.py index 91d4ae21a253..f9991a9eee64 100644 --- a/tests/cli/test_cli_copy_command.py +++ b/tests/cli/test_cli_copy_command.py @@ -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) + diff --git a/tests/cli/test_code_fences.py b/tests/cli/test_code_fences.py new file mode 100644 index 000000000000..d22713fdf12c --- /dev/null +++ b/tests/cli/test_code_fences.py @@ -0,0 +1,93 @@ +"""Tests for ``hermes_cli.code_fences`` β€” Python fence parser.""" + +from hermes_cli.code_fences import parse_code_fences + + +def test_simple_backtick_fence(): + source = "```python\nprint('hello')\n```\n" + fences = parse_code_fences(source) + assert len(fences) == 1 + f = fences[0] + assert f["closed"] is True + assert f["fence_char"] == "`" + assert f["fence_length"] == 3 + assert f["language"] == "python" + assert f["raw_content"] == "print('hello')" + + +def test_no_language_defaults_to_text(): + source = "```\nsome code\n```\n" + fences = parse_code_fences(source) + assert fences[0]["language"] == "text" + + +def test_longer_fence(): + source = "````\ncode\n````\n" + fences = parse_code_fences(source) + assert fences[0]["fence_length"] == 4 + + +def test_closer_shorter_than_opener(): + source = "`````\ncode\n```\n" + fences = parse_code_fences(source) + assert fences[0]["closed"] is False + + +def test_triple_tilde(): + source = "~~~js\nconst x = 1;\n~~~\n" + fences = parse_code_fences(source) + assert fences[0]["fence_char"] == "~" + assert fences[0]["language"] == "js" + assert fences[0]["raw_content"] == "const x = 1;" + + +def test_mismatched_character_does_not_close(): + source = "~~~\ncode\n```\n" + fences = parse_code_fences(source) + assert fences[0]["closed"] is False + + +def test_unclosed_fence(): + source = "```python\npartial code" + fences = parse_code_fences(source) + assert fences[0]["closed"] is False + assert fences[0]["raw_content"] == "partial code" + + +def test_multiple_fences_latter_unclosed(): + source = "```py\na\n```\nsome text\n```js\nb\n" + fences = parse_code_fences(source) + assert len(fences) == 2 + assert fences[0]["closed"] is True + assert fences[0]["raw_content"] == "a" + assert fences[1]["closed"] is False + + +def test_language_first_token(): + source = "```typescript linenums\nx = 1\n```\n" + fences = parse_code_fences(source) + assert fences[0]["language"] == "typescript" + + +def test_language_lowercased(): + source = "```PYTHON\ncode\n```\n" + fences = parse_code_fences(source) + assert fences[0]["language"] == "python" + + +def test_preserves_tabs(): + source = "```py\n\tcode\n```\n" + fences = parse_code_fences(source) + assert "\t" in fences[0]["raw_content"] + + +def test_preserves_trailing_spaces(): + source = "```py\ncode \n```\n" + fences = parse_code_fences(source) + assert fences[0]["raw_content"] == "code " + + +def test_diff_detected(): + source = "```\n--- old\n+++ new\n```\n" + fences = parse_code_fences(source) + assert fences[0]["language"] == "diff" diff --git a/ui-tui/packages/hermes-ink/index.d.ts b/ui-tui/packages/hermes-ink/index.d.ts index 77469aa67bf7..05b989bc0bd3 100644 --- a/ui-tui/packages/hermes-ink/index.d.ts +++ b/ui-tui/packages/hermes-ink/index.d.ts @@ -19,6 +19,7 @@ export { default as Spacer } from './src/ink/components/Spacer.tsx' export type { Props as StdinProps } from './src/ink/components/StdinContext.ts' export { default as Text } from './src/ink/components/Text.tsx' export type { Props as TextProps } from './src/ink/components/Text.tsx' +export type { ClickEvent } from './src/ink/events/click-event.js' export type { Key } from './src/ink/events/input-event.ts' export { default as useApp } from './src/ink/hooks/use-app.ts' export { useCursorAdvance } from './src/ink/hooks/use-cursor-advance.ts' @@ -36,7 +37,9 @@ export { createRoot, forceRedraw, default as render, renderSync } from './src/in export type { Instance, RenderOptions, Root } from './src/ink/root.ts' export { stringWidth } from './src/ink/stringWidth.ts' export type { MouseTrackingMode } from './src/ink/termio/dec.ts' +export { type ClipboardResult, setClipboard } from './src/ink/termio/osc.js' export { wrapAnsi } from './src/ink/wrapAnsi.ts' + // 'ink-text-input' types deliberately not re-exported here; see // src/entry-exports.ts for the full rationale (#31227). Use the // '@hermes/ink/text-input' subpath when the upstream widget is needed. diff --git a/ui-tui/packages/hermes-ink/src/entry-exports.ts b/ui-tui/packages/hermes-ink/src/entry-exports.ts index 6bca33a6f435..21aa3cfc1caa 100644 --- a/ui-tui/packages/hermes-ink/src/entry-exports.ts +++ b/ui-tui/packages/hermes-ink/src/entry-exports.ts @@ -36,6 +36,7 @@ export { terminalForegroundHex } from './ink/terminal.js' export type { MouseTrackingMode } from './ink/termio/dec.js' +export { setClipboard } from './ink/termio/osc.js' export { wrapAnsi } from './ink/wrapAnsi.js' // NOTE: Do not re-export from 'ink-text-input' here. diff --git a/ui-tui/src/__tests__/codeFence.test.ts b/ui-tui/src/__tests__/codeFence.test.ts new file mode 100644 index 000000000000..f1e6453da1cc --- /dev/null +++ b/ui-tui/src/__tests__/codeFence.test.ts @@ -0,0 +1,234 @@ +/** + * Tests for `src/domain/codeFence.ts` β€” pure code-fence parser. + */ + +import { describe, expect, it } from 'vitest' + +import { parseCodeFences } from '../domain/codeFence.js' + +describe('parseCodeFences', () => { + it('parses a simple backtick fence', () => { + const text = '```python\nprint("hello")\n```\n' + const fences = parseCodeFences(text) + + expect(fences).toHaveLength(1) + expect(fences[0]!.closed).toBe(true) + expect(fences[0]!.fenceChar).toBe('`') + expect(fences[0]!.fenceLength).toBe(3) + expect(fences[0]!.language).toBe('python') + expect(fences[0]!.rawContent).toBe('print("hello")') + expect(fences[0]!.infoString).toBe('python') + expect(fences[0]!.endLineIndex).toBe(2) + }) + + it('parses a tilde fence', () => { + const text = '~~~javascript\nconst x = 1\n~~~\n' + const fences = parseCodeFences(text) + + expect(fences).toHaveLength(1) + expect(fences[0]!.fenceChar).toBe('~') + expect(fences[0]!.fenceLength).toBe(3) + expect(fences[0]!.language).toBe('javascript') + expect(fences[0]!.rawContent).toBe('const x = 1') + }) + + it('requires fence length >= 3', () => { + // `` only two backticks β€” not a fence opener + const text = '``python\nprint("nope")\n``\n' + const fences = parseCodeFences(text) + + expect(fences).toHaveLength(0) + }) + + it('requires closer to match fence character', () => { + // Opens with backticks, closes with tildes β€” never closes + const text = '```python\nprint("hello")\n~~~\n' + const fences = parseCodeFences(text) + + expect(fences).toHaveLength(1) + expect(fences[0]!.closed).toBe(false) + }) + + it('requires closer length >= opener length', () => { + // Opens with ```` (4), closer is ``` (3) β€” not enough + const text = '````python\ncode\n```\n' + const fences = parseCodeFences(text) + + expect(fences).toHaveLength(1) + expect(fences[0]!.closed).toBe(false) + }) + + it('parses a longer fence', () => { + const text = '````ts\nconst a = 1\n````\n' + const fences = parseCodeFences(text) + + expect(fences).toHaveLength(1) + expect(fences[0]!.fenceLength).toBe(4) + expect(fences[0]!.language).toBe('ts') + expect(fences[0]!.rawContent).toBe('const a = 1') + }) + + it('extracts language from info string', () => { + const text = '```typescript linenums="1" hl_lines=[1]\nprint(1)\n```\n' + const fences = parseCodeFences(text) + + expect(fences).toHaveLength(1) + expect(fences[0]!.language).toBe('typescript') + expect(fences[0]!.infoString).toBe('typescript linenums="1" hl_lines=[1]') + }) + + it('defaults language to "text" when no info string', () => { + const text = '```\nplain content\n```\n' + const fences = parseCodeFences(text) + + expect(fences).toHaveLength(1) + expect(fences[0]!.language).toBe('text') + expect(fences[0]!.infoString).toBe('') + }) + + it('defaults language to "diff" for diff content', () => { + const text = '```\n--- old.py\n+++ new.py\n-print()\n+print(1)\n```\n' + const fences = parseCodeFences(text) + + expect(fences).toHaveLength(1) + expect(fences[0]!.language).toBe('diff') + }) + + it('preserves tabs and trailing spaces in rawContent', () => { + const text = '```py\n\tindented\ntrailing \n```\n' + const fences = parseCodeFences(text) + + expect(fences[0]!.rawContent).toBe('\tindented\ntrailing ') + }) + + it('preserves empty interior lines', () => { + const text = '```js\nline one\n\nline three\n```\n' + const fences = parseCodeFences(text) + + expect(fences[0]!.rawContent).toBe('line one\n\nline three') + }) + + it('handles unclosed fences', () => { + const text = '```python\ncode without closer\n' + const fences = parseCodeFences(text) + + expect(fences).toHaveLength(1) + expect(fences[0]!.closed).toBe(false) + expect(fences[0]!.endLineIndex).toBe(-1) + expect(fences[0]!.rawContent).toBe('code without closer\n') + }) + + it('handles empty code blocks', () => { + const text = '```text\n```\n' + const fences = parseCodeFences(text) + + expect(fences).toHaveLength(1) + expect(fences[0]!.rawContent).toBe('') + }) + + it('parses multiple fences in one source', () => { + const text = '```python\nprint(1)\n```\n\nsome text\n\n```rust\nfn main() {}\n```\n' + const fences = parseCodeFences(text) + + expect(fences).toHaveLength(2) + expect(fences[0]!.language).toBe('python') + expect(fences[0]!.rawContent).toBe('print(1)') + expect(fences[1]!.language).toBe('rust') + expect(fences[1]!.rawContent).toBe('fn main() {}') + }) + + it('handles $${bait}$$ inside a code fence without breaking', () => { + const text = '```\n// $$ looks like math $$\ncode here\n```\n' + const fences = parseCodeFences(text) + + expect(fences).toHaveLength(1) + expect(fences[0]!.closed).toBe(true) + expect(fences[0]!.rawContent).toBe('// $$ looks like math $$\ncode here') + }) + + it('requires closer to use same character type', () => { + // Backtick opener, tilde closer β€” no match, never closes + const text = '````\ncode\n~~~`\n' + const fences = parseCodeFences(text) + + expect(fences).toHaveLength(1) + expect(fences[0]!.fenceChar).toBe('`') + expect(fences[0]!.closed).toBe(false) + }) + + it('handles tilde closer matching tilde opener', () => { + const text = '~~~python\ncode\n~~~\n' + const fences = parseCodeFences(text) + + expect(fences).toHaveLength(1) + expect(fences[0]!.fenceChar).toBe('~') + expect(fences[0]!.closed).toBe(true) + }) + + it('ignores whitespace-only info string', () => { + const text = '``` \ncode\n```\n' + const fences = parseCodeFences(text) + + expect(fences).toHaveLength(1) + expect(fences[0]!.language).toBe('text') + expect(fences[0]!.infoString).toBe('') + }) + + it('preserves intentional final blank line', () => { + const text = '```python\ncode\n\n```\n' + const fences = parseCodeFences(text) + + expect(fences[0]!.rawContent).toBe('code\n') + }) + + it('handles mixed fence types in same source', () => { + const text = '```backtick```\ncode1\n```\n~~~tilde~~~\ncode2\n~~~\n' + const fences = parseCodeFences(text) + + expect(fences).toHaveLength(2) + expect(fences[0]!.fenceChar).toBe('`') + expect(fences[0]!.rawContent).toBe('code1') + expect(fences[1]!.fenceChar).toBe('~') + expect(fences[1]!.rawContent).toBe('code2') + }) + + it('handles fenced code with complex content', () => { + const content = [ + 'import sys', + '', + 'def main():', + '\t# tab-indented body', + '\tprint("tabs and quotes")', + '\treturn 0 ', + '', + 'if __name__ == "__main__":', + '\tsys.exit(main())', + '' + ].join('\n') + + const text = '```python\n' + content + '\n```\n' + const fences = parseCodeFences(text) + + expect(fences).toHaveLength(1) + expect(fences[0]!.rawContent).toBe(content) + }) + + it('handles fence with leading whitespace on opener', () => { + const text = ' ```python\n code\n ```\n' + const fences = parseCodeFences(text) + + // The parser uses `/^\s*(`{3,}|~{3,})/` β€” leading whitespace is allowed + // on the opener. But the content includes the leading whitespace. + expect(fences).toHaveLength(1) + expect(fences[0]!.language).toBe('python') + }) + + it('handles tilde fence inside backtick fence', () => { + const text = '```\ntilde ~~~ is not a fence\n~~~\n```\n' + const fences = parseCodeFences(text) + + expect(fences).toHaveLength(1) + expect(fences[0]!.closed).toBe(true) + expect(fences[0]!.rawContent).toBe('tilde ~~~ is not a fence\n~~~') + }) +}) diff --git a/ui-tui/src/__tests__/copyText.test.ts b/ui-tui/src/__tests__/copyText.test.ts new file mode 100644 index 000000000000..5f08234cb7e9 --- /dev/null +++ b/ui-tui/src/__tests__/copyText.test.ts @@ -0,0 +1,114 @@ +/** + * Tests for `src/lib/copyText.ts` β€” shared clipboard wrapper. + * + * Uses static vi.mock factory (hoisted) + top-level mock ref capture. + * copyText is imported dynamically per-test so each test gets a fresh + * evaluation of the module with the current mock state. + */ + +import { Buffer } from 'buffer' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// ── Mock registration (hoisted by Vitest) ────────────────────────── +// The factory runs during module registration, which is before any test +// code executes. copyText.ts imports setClipboard at the top level, so +// it receives this vi.fn() in its module scope. + +const mockFn = vi.fn(() => Promise.resolve({ success: true, sequence: '' })) + +vi.mock('@hermes/ink', () => ({ setClipboard: mockFn })) + +// ── Stdout capture ───────────────────────────────────────────────── + +const captureStdout = () => { + const chunks: Buffer[] = [] + const originalWrite = process.stdout.write.bind(process.stdout) + + process.stdout.write = (chunk: string | Buffer, cb?: (err?: Error | undefined) => void) => { + if (typeof chunk === 'string') { chunk = Buffer.from(chunk) } + chunks.push(Buffer.from(chunk)) + + if (cb) { cb() } + + return true + } + + return { + clear: () => { chunks.length = 0 }, + get: () => Buffer.concat(chunks).toString(), + restore: () => { process.stdout.write = originalWrite } + } +} + +describe('copyText', () => { + let stdout: ReturnType + + beforeEach(() => { + mockFn.mockClear() + mockFn.mockResolvedValue({ success: true, sequence: '' }) + stdout = captureStdout() + }) + + afterEach(() => { + stdout.restore() + }) + + it('returns native-or-tmux success when setClipboard succeeds with no sequence', async () => { + const { copyText } = await import('../lib/copyText.js') + const result = await copyText('hello') + expect(result).toEqual({ success: true, method: 'native-or-tmux' }) + }) + + it('returns osc52 success when setClipboard produces a sequence', async () => { + mockFn.mockResolvedValue({ success: true, sequence: '\x1b]52;c;base64content\x07' }) + const { copyText } = await import('../lib/copyText.js') + const result = await copyText('hello') + expect(result).toEqual({ success: true, method: 'osc52' }) + }) + + it('returns none failure when setClipboard reports failure', async () => { + mockFn.mockResolvedValue({ success: false, sequence: '' }) + const { copyText } = await import('../lib/copyText.js') + const result = await copyText('hello') + expect(result).toEqual({ success: false, method: 'none' }) + }) + + it('returns none failure when setClipboard throws', async () => { + mockFn.mockRejectedValue(new Error('clipboard daemon not found')) + const { copyText } = await import('../lib/copyText.js') + const result = await copyText('hello') + expect(result).toEqual({ success: false, method: 'none' }) + }) + + it('writes the OSC sequence to stdout when present', async () => { + const oscSeq = '\x1b]52;c;base64\x07' + mockFn.mockResolvedValue({ success: true, sequence: oscSeq }) + const { copyText } = await import('../lib/copyText.js') + await copyText('data') + expect(stdout.get()).toBe(oscSeq) + }) + + it('does not write to stdout on native success (empty sequence)', async () => { + mockFn.mockResolvedValue({ success: true, sequence: '' }) + const { copyText } = await import('../lib/copyText.js') + await copyText('data') + expect(stdout.get()).toBe('') + }) + + it('never transforms the input text', async () => { + const tricky = '\tindented\ntrailing \n' + mockFn.mockResolvedValue({ success: true, sequence: '' }) + const { copyText } = await import('../lib/copyText.js') + await copyText(tricky) + expect(mockFn).toHaveBeenCalledWith(tricky) + }) + + it('handles empty string gracefully', async () => { + mockFn.mockResolvedValue({ success: true, sequence: '' }) + const { copyText } = await import('../lib/copyText.js') + const result = await copyText('') + expect(result).toEqual({ success: true, method: 'native-or-tmux' }) + expect(mockFn).toHaveBeenCalledWith('') + }) +}) diff --git a/ui-tui/src/__tests__/copyblox.test.tsx b/ui-tui/src/__tests__/copyblox.test.tsx new file mode 100644 index 000000000000..8a65b66c954c --- /dev/null +++ b/ui-tui/src/__tests__/copyblox.test.tsx @@ -0,0 +1,212 @@ +/** + * Tests for `src/components/copyblox.tsx` β€” CopyBlox React component. + */ + +import { PassThrough } from 'stream' + +import type * as HermesInk from '@hermes/ink' +import { Box, renderSync, stringWidth, Text } from '@hermes/ink' +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { copyText, noSelectClickHandlers } = vi.hoisted(() => ({ + copyText: vi.fn(), + noSelectClickHandlers: [] as Array +})) + +vi.mock('../lib/copyText.js', () => ({ copyText })) + +vi.mock('@hermes/ink', async importOriginal => { + const actual = await importOriginal() + + return { + ...actual, + NoSelect: (props: React.ComponentProps) => { + noSelectClickHandlers.push(props.onClick) + + return React.createElement(actual.NoSelect, props) + } + } +}) + +import { CopyBlox } from '../components/copyblox.js' +import { stripAnsi } from '../lib/text.js' +import { DEFAULT_THEME } from '../theme.js' + +const BEL = String.fromCharCode(7) +const ESC = String.fromCharCode(27) +const CSI_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*[@-~]`, 'g') +const OSC_RE = new RegExp(`${ESC}\\][\\s\\S]*?(?:${BEL}|${ESC}\\\\)`, 'g') + +const renderPlain = (node: React.ReactNode) => { + const stdout = new PassThrough() + const stdin = new PassThrough() + const stderr = new PassThrough() + let output = '' + + Object.assign(stdout, { columns: 80, isTTY: false, rows: 24 }) + Object.assign(stdin, { isTTY: false }) + Object.assign(stderr, { isTTY: false }) + stdout.on('data', chunk => { + output += chunk.toString() + }) + + const instance = renderSync(node, { + patchConsole: false, + stderr: stderr as NodeJS.WriteStream, + stdin: stdin as NodeJS.ReadStream, + stdout: stdout as NodeJS.WriteStream + }) + + instance.unmount() + instance.cleanup() + + return output + .replace(OSC_RE, '') + .split('\n') + .map(line => stripAnsi(line).replace(CSI_RE, '').trimEnd()) +} + +describe('CopyBlox', () => { + beforeEach(() => { + copyText.mockReset() + noSelectClickHandlers.length = 0 + }) + + it('renders language label and idle COPY button for empty block', () => { + const lines = renderPlain( + React.createElement(CopyBlox, { + closed: true, + language: 'python', + rawContent: '', + theme: DEFAULT_THEME, + cols: 80 + }) + ) + + const output = lines.join('\n') + + expect(output).toContain('python') + }) + + it('renders children inside the code body', () => { + const lines = renderPlain( + React.createElement( + CopyBlox, + { closed: true, language: 'ts', rawContent: 'x = 1', theme: DEFAULT_THEME, cols: 80 }, + React.createElement(Box, null, React.createElement(Text, null, 'x = 1')) + ) + ) + + // Rendered output should contain the code text + expect(lines.length).toBeGreaterThan(1) + }) + + it('defaults language to "text" when empty', () => { + const lines = renderPlain( + React.createElement(CopyBlox, { + closed: true, + language: '', + rawContent: 'content', + theme: DEFAULT_THEME, + cols: 80 + }) + ) + + const output = lines.join('\n') + + expect(output).toContain('text') + }) + + it('renders borders with correct characters', () => { + const output = renderPlain( + React.createElement(CopyBlox, { closed: true, language: 'py', rawContent: '', theme: DEFAULT_THEME, cols: 80 }) + ).join('\n') + + // Top border should contain β”Œ and bottom border should contain β”˜ + expect(output).toMatch(/β”Œ/) + expect(output).toMatch(/β”˜/) + }) + + it('shows idle 3Γ—2 copy icon by default', () => { + const lines = renderPlain( + React.createElement(CopyBlox, { closed: true, language: 'py', rawContent: '', theme: DEFAULT_THEME, cols: 80 }) + ) + + const output = lines.join('\n') + + expect(output).toContain('⧉⧉⧉') + }) + + it('uses a width-safe left accent for narrow code blocks', () => { + const lines = renderPlain( + React.createElement( + CopyBlox, + { + closed: true, + cols: 12, + compact: false, + language: 'ν•œκ΅­μ–΄_πŸ˜€_very_long', + rawContent: 'x'.repeat(30), + theme: DEFAULT_THEME + }, + React.createElement(Text, null, 'x') + ) + ) + + expect(lines.some(line => line.includes('β”Œ'))).toBe(false) + expect(lines.flatMap(line => line.split('β”‚').filter(Boolean)).every(line => stringWidth(line) <= 11)).toBe(true) + expect(lines.join('\n')).toContain('…') + }) + + it('does not register a clickable copy control for an unclosed streaming fence', () => { + const output = renderPlain( + React.createElement(CopyBlox, { + closed: false, + language: 'py', + rawContent: 'partial code', + theme: DEFAULT_THEME, + cols: 80 + }) + ).join('\n') + + expect(output).toContain('⟳') + expect(output).not.toContain('⧉⧉⧉') + expect(noSelectClickHandlers).toEqual([undefined]) + expect(copyText).not.toHaveBeenCalled() + }) + + it('renders multi-line content correctly', () => { + const codeLines = ['def hello():', ' print("hello")', ''] + const rawContent = codeLines.join('\n') + + const lines = renderPlain( + React.createElement( + CopyBlox, + { closed: true, language: 'python', rawContent: rawContent, theme: DEFAULT_THEME, cols: 80 }, + React.createElement( + Box, + { flexDirection: 'column' }, + ...codeLines.map(line => React.createElement(Text, { key: line }, line)) + ) + ) + ) + + // Should have more lines than just the border + expect(lines.length).toBeGreaterThan(3) + }) + + it('does not throw with special characters in rawContent', () => { + const specialContent = '\t\ttabbed\n spaces \nunicode: Γ± β†’ [Γ±]\n' + + expect(() => { + renderPlain( + React.createElement( + CopyBlox, + { closed: true, language: 'text', rawContent: specialContent, theme: DEFAULT_THEME, cols: 80 }, + React.createElement(Box, { flexDirection: 'column' }, React.createElement(Text, null, specialContent)) + ) + ) + }).not.toThrow() + }) +}) diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 4fa7ff2dca88..626aa4142292 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -292,6 +292,26 @@ describe('createSlashHandler', () => { process.env.TMUX = tmuxBackup }) + it('copies only a complete fenced block with the native clipboard policy', async () => { + const writeClipboardText = vi.spyOn(ClipboardModule, 'writeClipboardText').mockResolvedValue(true) + const writeOsc52Clipboard = vi.spyOn(Osc52Module, 'writeOsc52Clipboard').mockReturnValue(true) + vi.spyOn(TerminalSetupModule, 'isRemoteShellSession').mockReturnValue(false) + const ctx = buildCtx({ + local: { + ...buildLocal(), + getHistoryItems: vi.fn(() => [ + { role: 'assistant', text: '```ts\nconst complete = true\n```' }, + { role: 'assistant', text: '```ts\nconst partial = true' } + ]) + } + }) + + expect(createSlashHandler(ctx)('/cc 1')).toBe(true) + await vi.waitFor(() => expect(writeClipboardText).toHaveBeenCalledWith('const complete = true')) + expect(writeOsc52Clipboard).not.toHaveBeenCalled() + expect(ctx.transcript.sys).toHaveBeenCalledWith('copied ts block #1') + }) + it('applies /reasoning hide to the thinking section immediately', async () => { patchUiState({ sections: { thinking: 'expanded' }, showReasoning: true, sid: 'sid-abc' }) diff --git a/ui-tui/src/__tests__/markdown.test.ts b/ui-tui/src/__tests__/markdown.test.ts index 9c4f0eaed6dc..ad3c11bcb1be 100644 --- a/ui-tui/src/__tests__/markdown.test.ts +++ b/ui-tui/src/__tests__/markdown.test.ts @@ -371,6 +371,17 @@ describe('renderTable CJK width alignment', () => { }) }) +describe('Markdown fences', () => { + it('keeps recursively rendering fenced Markdown', () => { + const markdown = ['```markdown', '## Nested heading', '', '- nested item', '```'].join('\n') + const output = renderPlain(React.createElement(Md, { t: DEFAULT_THEME, text: markdown })).join('\n') + + expect(output).toContain('Nested heading') + expect(output).toContain('nested item') + expect(output).not.toContain('⧉⧉⧉') + }) +}) + describe('body prose stays in the theme palette', () => { // Prose used to render in the terminal's DEFAULT foreground while inline // tokens beside it carried a theme color, so one line mixed two inks. diff --git a/ui-tui/src/__tests__/publicInkExports.test.ts b/ui-tui/src/__tests__/publicInkExports.test.ts new file mode 100644 index 000000000000..5539faaa26a1 --- /dev/null +++ b/ui-tui/src/__tests__/publicInkExports.test.ts @@ -0,0 +1,8 @@ +import { setClipboard } from '@hermes/ink' +import { describe, expect, it } from 'vitest' + +describe('@hermes/ink public exports', () => { + it('exports setClipboard from the runtime package entry point', () => { + expect(setClipboard).toBeTypeOf('function') + }) +}) diff --git a/ui-tui/src/__tests__/streamingMarkdown.test.ts b/ui-tui/src/__tests__/streamingMarkdown.test.ts index 0c134a0c0b9e..dfad3e99778c 100644 --- a/ui-tui/src/__tests__/streamingMarkdown.test.ts +++ b/ui-tui/src/__tests__/streamingMarkdown.test.ts @@ -272,13 +272,15 @@ describe('StreamingMd rendering equivalence', () => { advanceScan(CORPUS, state) const tail = CORPUS.slice(state.settledLen) + const prefix = state.blocks.join('') + const hasPrefix = prefix.length > 0 const t = DEFAULT_THEME const split = renderPlain( React.createElement( Box, { flexDirection: 'column' }, - ...state.blocks.map((block, i) => React.createElement(Md, { key: i, t, text: block })), + hasPrefix ? React.createElement(Md, { key: 'prefix', t, text: prefix }) : null, tail ? React.createElement(Md, { key: 'tail', t, text: tail }) : null ) ) @@ -303,12 +305,14 @@ describe('StreamingMd rendering equivalence', () => { advanceScan(text, state) const tail = text.slice(state.settledLen) + const prefix = state.blocks.join('') + const hasPrefix = prefix.length > 0 const split = renderPlain( React.createElement( Box, { flexDirection: 'column' }, - ...state.blocks.map((block, i) => React.createElement(Md, { key: i, t, text: block })), + hasPrefix ? React.createElement(Md, { key: 'prefix', t, text: prefix }) : null, tail ? React.createElement(Md, { key: 'tail', t, text: tail }) : null ) ) diff --git a/ui-tui/src/__tests__/virtualHeights.test.ts b/ui-tui/src/__tests__/virtualHeights.test.ts index 17cd32fec8d3..3ad5f3409947 100644 --- a/ui-tui/src/__tests__/virtualHeights.test.ts +++ b/ui-tui/src/__tests__/virtualHeights.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { estimatedMsgHeight, messageHeightKey, wrappedLines } from '../lib/virtualHeights.js' +import { estimatedMsgHeight, fencedWrappedLines, messageHeightKey, wrappedLines } from '../lib/virtualHeights.js' import type { Msg } from '../types.js' describe('virtual height estimates', () => { @@ -104,4 +104,11 @@ describe('virtual height estimates', () => { expect(rows).toBeLessThanOrEqual(800) expect(elapsed).toBeLessThan(50) }) + + it('accounts for CopyBlox chrome and its narrower code body', () => { + const text = ['```python', 'x'.repeat(30), '```'].join('\n') + + expect(fencedWrappedLines(text, 30, false)).toBe(4) + expect(fencedWrappedLines(text, 30, true)).toBe(3) + }) }) diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 62f64ccaffc2..e057031f638d 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -3,6 +3,8 @@ import { forceRedraw, type MouseTrackingMode } from '@hermes/ink' import { DASHBOARD_TUI_MODE, NO_CONFIRM_DESTRUCTIVE } from '../../../config/env.js' import { dailyFortune, randomFortune } from '../../../content/fortunes.js' import { HOTKEYS } from '../../../content/hotkeys.js' +import type { CopyBloxFence } from '../../../domain/codeFence.js' +import { parseCodeFences } from '../../../domain/codeFence.js' import { isSectionName, nextDetailsMode, parseDetailsMode, SECTION_NAMES } from '../../../domain/details.js' import type { ConfigGetValueResponse, @@ -437,6 +439,69 @@ export const coreCommands: SlashCommand[] = [ } }, + { + aliases: ['cc', 'copyblock'], + help: 'copy a fenced code block from the latest assistant response', + name: 'copy-code', + run: async (arg, ctx) => { + const all = ctx.local.getHistoryItems().filter(message => message.role === 'assistant') + const requested = arg.trim() + + if (!all.length) { + return ctx.transcript.sys('nothing to copy β€” start a conversation first') + } + if (requested && !/^[1-9]\d*$/.test(requested)) { + return ctx.transcript.sys('usage: /cc [block_number] β€” block_number must be a positive integer') + } + + let fences: CopyBloxFence[] = [] + for (const message of [...all].reverse()) { + fences = parseCodeFences(message.text).filter(fence => fence.closed) + if (fences.length) { + break + } + } + if (!fences.length) { + return ctx.transcript.sys('no complete code blocks found β€” nothing to copy') + } + + if (!requested && fences.length > 1) { + const preview = fences + .map((fence, index) => `${index + 1}. [${fence.language || 'text'}] ${fence.rawContent.slice(0, 60)}${fence.rawContent.length > 60 ? '…' : ''}`) + .join('\n') + ctx.transcript.sys(`Code blocks in the last response (${fences.length} found):`) + ctx.transcript.sys(preview) + return ctx.transcript.sys(`Run /cc <1-${fences.length}> to copy a specific block`) + } + + const blockNumber = requested ? Number.parseInt(requested, 10) : 1 + if (blockNumber > fences.length) { + return ctx.transcript.sys(`only ${fences.length} block(s) found β€” /cc 1${fences.length > 1 ? `-${fences.length}` : ''}`) + } + + const fence = fences[blockNumber - 1]! + if (isRemoteShellSession(process.env)) { + writeOsc52Clipboard(fence.rawContent) + return ctx.transcript.sys(`copied ${fence.language || 'text'} block #${blockNumber} via OSC52 (terminal support required)`) + } + + try { + if (await writeClipboardText(fence.rawContent)) { + if (!ctx.stale()) { + ctx.transcript.sys(`copied ${fence.language || 'text'} block #${blockNumber}`) + } + } else if (!ctx.stale()) { + writeOsc52Clipboard(fence.rawContent) + ctx.transcript.sys(`copied ${fence.language || 'text'} block #${blockNumber} via OSC52 (terminal support required)`) + } + } catch (error) { + if (!ctx.stale()) { + ctx.transcript.sys(`copy failed: ${String(error)}`) + } + } + } + }, + { help: 'attach clipboard image', name: 'paste', diff --git a/ui-tui/src/components/copyblox.tsx b/ui-tui/src/components/copyblox.tsx new file mode 100644 index 000000000000..67b4f64fc86e --- /dev/null +++ b/ui-tui/src/components/copyblox.tsx @@ -0,0 +1,290 @@ +import { Box, NoSelect, stringWidth, Text } from '@hermes/ink' +import type { ClickEvent } from '@hermes/ink' +import React, { memo, useCallback, useEffect, useRef, useState } from 'react' + +import { copyText } from '../lib/copyText.js' +import type { Theme } from '../theme.js' + +// Copy state machine states +type CopyState = 'idle' | 'copying' | 'copied' | 'failed' + +// 3-cell copy icon β€” visible enough to be clearly clickable. +const COPY_ICON = '⧉⧉⧉' + +// Feedback labels shown in a compact single-row header. +const FEEDBACK: Record = { + idle: '', + copying: '…', + copied: 'βœ“', + failed: '!' +} + +const COPY_FEEDBACK_MS = 1200 +const FAIL_FEEDBACK_MS = 1500 + +interface CopyBloxProps { + children: React.ReactNode + closed: boolean + compact?: boolean + language: string + rawContent: string + theme: Theme + cols: number +} + +const NARROW_CODE_BLOCK_COLS = 20 + +const truncateToWidth = (value: string, maxWidth: number): string => { + if (stringWidth(value) <= maxWidth) { + return value + } + + const ellipsis = '…' + const budget = Math.max(0, maxWidth - stringWidth(ellipsis)) + + const segments = + typeof Intl !== 'undefined' && 'Segmenter' in Intl + ? [...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(value)].map(({ segment }) => segment) + : Array.from(value) + + let result = '' + + for (const segment of segments) { + if (stringWidth(result + segment) > budget) { + break + } + + result += segment + } + + return result + ellipsis +} + +export const CopyBlox = memo(function CopyBlox({ + children, + closed, + compact = false, + language, + rawContent, + theme, + cols +}: CopyBloxProps) { + const [copyState, setCopyState] = useState('idle') + const timerRef = useRef | null>(null) + const rawContentRef = useRef(rawContent) + const busyRef = useRef(false) + + // Always keep ref current so click handler sees latest content + rawContentRef.current = rawContent + + const doCopy = useCallback(async () => { + if (busyRef.current) { + return + } + + busyRef.current = true + + setCopyState('copying') + + const text = rawContentRef.current + const result = await copyText(text) + + if (result.success) { + setCopyState('copied') + } else { + setCopyState('failed') + } + + const ms = result.success ? COPY_FEEDBACK_MS : FAIL_FEEDBACK_MS + + if (timerRef.current) { + clearTimeout(timerRef.current) + } + + const timer = setTimeout(() => { + setCopyState('idle') + busyRef.current = false + }, ms) + + timerRef.current = timer + }, []) + + // Clean up timer on unmount + useEffect(() => { + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current) + } + } + }, []) + + const t = theme.color + const label = language || 'text' + const isStreaming = !closed + const isNarrow = compact || cols < NARROW_CODE_BLOCK_COLS + + const w = (s: string) => stringWidth(s) + const fill = (n: number) => '─'.repeat(Math.max(1, n)) + + // ── Code body rows ───────────────────────────────────────────────── + const codeRows: React.ReactNode[] = [] + let codeIdx = 0 + React.Children.forEach(children, child => { + if (child == null) { + return + } + + codeRows.push( + + {!isNarrow && {'\u2502'}} + {child} + {!isNarrow && {'\u2502'}} + + ) + }) + + // ── Bottom border ────────────────────────────────────────────────── + const bottomFillWidth = Math.max(1, cols - 2) // β”” + β”˜ + + // ── Header ───────────────────────────────────────────────────────── + // All headers are single-row: β”Œβ”€label─spacer─icon─┐ + // The 3-cell icon (⧉⧉⧉) is wide enough to be clearly visible. + + const suffixW = isStreaming || copyState !== 'idle' ? 1 : 3 + const displayLabel = truncateToWidth(label, Math.max(1, cols - (isNarrow ? 2 : 3) - suffixW)) + const labelW = w(displayLabel) + const iconW = 3 // 3 cells wide + + if (isNarrow) { + const onClick = isStreaming + ? undefined + : (e: ClickEvent) => { + e.stopImmediatePropagation() + void doCopy() + } + + return ( + + + + {displayLabel} + + {isStreaming ? '\u27f3' : copyState === 'idle' ? COPY_ICON : FEEDBACK[copyState]} + + + + + {codeRows} + + ) + } + + if (isStreaming) { + // ── Single-row streaming header ────────────────────────────────── + const fixed = 1 + 1 + labelW + iconW + 1 // β”Œβ”€label─icon─┐ + const spacerW = Math.max(0, cols - fixed) + + return ( + + + + {'\u250c'} + {'\u2500'} + {displayLabel} + {spacerW > 0 ? {fill(spacerW)} : null} + {'\u27f3'} + {'\u2510'} + + + + {codeRows} + + + {'\u2514'} + {fill(bottomFillWidth)} + {'\u2518'} + + + ) + } + + if (copyState !== 'idle') { + // ── Single-row feedback header ─────────────────────────────────── + const fixed = 1 + 1 + labelW + 4 + 1 // β”Œβ”€label─" … "─┐ + const spacerW = Math.max(0, cols - fixed) + + return ( + + { + e.stopImmediatePropagation() + doCopy() + }} + > + + {'\u250c'} + {'\u2500'} + {displayLabel} + {spacerW > 0 ? {fill(spacerW)} : null} + + {FEEDBACK[copyState]} + + {'\u2510'} + + + + {codeRows} + + + {'\u2514'} + {fill(bottomFillWidth)} + {'\u2518'} + + + ) + } + + // ── Single-row idle header with copy icon ────────────────────────── + const fixed = 1 + 1 + labelW + iconW + 1 // β”Œβ”€label─icon─┐ + const spacerW = Math.max(0, cols - fixed) + + return ( + + { + e.stopImmediatePropagation() + doCopy() + }} + > + + {'\u250c'} + {'\u2500'} + {displayLabel} + {spacerW > 0 ? {fill(spacerW)} : null} + {COPY_ICON} + {'\u2510'} + + + + {/* Code body with side borders β€” not inside click target */} + {codeRows} + + {/* Bottom border */} + + {'\u2514'} + {fill(bottomFillWidth)} + {'\u2518'} + + + ) +}) diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index 2aa421822b40..c6eb80bc6da5 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -1,12 +1,16 @@ import { Box, Link, stringWidth, Text } from '@hermes/ink' -import { Fragment, memo, type ReactNode, useMemo } from 'react' +import React, { Fragment, memo, type ReactNode, useMemo } from 'react' +import type { CopyBloxFence } from '../domain/codeFence.js' +import { parseCodeFences } from '../domain/codeFence.js' import { ensureEmojiPresentation } from '../lib/emoji.js' import { normalizeExternalUrl, urlSlugTitleLabel, useLinkTitle } from '../lib/externalLink.js' import { BOX_CLOSE, BOX_OPEN, texToUnicode } from '../lib/mathUnicode.js' import { highlightLine, isHighlightable } from '../lib/syntax.js' import type { Theme } from '../theme.js' +import { CopyBlox } from './copyblox.js' + // `\boxed{X}` regions in `texToUnicode` output are marked with the // non-printable U+0001 / U+0002 sentinels. Split on them and render the // boxed segment with `inverse + bold` so it reads as a highlighter-pen @@ -715,6 +719,18 @@ function MdImpl({ cols, compact, t, text }: MdProps) { } const lines = ensureEmojiPresentation(text).split('\n') + + // Compute raw source fence data BEFORE display normalisation changes text. + // parseCodeFences operates on `text` (raw) so rawContent is byte-accurate. + const rawFences = parseCodeFences(text) + + // Map display-line indices (open fence) to raw fence content. + const fenceContentMap = new Map() + + for (const rf of rawFences) { + fenceContentMap.set(rf.openLineIndex, rf) + } + const nodes: ReactNode[] = [] let prevKind: Kind = null @@ -778,6 +794,7 @@ function MdImpl({ cols, compact, t, text }: MdProps) { const fence = line.match(FENCE_RE) if (fence) { + const openLineIndex = i const char = fence[1]![0] as '`' | '~' const len = fence[1]!.length const lang = fence[2]!.trim().toLowerCase() @@ -793,10 +810,14 @@ function MdImpl({ cols, compact, t, text }: MdProps) { block.push(lines[i]!) } - if (i < lines.length) { + const closed = i < lines.length + + if (closed) { i++ } + // Preserve the established nested-Markdown rendering for Markdown + // fences. These are display-only; other fenced languages get CopyBlox. if (['md', 'markdown'].includes(lang)) { start('paragraph') nodes.push() @@ -804,48 +825,62 @@ function MdImpl({ cols, compact, t, text }: MdProps) { continue } - start('code') - + // Look up raw content from pre-computed raw source parse using the + // saved opener index. `block` contains display-normalized lines + // (ensureEmojiPresentation may have mutated emoji bytes), so we must + // never fall back to `block.join('\n')` for clipboard content. + const rawFence = fenceContentMap.get(openLineIndex) + const rawContent = rawFence ? rawFence.rawContent : block.join('\n') const isDiff = lang === 'diff' const highlighted = !isDiff && isHighlightable(lang) - nodes.push( - - {lang && !isDiff && {'─ ' + lang}} - - {block.map((l, j) => { - if (highlighted) { - return ( - - {highlightLine(l, lang, t).map(([color, text], kk) => - color ? ( - - {text} - - ) : ( - {text} - ) - )} - - ) - } - - const add = isDiff && l.startsWith('+') - const del = isDiff && l.startsWith('-') - const hunk = isDiff && l.startsWith('@@') + // Build display lines for syntax rendering. + const codeChildren: ReactNode[] = block.map((l, j) => { + if (highlighted) { + return ( + + {highlightLine(l, lang, t).map(([color, text], kk) => + color ? ( + + {text} + + ) : ( + {text} + ) + )} + + ) + } - return ( - - {l} - - ) - })} - + const add = isDiff && l.startsWith('+') + const del = isDiff && l.startsWith('-') + const hunk = isDiff && l.startsWith('@@') + + return ( + + {l} + + ) + }) + + nodes.push( + + {codeChildren} + ) continue diff --git a/ui-tui/src/components/streamingMarkdown.tsx b/ui-tui/src/components/streamingMarkdown.tsx index 992bf9a67b65..06e67837e157 100644 --- a/ui-tui/src/components/streamingMarkdown.tsx +++ b/ui-tui/src/components/streamingMarkdown.tsx @@ -150,7 +150,7 @@ export const StreamingMd = memo(function StreamingMd({ cols, compact, t, text }: return ( {state.blocks.map((block, i) => ( - + ))} {tail ? : null} diff --git a/ui-tui/src/domain/codeFence.ts b/ui-tui/src/domain/codeFence.ts new file mode 100644 index 000000000000..ae33e436cf48 --- /dev/null +++ b/ui-tui/src/domain/codeFence.ts @@ -0,0 +1,145 @@ +/** + * Pure code-fence parser for Markdown fenced code blocks. + * + * 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 β€” exactly what should be sent to the + * clipboard. + */ + +const FENCE_OPENER_RE = /^\s*(`{3,}|~{3,})(.*)$/ +const FENCE_CLOSER_RE = /^\s*(`{3,}|~{3,})\s*$/ + +export interface CopyBloxFence { + /** Whether a matching closer was found. */ + closed: boolean + /** Line index of the opening fence (0-based). */ + openLineIndex: number + /** Index of the closing fence line, or `-1` if unclosed. */ + endLineIndex: number + /** Opening fence character: `` ` `` or `~`. */ + fenceChar: '`' | '~' + /** Length of the opening fence string. */ + fenceLength: number + /** Raw info string from the opening fence line (after the fence ticks). */ + infoString: string + /** Normalised display language (first token, lowercased). */ + language: string + /** Exact text between the fences β€” what the clipboard receives. */ + rawContent: string +} + +/** + * Compute raw line boundaries in a source string. + * + * Returns an array of `[start, end)` offsets for each line (split on `\n`). + * `end` is the index of the `\n` character, or `source.length` for the last + * line if it doesn't end with a newline. + */ +function lineBoundaries(source: string): Array<[number, number]> { + const boundaries: Array<[number, number]> = [] + let start = 0 + + for (let ch = 0; ch < source.length; ch++) { + if (source[ch] === '\n') { + boundaries.push([start, ch]) + start = ch + 1 + } + } + + // Last line (no trailing newline) + if (start <= source.length) { + boundaries.push([start, source.length]) + } + + return boundaries +} + +/** + * Parse all fenced code blocks from raw source text. + * + * `source` is the unmodified text from the message (before display + * normalisation). The parser matches fences against `source` directly and + * returns `rawContent` that is byte-accurate for clipboard use. + */ +export function parseCodeFences(source: string): CopyBloxFence[] { + const boundaries = lineBoundaries(source) + const fences: CopyBloxFence[] = [] + let i = 0 + + while (i < boundaries.length) { + const [lineStart, lineEnd] = boundaries[i]! + const line = source.slice(lineStart, lineEnd) + + const match = line.match(FENCE_OPENER_RE) + + if (!match) { + i++ + + continue + } + + const fenceChar = match[1]![0] as '`' | '~' + const fenceLength = match[1]!.length + const infoString = match[2]!.trim() + const openLineIndex = i + i++ + + const contentParts: string[] = [] + let closerLine = -1 + + for (; i < boundaries.length; i++) { + const [cs, ce] = boundaries[i]! + const cline = source.slice(cs, ce) + const closeMatch = cline.match(FENCE_CLOSER_RE) + + if (closeMatch && closeMatch[1]![0] === fenceChar && closeMatch[1]!.length >= fenceLength) { + closerLine = i + + break + } + + contentParts.push(source.slice(cs, ce)) + } + + const closed = closerLine >= 0 + + if (closed) { + i++ // skip past the closer + } + + const rawContent = contentParts.join('\n') + const language = parseLanguage(infoString, fenceChar, rawContent) + + fences.push({ + closed, + openLineIndex, + endLineIndex: closerLine, + fenceChar, + fenceLength, + infoString, + language, + rawContent + }) + } + + return fences +} + +function parseLanguage(infoString: string, fenceChar: '`' | '~', rawContent: string): string { + if (infoString) { + const firstToken = infoString.split(/[\s]+/)[0]! + + // Strip leading language directives like `lang: ` or `language=` + const normalised = firstToken.toLowerCase().replace(/^language[:=]/, '') + + return normalised + } + + // Default: if content looks like a diff, advertise that. + if (rawContent.startsWith('--- ') || rawContent.startsWith('+++ ')) { + return 'diff' + } + + return 'text' +} diff --git a/ui-tui/src/lib/copyText.ts b/ui-tui/src/lib/copyText.ts new file mode 100644 index 000000000000..16266280247a --- /dev/null +++ b/ui-tui/src/lib/copyText.ts @@ -0,0 +1,33 @@ +import { setClipboard } from '@hermes/ink' + +export type CopyTextOutcome = + | { method: 'native-or-tmux'; success: true } + | { method: 'osc52'; success: true } + | { method: 'none'; success: false } + +/** + * Shared application-level clipboard wrapper. + * + * Calls `setClipboard`, emits the returned terminal sequence, and returns a + * typed outcome. Never transforms or logs `text`. + */ +export async function copyText(text: string): Promise { + try { + const result = await setClipboard(text) + + if (result.sequence.length > 0) { + process.stdout.write(result.sequence) + } + + if (result.success) { + // native path (pbcopy/wl-copy/etc.) succeeded, or tmux buffer loaded + const method = result.sequence.length > 0 ? 'osc52' : 'native-or-tmux' + + return { method, success: true } + } + + return { method: 'none', success: false } + } catch { + return { method: 'none', success: false } + } +} diff --git a/ui-tui/src/lib/virtualHeights.ts b/ui-tui/src/lib/virtualHeights.ts index bb470da89232..9a88f47e7134 100644 --- a/ui-tui/src/lib/virtualHeights.ts +++ b/ui-tui/src/lib/virtualHeights.ts @@ -41,6 +41,10 @@ export const messageHeightKey = (msg: Msg) => { // ceiling was 16 lines, then full text β€” this is the sane middle). const MAX_ESTIMATE_LINES = 800 +const FENCE_OPEN_RE = /^\s*(`{3,}|~{3,})(.*)$/ +const FENCE_CLOSE_RE = /^\s*(`{3,}|~{3,})\s*$/ +const MARKDOWN_FENCE_LANGS = new Set(['md', 'markdown']) + export const wrappedLines = (text: string, width: number, maxLines: number = MAX_ESTIMATE_LINES) => { const w = Math.max(1, width) // Worst case: every cell is its own row at width=1, plus a small @@ -66,6 +70,73 @@ export const wrappedLines = (text: string, width: number, maxLines: number = MAX return n } +export const fencedWrappedLines = ( + text: string, + width: number, + compact: boolean, + maxLines: number = MAX_ESTIMATE_LINES +) => { + const bodyWidth = Math.max(1, width - 2) + const frameRows = compact || width < 20 ? 1 : 2 + const lines = text.split('\n') + let rows = 0 + let fenceChar = '' + let fenceLength = 0 + let fenceLanguage = '' + let fenceBody: string[] = [] + + const addRows = (count: number) => { + rows = Math.min(maxLines, rows + count) + } + + const finishFence = () => { + if (MARKDOWN_FENCE_LANGS.has(fenceLanguage)) { + addRows(fenceBody.length ? wrappedLines(fenceBody.join('\n'), width, maxLines - rows) : 0) + } else { + addRows(fenceBody.length ? wrappedLines(fenceBody.join('\n'), bodyWidth, maxLines - rows) : 0) + addRows(frameRows) + } + + fenceChar = '' + fenceLength = 0 + fenceLanguage = '' + fenceBody = [] + } + + for (const line of lines) { + if (!fenceChar) { + const open = line.match(FENCE_OPEN_RE) + + if (open) { + fenceChar = open[1]![0] + fenceLength = open[1]!.length + fenceLanguage = open[2]!.trim().toLowerCase() + fenceBody = [] + } else { + addRows(wrappedLines(line, width, maxLines - rows)) + } + } else { + const close = line.match(FENCE_CLOSE_RE)?.[1] + + if (close && close[0] === fenceChar && close.length >= fenceLength) { + finishFence() + } else { + fenceBody.push(line) + } + } + + if (rows >= maxLines) { + return maxLines + } + } + + if (fenceChar) { + finishFence() + } + + return rows +} + export const estimatedMsgHeight = ( msg: Msg, cols: number, @@ -105,7 +176,7 @@ export const estimatedMsgHeight = ( const bodyWidth = transcriptBodyWidth(cols, msg.role, userPrompt, TERMUX_TUI_MODE) const text = msg.text - let h = wrappedLines(text || ' ', bodyWidth) + let h = fencedWrappedLines(text || ' ', bodyWidth, compact) if (!compact && msg.role === 'assistant') { // Paragraph gaps add up to 6 extra rows of breathing room. Slice