Skip to content

feat: add '/cp' command to copy last response to clipboard #2049

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions clai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 17 additions & 2 deletions pydantic_ai_slim/pydantic_ai/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we'll want to put 1 or 2 newlines in between text from different parts, as they may not join cleanly.

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
2 changes: 1 addition & 1 deletion pydantic_ai_slim/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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…',
]
)
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.