diff --git a/pyproject.toml b/pyproject.toml index 4d8205e36..33f1b19b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "loguru>=0.6.0,<0.8", "prompt-toolkit==3.0.52", "pillow==12.1.0", + "pyperclip>=1.8.0", "pyyaml==6.0.3", "rich==14.2.0", "ripgrepy==2.2.0", diff --git a/src/kimi_cli/soul/slash.py b/src/kimi_cli/soul/slash.py index fbe5a4541..0aced19f9 100644 --- a/src/kimi_cli/soul/slash.py +++ b/src/kimi_cli/soul/slash.py @@ -89,6 +89,33 @@ async def clear(soul: KimiSoul, args: str): ) +@registry.command +async def copy(soul: KimiSoul, args: str): + """Copy the latest assistant response to the clipboard""" + from kimi_cli.utils.clipboard import copy_text_to_clipboard, is_clipboard_available + from kimi_cli.utils.export import format_assistant_message_md + + if not is_clipboard_available(): + wire_send(TextPart(text="Clipboard is not available on this system.")) + return + + last_assistant = next( + (m for m in reversed(soul.context.history) if m.role == "assistant"), + None, + ) + if last_assistant is None: + wire_send(TextPart(text="No assistant response to copy.")) + return + + markdown = format_assistant_message_md(last_assistant) + if not markdown: + wire_send(TextPart(text="The latest response is empty.")) + return + + copy_text_to_clipboard(markdown) + wire_send(TextPart(text="Copied the latest assistant response to clipboard.")) + + @registry.command async def yolo(soul: KimiSoul, args: str): """Toggle YOLO mode (auto-approve all actions)""" diff --git a/src/kimi_cli/utils/clipboard.py b/src/kimi_cli/utils/clipboard.py index ac76363d4..7306b1523 100644 --- a/src/kimi_cli/utils/clipboard.py +++ b/src/kimi_cli/utils/clipboard.py @@ -38,6 +38,11 @@ def is_clipboard_available() -> bool: return False +def copy_text_to_clipboard(text: str) -> None: + """Copy plain text to the system clipboard.""" + pyperclip.copy(text) + + def grab_media_from_clipboard() -> ClipboardResult | None: """Read media from the clipboard. diff --git a/src/kimi_cli/utils/export.py b/src/kimi_cli/utils/export.py index 2db00ebbe..5023e4512 100644 --- a/src/kimi_cli/utils/export.py +++ b/src/kimi_cli/utils/export.py @@ -100,6 +100,30 @@ def _format_content_part_md(part: ContentPart) -> str: return f"[{part.type}]" +def format_assistant_message_md(msg: Message) -> str: + """Format a single assistant message as markdown. + + Includes text, thinking, media placeholders, and tool calls. + Returns an empty string for non-assistant messages. + """ + if msg.role != "assistant": + return "" + + lines: list[str] = [] + for part in msg.content: + text = _format_content_part_md(part) + if text.strip(): + lines.append(text) + lines.append("") + + if msg.tool_calls: + for tc in msg.tool_calls: + lines.append(_format_tool_call_md(tc)) + lines.append("") + + return "\n".join(lines).strip() + + def _format_tool_call_md(tool_call: ToolCall) -> str: """Convert a ToolCall to a markdown sub-section with a readable title.""" args_raw = tool_call.function.arguments or "{}" diff --git a/tests/core/test_copy_slash.py b/tests/core/test_copy_slash.py new file mode 100644 index 000000000..2b2644c1a --- /dev/null +++ b/tests/core/test_copy_slash.py @@ -0,0 +1,128 @@ +"""Tests for /copy slash command.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest +from kosong.message import Message, TextPart, ThinkPart +from kosong.tooling.empty import EmptyToolset + +from kimi_cli.soul.agent import Agent, Runtime +from kimi_cli.soul.context import Context +from kimi_cli.soul.kimisoul import KimiSoul +from kimi_cli.soul.slash import copy +from kimi_cli.wire.types import TextPart as WireTextPart + + +def _make_soul(runtime: Runtime, tmp_path: Path) -> KimiSoul: + agent = Agent( + name="Test Agent", + system_prompt="Test system prompt.", + toolset=EmptyToolset(), + runtime=runtime, + ) + return KimiSoul(agent, context=Context(file_backend=tmp_path / "history.jsonl")) + + +async def _run_copy(soul: KimiSoul) -> None: + result = copy(soul, "") + if result is not None: + await result + + +class TestCopySlashCommand: + async def test_copy_no_assistant_message( + self, runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + soul = _make_soul(runtime, tmp_path) + sent: list[WireTextPart] = [] + monkeypatch.setattr("kimi_cli.soul.slash.wire_send", lambda msg: sent.append(msg)) + + await _run_copy(soul) + + assert any("No assistant response to copy" in s.text for s in sent) + + async def test_copy_text_only( + self, runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + soul = _make_soul(runtime, tmp_path) + await soul.context.append_message( + Message(role="assistant", content=[TextPart(text="Hello, world!")]) + ) + + sent: list[WireTextPart] = [] + monkeypatch.setattr("kimi_cli.soul.slash.wire_send", lambda msg: sent.append(msg)) + + with patch("kimi_cli.utils.clipboard.copy_text_to_clipboard") as mock_copy: + await _run_copy(soul) + + mock_copy.assert_called_once_with("Hello, world!") + assert any("Copied the latest assistant response" in s.text for s in sent) + + async def test_copy_with_thinking( + self, runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + soul = _make_soul(runtime, tmp_path) + await soul.context.append_message( + Message( + role="assistant", + content=[ + ThinkPart(think="I should say hello."), + TextPart(text="Hello, world!"), + ], + ) + ) + + sent: list[WireTextPart] = [] + monkeypatch.setattr("kimi_cli.soul.slash.wire_send", lambda msg: sent.append(msg)) + + with patch("kimi_cli.utils.clipboard.copy_text_to_clipboard") as mock_copy: + await _run_copy(soul) + + copied = mock_copy.call_args[0][0] + assert "I should say hello." in copied + assert "Hello, world!" in copied + assert any("Copied the latest assistant response" in s.text for s in sent) + + async def test_copy_latest_assistant_only( + self, runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + soul = _make_soul(runtime, tmp_path) + await soul.context.append_message( + Message(role="assistant", content=[TextPart(text="First response")]) + ) + await soul.context.append_message( + Message(role="user", content=[TextPart(text="Follow up")]) + ) + await soul.context.append_message( + Message(role="assistant", content=[TextPart(text="Second response")]) + ) + + sent: list[WireTextPart] = [] + monkeypatch.setattr("kimi_cli.soul.slash.wire_send", lambda msg: sent.append(msg)) + + with patch("kimi_cli.utils.clipboard.copy_text_to_clipboard") as mock_copy: + await _run_copy(soul) + + mock_copy.assert_called_once_with("Second response") + assert any("Copied the latest assistant response" in s.text for s in sent) + + async def test_copy_clipboard_unavailable( + self, runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + soul = _make_soul(runtime, tmp_path) + await soul.context.append_message( + Message(role="assistant", content=[TextPart(text="Hello")]) + ) + + sent: list[WireTextPart] = [] + monkeypatch.setattr("kimi_cli.soul.slash.wire_send", lambda msg: sent.append(msg)) + monkeypatch.setattr("kimi_cli.utils.clipboard.is_clipboard_available", lambda: False) + + with patch("kimi_cli.utils.clipboard.copy_text_to_clipboard") as mock_copy: + await _run_copy(soul) + + mock_copy.assert_not_called() + assert any("Clipboard is not available" in s.text for s in sent) diff --git a/uv.lock b/uv.lock index 4a8c9d74d..cb0f43f99 100644 --- a/uv.lock +++ b/uv.lock @@ -1229,6 +1229,7 @@ dependencies = [ { name = "pydantic" }, { name = "pykaos" }, { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" }, + { name = "pyperclip" }, { name = "pyyaml" }, { name = "rich" }, { name = "ripgrepy" }, @@ -1273,6 +1274,7 @@ requires-dist = [ { name = "pydantic", specifier = "==2.12.5" }, { name = "pykaos", editable = "packages/kaos" }, { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'", specifier = ">=12.1" }, + { name = "pyperclip", specifier = ">=1.8.0" }, { name = "pyyaml", specifier = "==6.0.3" }, { name = "rich", specifier = "==14.2.0" }, { name = "ripgrepy", specifier = "==2.2.0" },