From ebd6f55a2e2a7e4477b9291301dbf17bf26e938d Mon Sep 17 00:00:00 2001 From: Guangya Liu Date: Sat, 18 Apr 2026 22:16:17 -0400 Subject: [PATCH] feat: support copy command --- cli.py | 150 ++++++++++++++++++- hermes_cli/commands.py | 2 +- hermes_cli/curses_ui.py | 130 +++++++++++++++++ tests/cli/test_cli_copy_command.py | 202 +++++++++++++++++++++++++- ui-tui/src/app/interfaces.ts | 11 ++ ui-tui/src/app/overlayStore.ts | 5 +- ui-tui/src/app/slash/commands/core.ts | 23 ++- ui-tui/src/app/useInputHandlers.ts | 25 ++++ ui-tui/src/components/appOverlays.tsx | 27 +++- ui-tui/src/lib/text.ts | 17 +++ 10 files changed, 577 insertions(+), 15 deletions(-) diff --git a/cli.py b/cli.py index 02c1a4f7ef62..9592a6a3b438 100644 --- a/cli.py +++ b/cli.py @@ -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")) + for m in _CODE_BLOCK_RE.finditer(text) + ] + + # ============================================================================= # Configuration Loading # ============================================================================= @@ -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) @@ -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 "" @@ -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 — attach a local image file for the next prompt.""" raw_args = (cmd_original.split(None, 1)[1].strip() if " " in cmd_original else "") diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 681e6f9b2659..31622b333f71 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -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), diff --git a/hermes_cli/curses_ui.py b/hermes_cli/curses_ui.py index b05295f1e61d..0d3123a68dbf 100644 --- a/hermes_cli/curses_ui.py +++ b/hermes_cli/curses_ui.py @@ -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], diff --git a/tests/cli/test_cli_copy_command.py b/tests/cli/test_cli_copy_command.py index 6cd010df37f0..cf7776da05cd 100644 --- a/tests/cli/test_cli_copy_command.py +++ b/tests/cli/test_cli_copy_command.py @@ -1,8 +1,9 @@ """Tests for CLI /copy command.""" -from unittest.mock import MagicMock, patch +import os +from unittest.mock import MagicMock, call, patch -from cli import HermesCLI +from cli import HermesCLI, _extract_code_blocks def _make_cli() -> HermesCLI: @@ -17,6 +18,10 @@ def _make_cli() -> HermesCLI: return cli_obj +# --------------------------------------------------------------------------- +# Existing tests (unchanged behavior) +# --------------------------------------------------------------------------- + def test_copy_copies_latest_assistant_message(): cli_obj = _make_cli() cli_obj.conversation_history = [ @@ -25,7 +30,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.object(cli_obj, "_copy_to_system_clipboard", return_value=True) as mock_copy: result = cli_obj.process_command("/copy") assert result is True @@ -39,7 +44,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.object(cli_obj, "_copy_to_system_clipboard", return_value=True) as mock_copy: cli_obj.process_command("/copy 1") mock_copy.assert_called_once_with("one") @@ -54,18 +59,203 @@ def test_copy_strips_reasoning_blocks_before_copy(): } ] - with patch.object(cli_obj, "_write_osc52_clipboard") as mock_copy: + with patch.object(cli_obj, "_copy_to_system_clipboard", 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_unavailable(): + """When _copy_to_system_clipboard returns False, OSC 52 is used.""" + cli_obj = _make_cli() + cli_obj.conversation_history = [{"role": "assistant", "content": "fallback"}] + + with ( + patch.object(cli_obj, "_copy_to_system_clipboard", return_value=False), + patch.object(cli_obj, "_write_osc52_clipboard") as mock_osc, + ): + cli_obj.process_command("/copy") + + mock_osc.assert_called_once_with("fallback") + + 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.object(cli_obj, "_copy_to_system_clipboard", return_value=True) as mock_copy, + patch("cli._cprint") as mock_print, + ): cli_obj.process_command("/copy 99") mock_copy.assert_not_called() assert any("Invalid response number" in str(call) for call in mock_print.call_args_list) + + +# --------------------------------------------------------------------------- +# _extract_code_blocks +# --------------------------------------------------------------------------- + +def test_extract_code_blocks_finds_fenced_blocks(): + text = "Here:\n```python\nprint('hi')\n```\nAnd:\n```js\nconsole.log(1)\n```" + blocks = _extract_code_blocks(text) + assert len(blocks) == 2 + assert blocks[0] == ("python", "print('hi')") + assert blocks[1] == ("js", "console.log(1)") + + +def test_extract_code_blocks_no_lang_defaults_to_text(): + blocks = _extract_code_blocks("```\nfoo\n```") + assert blocks == [("text", "foo")] + + +def test_extract_code_blocks_returns_empty_for_no_blocks(): + assert _extract_code_blocks("just plain text") == [] + + +# --------------------------------------------------------------------------- +# Interactive picker — code blocks trigger picker +# --------------------------------------------------------------------------- + +_RESPONSE_WITH_BLOCKS = ( + "Here is some code:\n" + "```python\nprint('hello')\n```\n" + "And more:\n" + "```bash\necho hi\n```" +) + + +def test_copy_opens_picker_when_code_blocks_present(): + """When the response contains code blocks, _copy_with_picker is called.""" + cli_obj = _make_cli() + cli_obj.conversation_history = [ + {"role": "assistant", "content": _RESPONSE_WITH_BLOCKS}, + ] + + with patch.object(cli_obj, "_copy_with_picker") as mock_picker: + cli_obj.process_command("/copy") + + mock_picker.assert_called_once() + args = mock_picker.call_args + assert args[0][0] == _RESPONSE_WITH_BLOCKS # full_text + assert len(args[0][1]) == 2 # two code blocks + + +def test_copy_no_picker_when_no_code_blocks(): + """Plain text responses skip the picker and copy directly.""" + cli_obj = _make_cli() + cli_obj.conversation_history = [ + {"role": "assistant", "content": "just text, no blocks"}, + ] + + with patch.object(cli_obj, "_copy_to_system_clipboard", return_value=True) as mock_copy: + cli_obj.process_command("/copy") + + mock_copy.assert_called_once_with("just text, no blocks") + + +def test_copy_with_picker_copies_full_response(): + """Picker selecting index 0 copies full response.""" + cli_obj = _make_cli() + blocks = [("python", "print('hello')"), ("bash", "echo hi")] + + with ( + patch("hermes_cli.curses_ui.curses_copy_picker", return_value=(0, False)), + patch.object(cli_obj, "_copy_to_system_clipboard", return_value=True) as mock_copy, + ): + cli_obj._copy_with_picker(_RESPONSE_WITH_BLOCKS, blocks, 0) + + mock_copy.assert_called_once_with(_RESPONSE_WITH_BLOCKS) + + +def test_copy_with_picker_copies_specific_block(): + """Picker selecting index 1 copies the first code block.""" + cli_obj = _make_cli() + blocks = [("python", "print('hello')"), ("bash", "echo hi")] + + with ( + patch("hermes_cli.curses_ui.curses_copy_picker", return_value=(1, False)), + patch.object(cli_obj, "_copy_to_system_clipboard", return_value=True) as mock_copy, + ): + cli_obj._copy_with_picker(_RESPONSE_WITH_BLOCKS, blocks, 0) + + mock_copy.assert_called_once_with("print('hello')") + + +def test_copy_with_picker_cancel(): + """Picker returning None does not copy anything.""" + cli_obj = _make_cli() + blocks = [("python", "print('hello')")] + + with ( + patch("hermes_cli.curses_ui.curses_copy_picker", return_value=(None, False)), + patch.object(cli_obj, "_copy_to_system_clipboard", return_value=True) as mock_copy, + patch("cli._cprint"), + ): + cli_obj._copy_with_picker("text", blocks, 0) + + mock_copy.assert_not_called() + + +def test_copy_with_picker_write_to_file(tmp_path): + """Picker with write=True writes to disk instead of clipboard.""" + cli_obj = _make_cli() + blocks = [("python", "print('hello')")] + dest = tmp_path / "out.py" + + with ( + patch("hermes_cli.curses_ui.curses_copy_picker", return_value=(1, True)), + patch("cli._cprint"), + patch("prompt_toolkit.prompt", return_value=str(dest)), + ): + cli_obj._copy_with_picker("full text", blocks, 0) + + assert dest.read_text().strip() == "print('hello')" + + +# --------------------------------------------------------------------------- +# curses_copy_picker — fallback path +# --------------------------------------------------------------------------- + +def test_curses_copy_picker_fallback_non_tty(): + """Non-TTY stdin returns (None, False) immediately.""" + from hermes_cli.curses_ui import curses_copy_picker + + with patch("sys.stdin") as mock_stdin: + mock_stdin.isatty.return_value = False + result = curses_copy_picker(["Full response", "Block 1"]) + + assert result == (None, False) + + +# --------------------------------------------------------------------------- +# _copy_to_system_clipboard +# --------------------------------------------------------------------------- + +def test_system_clipboard_uses_pbcopy_on_macos(): + """On macOS without SSH, pbcopy is used.""" + cli_obj = _make_cli() + + with ( + patch("sys.platform", "darwin"), + patch.dict(os.environ, {}, clear=False), + patch("shutil.which", return_value="/usr/bin/pbcopy"), + patch("subprocess.run") as mock_run, + ): + os.environ.pop("SSH_CONNECTION", None) + result = cli_obj._copy_to_system_clipboard("hello") + + assert result is True + mock_run.assert_called_once() + assert mock_run.call_args[0][0] == ["pbcopy"] + + +def test_system_clipboard_skipped_over_ssh(): + """Over SSH, native clipboard is skipped (returns False).""" + cli_obj = _make_cli() + + with patch.dict(os.environ, {"SSH_CONNECTION": "1.2.3.4 5678 5.6.7.8 22"}): + result = cli_obj._copy_to_system_clipboard("hello") + + assert result is False diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 353c56535be1..3f7baba7e68d 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -51,10 +51,21 @@ export interface GatewayProviderProps { value: GatewayServices } +export interface CopyPickerState { + cursor: number + items: CopyPickerItem[] +} + +export interface CopyPickerItem { + label: string + text: string +} + export interface OverlayState { approval: ApprovalReq | null clarify: ClarifyReq | null confirm: ConfirmReq | null + copyPicker: CopyPickerState | null modelPicker: boolean pager: null | PagerState picker: boolean diff --git a/ui-tui/src/app/overlayStore.ts b/ui-tui/src/app/overlayStore.ts index 06dbd27a7897..a1564023b9df 100644 --- a/ui-tui/src/app/overlayStore.ts +++ b/ui-tui/src/app/overlayStore.ts @@ -6,6 +6,7 @@ const buildOverlayState = (): OverlayState => ({ approval: null, clarify: null, confirm: null, + copyPicker: null, modelPicker: false, pager: null, picker: false, @@ -18,8 +19,8 @@ export const $overlayState = atom(buildOverlayState()) export const $isBlocked = computed( $overlayState, - ({ approval, clarify, confirm, modelPicker, pager, picker, secret, skillsHub, sudo }) => - Boolean(approval || clarify || confirm || modelPicker || pager || picker || secret || skillsHub || sudo) + ({ approval, clarify, confirm, copyPicker, modelPicker, pager, picker, secret, skillsHub, sudo }) => + Boolean(approval || clarify || confirm || copyPicker || modelPicker || pager || picker || secret || skillsHub || sudo) ) export const getOverlayState = () => $overlayState.get() diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 0f8916c5cb6d..c94cd03cffd7 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -8,6 +8,7 @@ import type { SessionSteerResponse, SessionUndoResponse } from '../../../gatewayTypes.js' +import { extractCodeBlocks } from '../../../lib/text.js' import { writeOsc52Clipboard } from '../../../lib/osc52.js' import type { DetailsMode, Msg, PanelSection } from '../../../types.js' import { patchOverlayState } from '../../overlayStore.js' @@ -198,7 +199,7 @@ export const coreCommands: SlashCommand[] = [ }, { - help: 'copy selection or assistant message', + help: 'copy selection or assistant message (picker for code blocks)', name: 'copy', run: (arg, ctx) => { const { sys } = ctx.transcript @@ -218,6 +219,26 @@ export const coreCommands: SlashCommand[] = [ return sys('nothing to copy') } + const blocks = extractCodeBlocks(target.text) + + if (blocks.length > 0) { + const items = [ + { label: 'Full response', text: target.text }, + ...blocks.map((b, i) => { + const preview = b.code.split('\n', 1)[0]?.slice(0, 60) ?? '' + + return { + label: `Block ${i + 1} (${b.lang}): ${preview}${preview.length < (b.code.split('\n', 1)[0]?.length ?? 0) ? '…' : ''}`, + text: b.code + } + }) + ] + + patchOverlayState({ copyPicker: { cursor: 0, items } }) + + return + } + writeOsc52Clipboard(target.text) sys('sent OSC52 copy sequence (terminal support required)') } diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index b71a1dc39241..03de14e349c6 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -171,6 +171,31 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { const live = getUiState() if (isBlocked) { + if (overlay.copyPicker) { + const cp = overlay.copyPicker + const len = cp.items.length + + if (key.upArrow) { + patchOverlayState({ copyPicker: { ...cp, cursor: (cp.cursor - 1 + len) % len } }) + } else if (key.downArrow) { + patchOverlayState({ copyPicker: { ...cp, cursor: (cp.cursor + 1) % len } }) + } else if (key.return) { + writeOsc52Clipboard(cp.items[cp.cursor]!.text) + patchOverlayState({ copyPicker: null }) + actions.sys(`copied "${cp.items[cp.cursor]!.label}" to clipboard (OSC52)`) + } else if (ch === 'w') { + const item = cp.items[cp.cursor]! + + patchOverlayState({ copyPicker: null }) + actions.sys(`write-to-file is available in the classic CLI (/copy). Copied "${item.label}" via OSC52 instead.`) + writeOsc52Clipboard(item.text) + } else if (key.escape || isCtrl(key, ch, 'c') || ch === 'q') { + patchOverlayState({ copyPicker: null }) + } + + return + } + if (overlay.pager) { if (key.return || ch === ' ') { const nextOffset = overlay.pager.offset + pagerPageSize diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index 844996af3f9a..829c01dbcdbc 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -100,7 +100,7 @@ export function FloatingOverlays({ const overlay = useStore($overlayState) const ui = useStore($uiState) - const hasAny = overlay.modelPicker || overlay.pager || overlay.picker || overlay.skillsHub || completions.length + const hasAny = overlay.copyPicker || overlay.modelPicker || overlay.pager || overlay.picker || overlay.skillsHub || completions.length if (!hasAny) { return null @@ -139,6 +139,31 @@ export function FloatingOverlays({ )} + {overlay.copyPicker && ( + + + + Select content to copy + + + {overlay.copyPicker.items.map((item, i) => ( + + {i === overlay.copyPicker!.cursor ? ' → ' : ' '} + {item.label} + + ))} + + + ↑↓ navigate · Enter copy · Esc cancel + + + + )} + {overlay.pager && ( diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index fb10d7d2d439..050a4127b676 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -195,3 +195,20 @@ export const pick = (a: T[]) => a[Math.floor(Math.random() * a.length)]! export const isPasteBackedText = (text: string) => /\[\[paste:\d+(?:[^\n]*?)\]\]|\[paste #\d+ (?:attached|excerpt)(?:[^\n]*?)\]/.test(text) + +const CODE_BLOCK_RE = /```(\w*)\n([\s\S]*?)```/g + +export interface CodeBlock { + code: string + lang: string +} + +export const extractCodeBlocks = (text: string): CodeBlock[] => { + const blocks: CodeBlock[] = [] + + for (const m of text.matchAll(CODE_BLOCK_RE)) { + blocks.push({ code: m[2]!.replace(/\n$/, ''), lang: m[1] || 'text' }) + } + + return blocks +}