diff --git a/clai/README.md b/clai/README.md index 635e381fc..01a7e2b8d 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 9f5b94d96..5091f8d53 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 ae4f6ff6f..71db1dde7 100644 --- a/pydantic_ai_slim/pydantic_ai/_cli.py +++ b/pydantic_ai_slim/pydantic_ai/_cli.py @@ -18,12 +18,13 @@ from ._run_context import AgentDepsT from .agent import Agent from .exceptions import UserError -from .messages import ModelMessage +from .messages import ModelMessage, TextPart from .models import KnownModelName, infer_model from .output import OutputDataT try: import argcomplete + import pyperclip from prompt_toolkit import PromptSession from prompt_toolkit.auto_suggest import AutoSuggestFromHistory, Suggestion from prompt_toolkit.buffer import Buffer @@ -38,7 +39,7 @@ from rich.text import Text except ImportError as _import_error: raise ImportError( - 'Please install `rich`, `prompt-toolkit` and `argcomplete` to use the Pydantic AI CLI, ' + 'Please install `rich`, `prompt-toolkit`, `pyperclip` and `argcomplete` to use the Pydantic AI CLI, ' 'you can use the `cli` optional group — `pip install "pydantic-ai-slim[cli]"`' ) from _import_error @@ -114,6 +115,7 @@ def cli( # noqa: C901 * `/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, ) @@ -237,7 +239,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 @@ -347,6 +349,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 = '\n\n'.join(part.content for part in parts if isinstance(part, TextPart)) + 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 8933e06e1..5fcac8763 100644 --- a/pydantic_ai_slim/pyproject.toml +++ b/pydantic_ai_slim/pyproject.toml @@ -75,7 +75,7 @@ huggingface = ["huggingface-hub[inference]>=0.33.5"] duckduckgo = ["ddgs>=9.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.10.0; python_version >= '3.10'"] # Evals diff --git a/tests/test_cli.py b/tests/test_cli.py index ca85564d2..7c0a10fa7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -165,10 +165,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) @@ -182,6 +189,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…', ] ) @@ -212,6 +220,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 58007b31f..305644de8 100644 --- a/uv.lock +++ b/uv.lock @@ -3427,6 +3427,7 @@ bedrock = [ cli = [ { name = "argcomplete" }, { name = "prompt-toolkit" }, + { name = "pyperclip" }, { name = "rich" }, ] cohere = [ @@ -3518,6 +3519,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 = "starlette", marker = "extra == 'ag-ui'", specifier = ">=0.45.3" }, @@ -3750,6 +3752,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"