Skip to content

Commit 39357d2

Browse files
committed
feat: add command to copy last response to clipboard
1 parent 80a7284 commit 39357d2

File tree

6 files changed

+64
-3
lines changed

6 files changed

+64
-3
lines changed

clai/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ Either way, running `clai` will start an interactive session where you can chat
4949
- `/exit`: Exit the session
5050
- `/markdown`: Show the last response in markdown format
5151
- `/multiline`: Toggle multiline input mode (use Ctrl+D to submit)
52+
- `/cp`: Copy the last response to clipboard
5253

5354
## Help
5455

@@ -61,6 +62,7 @@ Special prompts:
6162
* `/exit` - exit the interactive mode (ctrl-c and ctrl-d also work)
6263
* `/markdown` - show the last markdown output of the last question
6364
* `/multiline` - toggle multiline mode
65+
* `/cp` - copy the last response to clipboard
6466
6567
positional arguments:
6668
prompt AI Prompt, if omitted fall into interactive mode

docs/cli.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ Either way, running `clai` will start an interactive session where you can chat
4646
- `/exit`: Exit the session
4747
- `/markdown`: Show the last response in markdown format
4848
- `/multiline`: Toggle multiline input mode (use Ctrl+D to submit)
49+
- `/cp`: Copy the last response to clipboard
4950

5051
### Help
5152

pydantic_ai_slim/pydantic_ai/_cli.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
try:
2626
import argcomplete
27+
import pyperclip
2728
from prompt_toolkit import PromptSession
2829
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory, Suggestion
2930
from prompt_toolkit.buffer import Buffer
@@ -38,7 +39,7 @@
3839
from rich.text import Text
3940
except ImportError as _import_error:
4041
raise ImportError(
41-
'Please install `rich`, `prompt-toolkit` and `argcomplete` to use the Pydantic AI CLI, '
42+
'Please install `rich`, `prompt-toolkit`, `pyperclip` and `argcomplete` to use the Pydantic AI CLI, '
4243
'you can use the `cli` optional group — `pip install "pydantic-ai-slim[cli]"`'
4344
) from _import_error
4445

