From ccc94227ea9e919353e5fef590857d3a6f9eed22 Mon Sep 17 00:00:00 2001 From: 07pepa <9963200+07pepa@users.noreply.github.com> Date: Sat, 21 Jun 2025 17:34:10 +0200 Subject: [PATCH] feat: add command to copy last response to clipboard --- clai/README.md | 2 ++ docs/cli.md | 1 + pydantic_ai_slim/pydantic_ai/_cli.py | 19 +++++++++++++-- pydantic_ai_slim/pyproject.toml | 2 +- tests/test_cli.py | 35 ++++++++++++++++++++++++++++ uv.lock | 8 +++++++ 6 files changed, 64 insertions(+), 3 deletions(-) diff --git a/clai/README.md b/clai/README.md index 8899a82ff..1f4affb1e 100644 --- a/clai/README.md +++ b/clai/README.md @@ -49,6 +49,7 @@ Either way, running `clai` will start an interactive session where you can chat - `/exit`: Exit the session - `/markdown`: Show the last response in markdown format - `/multiline`: Toggle multiline input mode (use Ctrl+D to submit) +- `/cp`: Copy the last response to clipboard ## Help @@ -61,6 +62,7 @@ Special prompts: * `/exit` - exit the interactive mode (ctrl-c and ctrl-d also work) * `/markdown` - show the last markdown output of the last question * `/multiline` - toggle multiline mode +* `/cp` - copy the last response to clipboard positional arguments: prompt AI Prompt, if omitted fall into interactive mode diff --git a/docs/cli.md b/docs/cli.md index 2cd373c00..83bfae8d4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -46,6 +46,7 @@ Either way, running `clai` will start an interactive session where you can chat - `/exit`: Exit the session - `/markdown`: Show the last response in markdown format - `/multiline`: Toggle multiline input mode (use Ctrl+D to submit) +- `/cp`: Copy the last response to clipboard ### Help diff --git a/pydantic_ai_slim/pydantic_ai/_cli.py b/pydantic_ai_slim/pydantic_ai/_cli.py index 7e041696b..d6c0b9653 100644 --- a/pydantic_ai_slim/pydantic_ai/_cli.py +++ b/pydantic_ai_slim/pydantic_ai/_cli.py @@ -25,6 +25,7 @@ try: import argcomplete + import pyperclip from prompt_toolkit import PromptSession from prompt_toolkit.auto_suggest import AutoSuggestFromHistory, Suggestion from prompt_toolkit.buffer import Buffer @@ -39,7 +40,7 @@ from rich.text import Text except ImportError as _import_error: raise ImportError( - 'Please install `rich`, `prompt-toolkit` and `argcomplete` to use the PydanticAI CLI, ' + 'Please install `rich`, `prompt-toolkit`, `pyperclip` and `argcomplete` to use the PydanticAI CLI, ' 'you can use the `cli` optional group — `pip install "pydantic-ai-slim[cli]"`' ) from _import_error @@ -113,6 +114,7 @@ def cli(args_list: Sequence[str] | None = None, *, prog_name: str = 'pai') -> in * `/exit` - exit the interactive mode (ctrl-c and ctrl-d also work) * `/markdown` - show the last markdown output of the last question * `/multiline` - toggle multiline mode +* `/cp` - copy the last response to clipboard """, formatter_class=argparse.RawTextHelpFormatter, ) @@ -236,7 +238,7 @@ async def run_chat( while True: try: - auto_suggest = CustomAutoSuggest(['/markdown', '/multiline', '/exit']) + auto_suggest = CustomAutoSuggest(['/markdown', '/multiline', '/exit', '/cp']) text = await session.prompt_async(f'{prog_name} ➤ ', auto_suggest=auto_suggest, multiline=multiline) except (KeyboardInterrupt, EOFError): # pragma: no cover return 0 @@ -341,6 +343,19 @@ def handle_slash_command( elif ident_prompt == '/exit': console.print('[dim]Exiting…[/dim]') return 0, multiline + elif ident_prompt == '/cp': + try: + parts = messages[-1].parts + except IndexError: + console.print('[dim]No output available to copy.[/dim]') + else: + text_to_copy = ''.join(part.content for part in parts if part.part_kind == 'text') + text_to_copy = text_to_copy.strip() + if text_to_copy: + pyperclip.copy(text_to_copy) + console.print('[dim]Copied last output to clipboard.[/dim]') + else: + console.print('[dim]No text content to copy.[/dim]') else: console.print(f'[red]Unknown command[/red] [magenta]`{ident_prompt}`[/magenta]') return None, multiline diff --git a/pydantic_ai_slim/pyproject.toml b/pydantic_ai_slim/pyproject.toml index a04bd07c5..9f403d326 100644 --- a/pydantic_ai_slim/pyproject.toml +++ b/pydantic_ai_slim/pyproject.toml @@ -73,7 +73,7 @@ bedrock = ["boto3>=1.37.24"] duckduckgo = ["duckduckgo-search>=7.0.0"] tavily = ["tavily-python>=0.5.0"] # CLI -cli = ["rich>=13", "prompt-toolkit>=3", "argcomplete>=3.5.0"] +cli = ["rich>=13", "prompt-toolkit>=3", "argcomplete>=3.5.0", "pyperclip>=1.9.0"] # MCP mcp = ["mcp>=1.9.4; python_version >= '3.10'"] # Evals diff --git a/tests/test_cli.py b/tests/test_cli.py index 024116249..1bb075703 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -162,10 +162,17 @@ def test_cli_prompt(capfd: CaptureFixture[str], env: TestEnv): def test_chat(capfd: CaptureFixture[str], mocker: MockerFixture, env: TestEnv): env.set('OPENAI_API_KEY', 'test') + + # mocking is needed because of ci does not have xclip or xselect installed + def mock_copy(text: str) -> None: + pass + + mocker.patch('pyperclip.copy', mock_copy) with create_pipe_input() as inp: inp.send_text('\n') inp.send_text('hello\n') inp.send_text('/markdown\n') + inp.send_text('/cp\n') inp.send_text('/exit\n') session = PromptSession[Any](input=inp, output=DummyOutput()) m = mocker.patch('pydantic_ai._cli.PromptSession', return_value=session) @@ -179,6 +186,7 @@ def test_chat(capfd: CaptureFixture[str], mocker: MockerFixture, env: TestEnv): IsStr(regex='goodbye *Markdown output of last question:'), '', 'goodbye', + 'Copied last output to clipboard.', 'Exiting…', ] ) @@ -209,6 +217,33 @@ def test_handle_slash_command_multiline(): assert io.getvalue() == snapshot('Disabling multiline mode.\n') +def test_handle_slash_command_copy(mocker: MockerFixture): + io = StringIO() + # mocking is needed because of ci does not have xclip or xselect installed + mock_clipboard: list[str] = [] + + def append_to_clipboard(text: str) -> None: + mock_clipboard.append(text) + + mocker.patch('pyperclip.copy', append_to_clipboard) + assert handle_slash_command('/cp', [], False, Console(file=io), 'default') == (None, False) + assert io.getvalue() == snapshot('No output available to copy.\n') + assert len(mock_clipboard) == 0 + + messages: list[ModelMessage] = [ModelResponse(parts=[TextPart(''), ToolCallPart('foo', '{}')])] + io = StringIO() + assert handle_slash_command('/cp', messages, True, Console(file=io), 'default') == (None, True) + assert io.getvalue() == snapshot('No text content to copy.\n') + assert len(mock_clipboard) == 0 + + messages: list[ModelMessage] = [ModelResponse(parts=[TextPart('hello'), ToolCallPart('foo', '{}')])] + io = StringIO() + assert handle_slash_command('/cp', messages, True, Console(file=io), 'default') == (None, True) + assert io.getvalue() == snapshot('Copied last output to clipboard.\n') + assert len(mock_clipboard) == 1 + assert mock_clipboard[0] == snapshot('hello') + + def test_handle_slash_command_exit(): io = StringIO() assert handle_slash_command('/exit', [], False, Console(file=io), 'default') == (0, False) diff --git a/uv.lock b/uv.lock index 24f7bb52c..aeb6bef4d 100644 --- a/uv.lock +++ b/uv.lock @@ -3012,6 +3012,7 @@ bedrock = [ cli = [ { name = "argcomplete" }, { name = "prompt-toolkit" }, + { name = "pyperclip" }, { name = "rich" }, ] cohere = [ @@ -3093,6 +3094,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.10" }, { name = "pydantic-evals", marker = "extra == 'evals'", editable = "pydantic_evals" }, { name = "pydantic-graph", editable = "pydantic_graph" }, + { name = "pyperclip", marker = "extra == 'cli'", specifier = ">=1.9.0" }, { name = "requests", marker = "extra == 'vertexai'", specifier = ">=2.32.2" }, { name = "rich", marker = "extra == 'cli'", specifier = ">=13" }, { name = "tavily-python", marker = "extra == 'tavily'", specifier = ">=0.5.0" }, @@ -3309,6 +3311,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/f5/b9e2a42aa8f9e34d52d66de87941ecd236570c7ed2e87775ed23bbe4e224/pymdown_extensions-10.14.3-py3-none-any.whl", hash = "sha256:05e0bee73d64b9c71a4ae17c72abc2f700e8bc8403755a00580b49a4e9f189e9", size = 264467, upload-time = "2025-02-01T15:43:13.995Z" }, ] +[[package]] +name = "pyperclip" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/23/2f0a3efc4d6a32f3b63cdff36cd398d9701d26cda58e3ab97ac79fb5e60d/pyperclip-1.9.0.tar.gz", hash = "sha256:b7de0142ddc81bfc5c7507eea19da920b92252b548b96186caf94a5e2527d310", size = 20961, upload-time = "2024-06-18T20:38:48.401Z" } + [[package]] name = "pyright" version = "1.1.398"