Skip to content
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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
27 changes: 27 additions & 0 deletions src/kimi_cli/soul/slash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)"""
Expand Down
5 changes: 5 additions & 0 deletions src/kimi_cli/utils/clipboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
24 changes: 24 additions & 0 deletions src/kimi_cli/utils/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "{}"
Expand Down
128 changes: 128 additions & 0 deletions tests/core/test_copy_slash.py
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Mock clipboard availability in success-path /copy tests

The new /copy tests (except the explicit unavailable case) call the command without stubbing is_clipboard_available(), so they depend on the host having a working system clipboard. In headless CI/Linux environments where pyperclip reports no backend, /copy returns early with the unavailable message and these assertions fail even though command logic is otherwise correct. Please force clipboard availability to True in the success-path tests to make them deterministic.

Useful? React with 👍 / 👎.


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)
Comment on lines +47 to +62
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Tests don't mock is_clipboard_available, causing failures on headless CI

The tests test_copy_text_only, test_copy_with_thinking, and test_copy_latest_assistant_only mock copy_text_to_clipboard but do not mock is_clipboard_available. The /copy command (src/kimi_cli/soul/slash.py:95-98) does a local import of is_clipboard_available from kimi_cli.utils.clipboard and calls it first. On headless CI runners (ubuntu-22.04 per .github/workflows/ci-kimi-cli.yml with no xclip/xsel installed and no conftest fixture mocking clipboard), pyperclip.paste() raises PyperclipException, is_clipboard_available() returns False, and the command returns early with "Clipboard is not available" — never reaching copy_text_to_clipboard. The subsequent mock_copy.assert_called_once_with(...) assertions then fail. The test_copy_clipboard_unavailable test correctly patches is_clipboard_available to lambda: False, showing the author was aware of this pattern but didn't apply the inverse (lambda: True) to the other three tests.

Prompt for agents
In tests/core/test_copy_slash.py, the tests test_copy_text_only, test_copy_with_thinking, and test_copy_latest_assistant_only all need to mock is_clipboard_available to return True before calling _run_copy. Without this, on headless systems (like the CI runners), pyperclip.paste() will throw, is_clipboard_available() will return False, and the /copy command will return early without ever calling copy_text_to_clipboard, failing the assertions.

The fix is to add a monkeypatch.setattr call in each of the three tests, similar to what test_copy_clipboard_unavailable does but returning True instead of False. For example, add this line before the 'with patch(...)' block in each test:

    monkeypatch.setattr("kimi_cli.soul.slash.is_clipboard_available", lambda: True)

Or, since the copy function does a local import from kimi_cli.utils.clipboard, patch the module attribute:

    monkeypatch.setattr("kimi_cli.utils.clipboard.is_clipboard_available", lambda: True)

Alternatively, consider creating a shared fixture or autouse fixture in a conftest.py that mocks is_clipboard_available for all clipboard tests.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


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)
2 changes: 2 additions & 0 deletions uv.lock

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

Loading