@@ -114,6 +115,7 @@ def cli( # noqa: C901
114115
* `/exit` - exit the interactive mode (ctrl-c and ctrl-d also work)
115116
* `/markdown` - show the last markdown output of the last question
116117
* `/multiline` - toggle multiline mode
118+
* `/cp` - copy the last response to clipboard
117119
""",
118120
formatter_class=argparse.RawTextHelpFormatter,
119121
)
@@ -237,7 +239,7 @@ async def run_chat(
237239

238240
while True:
239241
try:
240-
auto_suggest = CustomAutoSuggest(['/markdown', '/multiline', '/exit'])
242+
auto_suggest = CustomAutoSuggest(['/markdown', '/multiline', '/exit', '/cp'])
241243
text = await session.prompt_async(f'{prog_name} ➤ ', auto_suggest=auto_suggest, multiline=multiline)
242244
except (KeyboardInterrupt, EOFError): # pragma: no cover
243245
return 0
@@ -347,6 +349,19 @@ def handle_slash_command(
347349
elif ident_prompt == '/exit':
348350
console.print('[dim]Exiting…[/dim]')
349351
return 0, multiline
352+
elif ident_prompt == '/cp':
353+
try:
354+
parts = messages[-1].parts
355+
except IndexError:
356+
console.print('[dim]No output available to copy.[/dim]')
357+
else:
358+
text_to_copy = ''.join(part.content for part in parts if part.part_kind == 'text')
359+
text_to_copy = text_to_copy.strip()
360+
if text_to_copy:
361+
pyperclip.copy(text_to_copy)
362+
console.print('[dim]Copied last output to clipboard.[/dim]')
363+
else:
364+
console.print('[dim]No text content to copy.[/dim]')
350365
else:
351366
console.print(f'[red]Unknown command[/red] [magenta]`{ident_prompt}`[/magenta]')
352367
return None, multiline

pydantic_ai_slim/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ huggingface = ["huggingface-hub[inference]>=0.33.5"]
7575
duckduckgo = ["ddgs>=9.0.0"]
7676
tavily = ["tavily-python>=0.5.0"]
7777
# CLI
78-
cli = ["rich>=13", "prompt-toolkit>=3", "argcomplete>=3.5.0"]
78+
cli = ["rich>=13", "prompt-toolkit>=3", "argcomplete>=3.5.0", "pyperclip>=1.9.0"]
7979
# MCP
8080
mcp = ["mcp>=1.10.0; python_version >= '3.10'"]
8181
# Evals

tests/test_cli.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,10 +165,17 @@ def test_cli_prompt(capfd: CaptureFixture[str], env: TestEnv):
165165

166166
def test_chat(capfd: CaptureFixture[str], mocker: MockerFixture, env: TestEnv):
167167
env.set('OPENAI_API_KEY', 'test')
168+
169+
# mocking is needed because of ci does not have xclip or xselect installed
170+
def mock_copy(text: str) -> None:
171+
pass
172+
173+
mocker.patch('pyperclip.copy', mock_copy)
168174
with create_pipe_input() as inp:
169175
inp.send_text('\n')
170176
inp.send_text('hello\n')
171177
inp.send_text('/markdown\n')
178+
inp.send_text('/cp\n')
172179
inp.send_text('/exit\n')
173180
session = PromptSession[Any](input=inp, output=DummyOutput())
174181
m = mocker.patch('pydantic_ai._cli.PromptSession', return_value=session)
@@ -182,6 +189,7 @@ def test_chat(capfd: CaptureFixture[str], mocker: MockerFixture, env: TestEnv):
182189
IsStr(regex='goodbye *Markdown output of last question:'),
183190
'',
184191
'goodbye',
192+
'Copied last output to clipboard.',
185193
'Exiting…',
186194
]
187195
)
@@ -212,6 +220,33 @@ def test_handle_slash_command_multiline():
212220
assert io.getvalue() == snapshot('Disabling multiline mode.\n')
213221

214222

223+
def test_handle_slash_command_copy(mocker: MockerFixture):
224+
io = StringIO()
225+
# mocking is needed because of ci does not have xclip or xselect installed
226+
mock_clipboard: list[str] = []
227+
228+
def append_to_clipboard(text: str) -> None:
229+
mock_clipboard.append(text)
230+
231+
mocker.patch('pyperclip.copy', append_to_clipboard)
232+
assert handle_slash_command('/cp', [], False, Console(file=io), 'default') == (None, False)
233+
assert io.getvalue() == snapshot('No output available to copy.\n')
234+
assert len(mock_clipboard) == 0
235+
236+
messages: list[ModelMessage] = [ModelResponse(parts=[TextPart(''), ToolCallPart('foo', '{}')])]
237+
io = StringIO()
238+
assert handle_slash_command('/cp', messages, True, Console(file=io), 'default') == (None, True)
239+
assert io.getvalue() == snapshot('No text content to copy.\n')
240+
assert len(mock_clipboard) == 0
241+
242+
messages: list[ModelMessage] = [ModelResponse(parts=[TextPart('hello'), ToolCallPart('foo', '{}')])]
243+
io = StringIO()
244+
assert handle_slash_command('/cp', messages, True, Console(file=io), 'default') == (None, True)
245+
assert io.getvalue() == snapshot('Copied last output to clipboard.\n')
246+
assert len(mock_clipboard) == 1
247+
assert mock_clipboard[0] == snapshot('hello')
248+
249+
215250
def test_handle_slash_command_exit():
216251
io = StringIO()
217252
assert handle_slash_command('/exit', [], False, Console(file=io), 'default') == (0, False)

uv.lock

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)