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
28 changes: 25 additions & 3 deletions src/kimi_cli/ui/shell/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from prompt_toolkit import PromptSession
from prompt_toolkit.application.current import get_app_or_none
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.clipboard.base import ClipboardData
from prompt_toolkit.clipboard.pyperclip import PyperclipClipboard
from prompt_toolkit.completion import (
CompleteEvent,
Expand Down Expand Up @@ -81,6 +82,24 @@
PROMPT_SYMBOL_PLAN = "📋"


class _SafePyperclipClipboard(PyperclipClipboard):
"""Treat non-text clipboard payloads as empty text instead of crashing prompt_toolkit."""

@override
def get_data(self) -> ClipboardData:
try:
data = super().get_data()
except TypeError as exc:
logger.debug(
"Ignoring non-text clipboard payload in clipboard get_data: {error}",
error=exc,
)
return ClipboardData()
if not isinstance(data.text, str):
return ClipboardData()
return data


class SlashCommandCompleter(Completer):
"""
A completer that:
Expand Down Expand Up @@ -1488,7 +1507,7 @@ def _(event: KeyPressEvent) -> None:
self._insert_pasted_text(event.current_buffer, clipboard_data.text)
event.app.invalidate()

clipboard = PyperclipClipboard()
clipboard = _SafePyperclipClipboard()
else:
clipboard = None

Expand Down Expand Up @@ -1839,13 +1858,15 @@ def _try_paste_media(self, event: KeyPressEvent) -> bool:
Reads the clipboard once and handles all detected content:
non-image files (videos, PDFs, etc.) are inserted as paths,
image files are cached and inserted as placeholders.
Returns True if any media content was inserted.
Returns True if the paste event was handled (content inserted or
recognized but unsupported), False if no media was detected.
"""
result = grab_media_from_clipboard()
if result is None:
return False

parts: list[str] = []
unsupported_images = False

# 1. Insert file paths (videos, PDFs, etc.)
if result.file_paths:
Expand All @@ -1859,6 +1880,7 @@ def _try_paste_media(self, event: KeyPressEvent) -> bool:
# 2. Insert images via cache.
if result.images:
if "image_in" not in self._model_capabilities:
unsupported_images = True
console.print(
"[yellow]Image input is not supported by the selected LLM model[/yellow]"
)
Expand All @@ -1877,7 +1899,7 @@ def _try_paste_media(self, event: KeyPressEvent) -> bool:
if parts:
event.current_buffer.insert_text(" ".join(parts))
event.app.invalidate()
return bool(parts)
return bool(parts) or unsupported_images

Comment on lines 1899 to 1903
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

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

_try_paste_media() now returns True even when no content is inserted (when images are detected but unsupported), but its docstring still says it returns True only if media content was inserted. Please update the docstring to reflect the new "paste was handled/consumed" semantics so callers/tests don’t rely on the old meaning.

Copilot uses AI. Check for mistakes.
def set_prefill_text(self, text: str) -> None:
"""Pre-fill the input buffer with the given text.
Expand Down
22 changes: 19 additions & 3 deletions tests/ui_and_conv/test_prompt_clipboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from PIL import Image
from prompt_toolkit.key_binding import KeyPressEvent
from prompt_toolkit.selection import SelectionType

if TYPE_CHECKING:
from prompt_toolkit.buffer import Buffer
Expand Down Expand Up @@ -184,7 +185,7 @@ def test_paste_single_image(monkeypatch) -> None:
assert buffer.inserted[0].startswith("[image:")


def test_paste_image_unsupported_model(monkeypatch, capsys) -> None:
def test_paste_image_unsupported_model_consumes_paste(monkeypatch) -> None:
img = Image.new("RGB", (10, 10))
monkeypatch.setattr(
shell_prompt,
Expand All @@ -199,9 +200,11 @@ def test_paste_image_unsupported_model(monkeypatch, capsys) -> None:

result = ps._try_paste_media(cast(KeyPressEvent, event))

# No image placeholder inserted, returns False so caller can fall back to text paste
assert result is False
# Media was recognized, so the paste event should be consumed even though the
# model cannot accept image input.
assert result is True
assert buffer.inserted == []
assert app.invalidated is True


# --- Mixed content tests ---
Expand Down Expand Up @@ -270,6 +273,19 @@ def test_paste_returns_false_when_no_media(monkeypatch) -> None:
assert buffer.inserted == []


def test_safe_pyperclip_clipboard_treats_none_as_empty_text(monkeypatch) -> None:
monkeypatch.setattr(
"prompt_toolkit.clipboard.pyperclip.pyperclip.paste",
lambda: None,
)

clipboard = shell_prompt._SafePyperclipClipboard()
data = clipboard.get_data()

assert data.text == ""
assert data.type == SelectionType.CHARACTERS


def test_insert_pasted_text_placeholderizes_long_text_in_agent_mode() -> None:
ps = _make_prompt_session(PromptMode.AGENT)
buffer = _DummyBuffer()
Expand Down