diff --git a/src/kimi_cli/ui/shell/prompt.py b/src/kimi_cli/ui/shell/prompt.py index 0b7870ccc..3558d5565 100644 --- a/src/kimi_cli/ui/shell/prompt.py +++ b/src/kimi_cli/ui/shell/prompt.py @@ -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, @@ -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: @@ -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 @@ -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: @@ -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]" ) @@ -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 def set_prefill_text(self, text: str) -> None: """Pre-fill the input buffer with the given text. diff --git a/tests/ui_and_conv/test_prompt_clipboard.py b/tests/ui_and_conv/test_prompt_clipboard.py index 143d80a2c..692b47a90 100644 --- a/tests/ui_and_conv/test_prompt_clipboard.py +++ b/tests/ui_and_conv/test_prompt_clipboard.py @@ -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 @@ -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, @@ -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 --- @@ -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()