From 898e347a824a0fa00ee7354ab87c4ee2e76ace45 Mon Sep 17 00:00:00 2001 From: Hongyi Shen Date: Tue, 4 Nov 2025 23:08:32 -0800 Subject: [PATCH 1/8] feat: support large text paste collapse --- src/kimi_cli/ui/shell/prompt.py | 36 +++++++++++++++++- src/kimi_cli/ui/shell/replay.py | 4 +- src/kimi_cli/utils/message.py | 27 ++++++++++++-- tests/test_message_utils.py | 66 +++++++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 6 deletions(-) diff --git a/src/kimi_cli/ui/shell/prompt.py b/src/kimi_cli/ui/shell/prompt.py index ea6a276c..4928296d 100644 --- a/src/kimi_cli/ui/shell/prompt.py +++ b/src/kimi_cli/ui/shell/prompt.py @@ -41,6 +41,7 @@ from kimi_cli.ui.shell.metacmd import get_meta_commands from kimi_cli.utils.clipboard import is_clipboard_available from kimi_cli.utils.logging import logger +from kimi_cli.utils.message import LARGE_PASTE_WORD_THRESHOLD from kimi_cli.utils.string import random_string PROMPT_SYMBOL = "✨" @@ -394,7 +395,8 @@ def toast(message: str, duration: float = 5.0) -> None: _ATTACHMENT_PLACEHOLDER_RE = re.compile( - r"\[(?Pimage):(?P[a-zA-Z0-9_\-\.]+)(?:,(?P\d+)x(?P\d+))?\]" + r"\[(?Pimage|paste):(?P[a-zA-Z0-9_\-\.]+)" + r"(?:,(?P\d+)x(?P\d+)|,(?P\d+)words)?\]" ) @@ -468,6 +470,8 @@ def _insert_newline(event: KeyPressEvent) -> None: def _paste(event: KeyPressEvent) -> None: if self._try_paste_image(event): return + if self._try_paste_large_text(event): + return clipboard_data = event.app.clipboard.get_data() event.current_buffer.paste_clipboard_data(clipboard_data) @@ -599,6 +603,36 @@ def _try_paste_image(self, event: KeyPressEvent) -> bool: event.app.invalidate() return True + def _try_paste_large_text(self, event: KeyPressEvent) -> bool: + """Try to paste large text as a collapsed placeholder. Return True if successful.""" + # Get clipboard text + clipboard_data = event.app.clipboard.get_data() + text = clipboard_data.text + + if not text: + return False + + # Check if it's large enough to collapse + word_count = len(text.split()) + if word_count <= LARGE_PASTE_WORD_THRESHOLD: + return False # Not large enough, fall through to normal paste + + # Create attachment + paste_id = random_string(8) + self._attachment_parts[paste_id] = TextPart(text=text) + + logger.debug( + "Pasted large text: {paste_id}, {word_count} words", + paste_id=paste_id, + word_count=word_count, + ) + + # Insert placeholder in buffer + placeholder = f"[paste:{paste_id},{word_count}words]" + event.current_buffer.insert_text(placeholder) + event.app.invalidate() + return True + async def prompt(self) -> UserInput: with patch_stdout(): command = str(await self._session.prompt_async()).strip() diff --git a/src/kimi_cli/ui/shell/replay.py b/src/kimi_cli/ui/shell/replay.py index 50420dad..ef9782b9 100644 --- a/src/kimi_cli/ui/shell/replay.py +++ b/src/kimi_cli/ui/shell/replay.py @@ -41,7 +41,9 @@ async def replay_recent_history(history: Sequence[Message]) -> None: for run in runs: wire = Wire() - console.print(f"{getpass.getuser()}{PROMPT_SYMBOL} {message_stringify(run.user_message)}") + console.print( + f"{getpass.getuser()}{PROMPT_SYMBOL} {message_stringify(run.user_message, context='replay')}" + ) ui_task = asyncio.create_task( visualize(wire.ui_side, initial_status=StatusSnapshot(context_usage=0.0)) ) diff --git a/src/kimi_cli/utils/message.py b/src/kimi_cli/utils/message.py index 83bba974..54b4466b 100644 --- a/src/kimi_cli/utils/message.py +++ b/src/kimi_cli/utils/message.py @@ -1,5 +1,8 @@ from kosong.base.message import Message, TextPart +# Large paste display threshold (same as ui/shell/prompt.py) +LARGE_PASTE_WORD_THRESHOLD = 300 + def message_extract_text(message: Message) -> str: """Extract text from a message.""" @@ -8,15 +11,31 @@ def message_extract_text(message: Message) -> str: return "\n".join(part.text for part in message.content if isinstance(part, TextPart)) -def message_stringify(message: Message) -> str: - """Get a string representation of a message.""" +def message_stringify(message: Message, context: str = "default") -> str: + """ + Get a string representation of a message. + + Args: + message: The message to stringify. + context: Display context - "replay" shows full text, others collapse large pastes. + """ + + def _maybe_collapse(text: str) -> str: + """Collapse text if it exceeds threshold (except in replay context).""" + if context == "replay": + return text + word_count = len(text.split()) + if word_count > LARGE_PASTE_WORD_THRESHOLD: + return f"πŸ“‹ pasted {word_count} words" + return text + parts: list[str] = [] if isinstance(message.content, str): - parts.append(message.content) + parts.append(_maybe_collapse(message.content)) else: for part in message.content: if isinstance(part, TextPart): - parts.append(part.text) + parts.append(_maybe_collapse(part.text)) else: parts.append(f"[{part.type}]") return "".join(parts) diff --git a/tests/test_message_utils.py b/tests/test_message_utils.py index f2c8da73..3b1c55b0 100644 --- a/tests/test_message_utils.py +++ b/tests/test_message_utils.py @@ -101,3 +101,69 @@ def test_extract_text_from_empty_string(): result = message_extract_text(message) assert result == "" + + +def test_stringify_small_text_not_collapsed(): + """Test that small text is not collapsed.""" + message = Message(role="user", content="Hello world this is a short message") + result = message_stringify(message) + + assert result == "Hello world this is a short message" + + +def test_stringify_large_text_collapsed_in_default_context(): + """Test that large text is collapsed in default/echo context.""" + # Create text with 400 words (above default threshold of 300) + large_text = " ".join(["word"] * 400) + message = Message(role="user", content=large_text) + result = message_stringify(message) + + assert result == "πŸ“‹ pasted 400 words" + + +def test_stringify_large_text_full_in_replay_context(): + """Test that large text is shown in full in replay context.""" + large_text = " ".join(["word"] * 400) + message = Message(role="user", content=large_text) + result = message_stringify(message, context="replay") + + assert result == large_text + + +def test_stringify_boundary_at_threshold(): + """Test behavior at exact threshold boundary (300 words).""" + text_300 = " ".join(["word"] * 300) + text_301 = " ".join(["word"] * 301) + + # 300 words should not be collapsed (threshold is >300) + result_300 = message_stringify(Message(role="user", content=text_300)) + assert result_300 == text_300 + + # 301 words should be collapsed + result_301 = message_stringify(Message(role="user", content=text_301)) + assert result_301 == "πŸ“‹ pasted 301 words" + + +def test_stringify_mixed_content_with_large_text(): + """Test mixed content with large text part and image.""" + large_text = " ".join(["word"] * 400) + text_part = TextPart(text=large_text) + image_part = ImageURLPart(image_url=ImageURLPart.ImageURL(url="https://example.com/image.jpg")) + + message = Message(role="user", content=[text_part, image_part]) + result = message_stringify(message) + + assert result == "πŸ“‹ pasted 400 words[image_url]" + + +def test_stringify_multiple_text_parts_evaluated_separately(): + """Test that each text part is evaluated separately for collapsing.""" + # Two separate text parts, each large enough to collapse + text_part1 = TextPart(text=" ".join(["word"] * 350)) + text_part2 = TextPart(text=" ".join(["word"] * 350)) + + message = Message(role="user", content=[text_part1, text_part2]) + result = message_stringify(message) + + # Each part should be collapsed separately + assert result == "πŸ“‹ pasted 350 wordsπŸ“‹ pasted 350 words" From e0413f437b9e6956f54e335e3a44b71c657c652e Mon Sep 17 00:00:00 2001 From: Hongyi Shen Date: Wed, 5 Nov 2025 10:23:27 -0800 Subject: [PATCH 2/8] use cmd+v --- src/kimi_cli/ui/shell/prompt.py | 94 ++++++++++++++++++++++++--------- src/kimi_cli/utils/message.py | 2 +- tests/test_message_utils.py | 8 +-- 3 files changed, 73 insertions(+), 31 deletions(-) diff --git a/src/kimi_cli/ui/shell/prompt.py b/src/kimi_cli/ui/shell/prompt.py index 4928296d..7612553b 100644 --- a/src/kimi_cli/ui/shell/prompt.py +++ b/src/kimi_cli/ui/shell/prompt.py @@ -395,10 +395,13 @@ def toast(message: str, duration: float = 5.0) -> None: _ATTACHMENT_PLACEHOLDER_RE = re.compile( - r"\[(?Pimage|paste):(?P[a-zA-Z0-9_\-\.]+)" - r"(?:,(?P\d+)x(?P\d+)|,(?P\d+)words)?\]" + r"\[(?Pimage):(?P[a-zA-Z0-9_\-\.]+)" + r"(?:,(?P\d+)x(?P\d+))?\]" ) +# Pattern for user-friendly large paste placeholder +_LARGE_PASTE_PLACEHOLDER_RE = re.compile(r"\[pasted (\d+) words\]") + class CustomPromptSession: def __init__(self, status_provider: Callable[[], StatusSnapshot]): @@ -412,6 +415,8 @@ def __init__(self, status_provider: Callable[[], StatusSnapshot]): self._thinking: bool = False self._attachment_parts: dict[str, ContentPart] = {} """Mapping from attachment id to ContentPart.""" + self._placeholder_to_id: dict[str, str] = {} + """Mapping from placeholder text to paste id.""" history_entries = _load_history_entries(self._history_file) history = InMemoryHistory() @@ -470,8 +475,6 @@ def _insert_newline(event: KeyPressEvent) -> None: def _paste(event: KeyPressEvent) -> None: if self._try_paste_image(event): return - if self._try_paste_large_text(event): - return clipboard_data = event.app.clipboard.get_data() event.current_buffer.paste_clipboard_data(clipboard_data) @@ -502,6 +505,16 @@ def _switch_thinking(event: KeyPressEvent) -> None: bottom_toolbar=self._render_bottom_toolbar, ) + # Add callback to detect large text insertions (works with any paste method: Cmd-V, Ctrl-V, etc.) + self._pending_large_paste_replacement = False + + def _on_text_insert_handler(buffer): + # Check if large text was just inserted (via paste) + self._check_and_replace_large_paste(buffer) + + # Add handler to the event (use += to keep it as an Event object) + self._session.default_buffer.on_text_insert += _on_text_insert_handler + self._status_refresh_task: asyncio.Task | None = None self._current_toast: str | None = None self._current_toast_duration: float = 0.0 @@ -563,6 +576,7 @@ def __exit__(self, exc_type, exc_value, traceback) -> None: self._status_refresh_task.cancel() self._status_refresh_task = None self._attachment_parts.clear() + self._placeholder_to_id.clear() def _try_paste_image(self, event: KeyPressEvent) -> bool: """Try to paste an image from the clipboard. Return True if successful.""" @@ -603,35 +617,47 @@ def _try_paste_image(self, event: KeyPressEvent) -> bool: event.app.invalidate() return True - def _try_paste_large_text(self, event: KeyPressEvent) -> bool: - """Try to paste large text as a collapsed placeholder. Return True if successful.""" - # Get clipboard text - clipboard_data = event.app.clipboard.get_data() - text = clipboard_data.text + def _check_and_replace_large_paste(self, buffer) -> None: + """Check if large text was just pasted and replace with placeholder.""" + if self._pending_large_paste_replacement: + return # Already processing - if not text: - return False + # Get current document text + text = buffer.document.text - # Check if it's large enough to collapse + # Skip if text is short + if len(text) < 1000: # Quick check before word count + return + + # Check word count of entire buffer word_count = len(text.split()) if word_count <= LARGE_PASTE_WORD_THRESHOLD: - return False # Not large enough, fall through to normal paste + return - # Create attachment - paste_id = random_string(8) - self._attachment_parts[paste_id] = TextPart(text=text) + try: + self._pending_large_paste_replacement = True - logger.debug( - "Pasted large text: {paste_id}, {word_count} words", - paste_id=paste_id, - word_count=word_count, - ) + # Create attachment for the pasted text + paste_id = random_string(8) + self._attachment_parts[paste_id] = TextPart(text=text) - # Insert placeholder in buffer - placeholder = f"[paste:{paste_id},{word_count}words]" - event.current_buffer.insert_text(placeholder) - event.app.invalidate() - return True + logger.debug( + "Detected large paste: {paste_id}, {word_count} words", + paste_id=paste_id, + word_count=word_count, + ) + + # Replace buffer content with user-friendly placeholder + # Store ID internally but show simple format to user + placeholder = f"[pasted {word_count} words]" + buffer.text = placeholder + buffer.cursor_position = len(placeholder) + + # Store mapping from placeholder text to paste_id for later parsing + self._placeholder_to_id[placeholder] = paste_id + + finally: + self._pending_large_paste_replacement = False async def prompt(self) -> UserInput: with patch_stdout(): @@ -642,6 +668,22 @@ async def prompt(self) -> UserInput: # Parse rich content parts content: list[ContentPart] = [] remaining_command = command + + # First, check for large paste placeholder + paste_match = _LARGE_PASTE_PLACEHOLDER_RE.search(remaining_command) + if paste_match: + placeholder_text = paste_match.group(0) + paste_id = self._placeholder_to_id.get(placeholder_text) + if paste_id and paste_id in self._attachment_parts: + # Replace placeholder with actual text content + part = self._attachment_parts[paste_id] + content.append(part) + # Clean up + del self._placeholder_to_id[placeholder_text] + del self._attachment_parts[paste_id] + return UserInput(mode=self._mode, content=content, command=command) + + # Parse image attachments while match := _ATTACHMENT_PLACEHOLDER_RE.search(remaining_command): start, end = match.span() if start > 0: diff --git a/src/kimi_cli/utils/message.py b/src/kimi_cli/utils/message.py index 54b4466b..c09d561b 100644 --- a/src/kimi_cli/utils/message.py +++ b/src/kimi_cli/utils/message.py @@ -26,7 +26,7 @@ def _maybe_collapse(text: str) -> str: return text word_count = len(text.split()) if word_count > LARGE_PASTE_WORD_THRESHOLD: - return f"πŸ“‹ pasted {word_count} words" + return f"[pasted {word_count} words]" return text parts: list[str] = [] diff --git a/tests/test_message_utils.py b/tests/test_message_utils.py index 3b1c55b0..20bb54e2 100644 --- a/tests/test_message_utils.py +++ b/tests/test_message_utils.py @@ -118,7 +118,7 @@ def test_stringify_large_text_collapsed_in_default_context(): message = Message(role="user", content=large_text) result = message_stringify(message) - assert result == "πŸ“‹ pasted 400 words" + assert result == "[pasted 400 words]" def test_stringify_large_text_full_in_replay_context(): @@ -141,7 +141,7 @@ def test_stringify_boundary_at_threshold(): # 301 words should be collapsed result_301 = message_stringify(Message(role="user", content=text_301)) - assert result_301 == "πŸ“‹ pasted 301 words" + assert result_301 == "[pasted 301 words]" def test_stringify_mixed_content_with_large_text(): @@ -153,7 +153,7 @@ def test_stringify_mixed_content_with_large_text(): message = Message(role="user", content=[text_part, image_part]) result = message_stringify(message) - assert result == "πŸ“‹ pasted 400 words[image_url]" + assert result == "[pasted 400 words][image_url]" def test_stringify_multiple_text_parts_evaluated_separately(): @@ -166,4 +166,4 @@ def test_stringify_multiple_text_parts_evaluated_separately(): result = message_stringify(message) # Each part should be collapsed separately - assert result == "πŸ“‹ pasted 350 wordsπŸ“‹ pasted 350 words" + assert result == "[pasted 350 words][pasted 350 words]" From 91f6b515fdd0c262ecfd65355091eff45cec6368 Mon Sep 17 00:00:00 2001 From: Hongyi Shen Date: Wed, 5 Nov 2025 14:55:09 -0800 Subject: [PATCH 3/8] delete the whole placeholder --- src/kimi_cli/ui/shell/prompt.py | 40 +++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/kimi_cli/ui/shell/prompt.py b/src/kimi_cli/ui/shell/prompt.py index 7612553b..2b80f5c9 100644 --- a/src/kimi_cli/ui/shell/prompt.py +++ b/src/kimi_cli/ui/shell/prompt.py @@ -479,6 +479,46 @@ def _paste(event: KeyPressEvent) -> None: event.current_buffer.paste_clipboard_data(clipboard_data) shortcut_hints.append("ctrl-v: paste") + + def _delete_placeholder_at_cursor(buff) -> bool: + """Delete placeholder at cursor position if present. Returns True if deleted.""" + doc = buff.document + cursor = doc.cursor_position + + for match in _LARGE_PASTE_PLACEHOLDER_RE.finditer(doc.text): + start, end = match.span() + if start <= cursor <= end: + placeholder_text = match.group(0) + buff.text = doc.text[:start] + doc.text[end:] + buff.cursor_position = start + if placeholder_text in self._placeholder_to_id: + paste_id = self._placeholder_to_id.pop(placeholder_text) + self._attachment_parts.pop(paste_id, None) + return True + + for match in _ATTACHMENT_PLACEHOLDER_RE.finditer(doc.text): + start, end = match.span() + if start <= cursor <= end: + attachment_id = match.group("id") + buff.text = doc.text[:start] + doc.text[end:] + buff.cursor_position = start + self._attachment_parts.pop(attachment_id, None) + return True + + return False + + @_kb.add("backspace", eager=True) + def _smart_backspace(event: KeyPressEvent) -> None: + """Delete entire placeholder if cursor is within one, otherwise backspace normally.""" + if not _delete_placeholder_at_cursor(event.current_buffer): + event.current_buffer.delete_before_cursor(1) + + @_kb.add("delete", eager=True) + def _smart_delete(event: KeyPressEvent) -> None: + """Delete entire placeholder if cursor is within one, otherwise delete normally.""" + if not _delete_placeholder_at_cursor(event.current_buffer): + event.current_buffer.delete(1) + clipboard = PyperclipClipboard() else: clipboard = None From 07c35270aa7e68d89d0f25af66d4f0d1d154d9cf Mon Sep 17 00:00:00 2001 From: Hongyi Shen Date: Wed, 5 Nov 2025 15:38:36 -0800 Subject: [PATCH 4/8] count lines, not words --- src/kimi_cli/ui/shell/prompt.py | 118 ++++++++------------------------ src/kimi_cli/utils/message.py | 8 +-- tests/test_message_utils.py | 36 +++++----- 3 files changed, 50 insertions(+), 112 deletions(-) diff --git a/src/kimi_cli/ui/shell/prompt.py b/src/kimi_cli/ui/shell/prompt.py index 2b80f5c9..b6aff3d3 100644 --- a/src/kimi_cli/ui/shell/prompt.py +++ b/src/kimi_cli/ui/shell/prompt.py @@ -41,7 +41,7 @@ from kimi_cli.ui.shell.metacmd import get_meta_commands from kimi_cli.utils.clipboard import is_clipboard_available from kimi_cli.utils.logging import logger -from kimi_cli.utils.message import LARGE_PASTE_WORD_THRESHOLD +from kimi_cli.utils.message import LARGE_PASTE_LINE_THRESHOLD from kimi_cli.utils.string import random_string PROMPT_SYMBOL = "✨" @@ -395,13 +395,10 @@ def toast(message: str, duration: float = 5.0) -> None: _ATTACHMENT_PLACEHOLDER_RE = re.compile( - r"\[(?Pimage):(?P[a-zA-Z0-9_\-\.]+)" - r"(?:,(?P\d+)x(?P\d+))?\]" + r"\[(?Pimage|text):(?P[a-zA-Z0-9_\-\.]+)" + r"(?:,(?P\d+)x(?P\d+)|,(?P\d+) lines)?\]" ) -# Pattern for user-friendly large paste placeholder -_LARGE_PASTE_PLACEHOLDER_RE = re.compile(r"\[pasted (\d+) words\]") - class CustomPromptSession: def __init__(self, status_provider: Callable[[], StatusSnapshot]): @@ -415,8 +412,6 @@ def __init__(self, status_provider: Callable[[], StatusSnapshot]): self._thinking: bool = False self._attachment_parts: dict[str, ContentPart] = {} """Mapping from attachment id to ContentPart.""" - self._placeholder_to_id: dict[str, str] = {} - """Mapping from placeholder text to paste id.""" history_entries = _load_history_entries(self._history_file) history = InMemoryHistory() @@ -475,8 +470,30 @@ def _insert_newline(event: KeyPressEvent) -> None: def _paste(event: KeyPressEvent) -> None: if self._try_paste_image(event): return + + # Get text from clipboard clipboard_data = event.app.clipboard.get_data() - event.current_buffer.paste_clipboard_data(clipboard_data) + text = clipboard_data.text + + # Check if text is large (similar to image paste pattern) + line_count = text.count('\n') + 1 + if line_count > LARGE_PASTE_LINE_THRESHOLD: + # Create placeholder for large text (like we do for images) + paste_id = random_string(8) + self._attachment_parts[paste_id] = TextPart(text=text) + placeholder = f"[text:{paste_id},{line_count} lines]" + + logger.debug( + "Pasting large text as placeholder: {paste_id}, {line_count} lines", + paste_id=paste_id, + line_count=line_count, + ) + + # Insert placeholder at cursor (preserves surrounding text) + event.current_buffer.insert_text(placeholder) + else: + # Normal paste for small text + event.current_buffer.paste_clipboard_data(clipboard_data) shortcut_hints.append("ctrl-v: paste") @@ -485,20 +502,9 @@ def _delete_placeholder_at_cursor(buff) -> bool: doc = buff.document cursor = doc.cursor_position - for match in _LARGE_PASTE_PLACEHOLDER_RE.finditer(doc.text): - start, end = match.span() - if start <= cursor <= end: - placeholder_text = match.group(0) - buff.text = doc.text[:start] + doc.text[end:] - buff.cursor_position = start - if placeholder_text in self._placeholder_to_id: - paste_id = self._placeholder_to_id.pop(placeholder_text) - self._attachment_parts.pop(paste_id, None) - return True - for match in _ATTACHMENT_PLACEHOLDER_RE.finditer(doc.text): start, end = match.span() - if start <= cursor <= end: + if start <= cursor < end: attachment_id = match.group("id") buff.text = doc.text[:start] + doc.text[end:] buff.cursor_position = start @@ -545,16 +551,6 @@ def _switch_thinking(event: KeyPressEvent) -> None: bottom_toolbar=self._render_bottom_toolbar, ) - # Add callback to detect large text insertions (works with any paste method: Cmd-V, Ctrl-V, etc.) - self._pending_large_paste_replacement = False - - def _on_text_insert_handler(buffer): - # Check if large text was just inserted (via paste) - self._check_and_replace_large_paste(buffer) - - # Add handler to the event (use += to keep it as an Event object) - self._session.default_buffer.on_text_insert += _on_text_insert_handler - self._status_refresh_task: asyncio.Task | None = None self._current_toast: str | None = None self._current_toast_duration: float = 0.0 @@ -616,7 +612,6 @@ def __exit__(self, exc_type, exc_value, traceback) -> None: self._status_refresh_task.cancel() self._status_refresh_task = None self._attachment_parts.clear() - self._placeholder_to_id.clear() def _try_paste_image(self, event: KeyPressEvent) -> bool: """Try to paste an image from the clipboard. Return True if successful.""" @@ -657,73 +652,16 @@ def _try_paste_image(self, event: KeyPressEvent) -> bool: event.app.invalidate() return True - def _check_and_replace_large_paste(self, buffer) -> None: - """Check if large text was just pasted and replace with placeholder.""" - if self._pending_large_paste_replacement: - return # Already processing - - # Get current document text - text = buffer.document.text - - # Skip if text is short - if len(text) < 1000: # Quick check before word count - return - - # Check word count of entire buffer - word_count = len(text.split()) - if word_count <= LARGE_PASTE_WORD_THRESHOLD: - return - - try: - self._pending_large_paste_replacement = True - - # Create attachment for the pasted text - paste_id = random_string(8) - self._attachment_parts[paste_id] = TextPart(text=text) - - logger.debug( - "Detected large paste: {paste_id}, {word_count} words", - paste_id=paste_id, - word_count=word_count, - ) - - # Replace buffer content with user-friendly placeholder - # Store ID internally but show simple format to user - placeholder = f"[pasted {word_count} words]" - buffer.text = placeholder - buffer.cursor_position = len(placeholder) - - # Store mapping from placeholder text to paste_id for later parsing - self._placeholder_to_id[placeholder] = paste_id - - finally: - self._pending_large_paste_replacement = False - async def prompt(self) -> UserInput: with patch_stdout(): command = str(await self._session.prompt_async()).strip() command = command.replace("\x00", "") # just in case null bytes are somehow inserted self._append_history_entry(command) - # Parse rich content parts + # Parse rich content parts (both text and image attachments) content: list[ContentPart] = [] remaining_command = command - # First, check for large paste placeholder - paste_match = _LARGE_PASTE_PLACEHOLDER_RE.search(remaining_command) - if paste_match: - placeholder_text = paste_match.group(0) - paste_id = self._placeholder_to_id.get(placeholder_text) - if paste_id and paste_id in self._attachment_parts: - # Replace placeholder with actual text content - part = self._attachment_parts[paste_id] - content.append(part) - # Clean up - del self._placeholder_to_id[placeholder_text] - del self._attachment_parts[paste_id] - return UserInput(mode=self._mode, content=content, command=command) - - # Parse image attachments while match := _ATTACHMENT_PLACEHOLDER_RE.search(remaining_command): start, end = match.span() if start > 0: diff --git a/src/kimi_cli/utils/message.py b/src/kimi_cli/utils/message.py index c09d561b..a3c7a9cb 100644 --- a/src/kimi_cli/utils/message.py +++ b/src/kimi_cli/utils/message.py @@ -1,7 +1,7 @@ from kosong.base.message import Message, TextPart # Large paste display threshold (same as ui/shell/prompt.py) -LARGE_PASTE_WORD_THRESHOLD = 300 +LARGE_PASTE_LINE_THRESHOLD = 50 def message_extract_text(message: Message) -> str: @@ -24,9 +24,9 @@ def _maybe_collapse(text: str) -> str: """Collapse text if it exceeds threshold (except in replay context).""" if context == "replay": return text - word_count = len(text.split()) - if word_count > LARGE_PASTE_WORD_THRESHOLD: - return f"[pasted {word_count} words]" + line_count = text.count('\n') + 1 + if line_count > LARGE_PASTE_LINE_THRESHOLD: + return f"[pasted {line_count} lines]" return text parts: list[str] = [] diff --git a/tests/test_message_utils.py b/tests/test_message_utils.py index 20bb54e2..d0b82bb6 100644 --- a/tests/test_message_utils.py +++ b/tests/test_message_utils.py @@ -113,17 +113,17 @@ def test_stringify_small_text_not_collapsed(): def test_stringify_large_text_collapsed_in_default_context(): """Test that large text is collapsed in default/echo context.""" - # Create text with 400 words (above default threshold of 300) - large_text = " ".join(["word"] * 400) + # Create text with 60 lines (above default threshold of 50) + large_text = "\n".join(["line content"] * 60) message = Message(role="user", content=large_text) result = message_stringify(message) - assert result == "[pasted 400 words]" + assert result == "[pasted 60 lines]" def test_stringify_large_text_full_in_replay_context(): """Test that large text is shown in full in replay context.""" - large_text = " ".join(["word"] * 400) + large_text = "\n".join(["line content"] * 60) message = Message(role="user", content=large_text) result = message_stringify(message, context="replay") @@ -131,39 +131,39 @@ def test_stringify_large_text_full_in_replay_context(): def test_stringify_boundary_at_threshold(): - """Test behavior at exact threshold boundary (300 words).""" - text_300 = " ".join(["word"] * 300) - text_301 = " ".join(["word"] * 301) + """Test behavior at exact threshold boundary (50 lines).""" + text_50 = "\n".join(["line"] * 50) + text_51 = "\n".join(["line"] * 51) - # 300 words should not be collapsed (threshold is >300) - result_300 = message_stringify(Message(role="user", content=text_300)) - assert result_300 == text_300 + # 50 lines should not be collapsed (threshold is >50) + result_50 = message_stringify(Message(role="user", content=text_50)) + assert result_50 == text_50 - # 301 words should be collapsed - result_301 = message_stringify(Message(role="user", content=text_301)) - assert result_301 == "[pasted 301 words]" + # 51 lines should be collapsed + result_51 = message_stringify(Message(role="user", content=text_51)) + assert result_51 == "[pasted 51 lines]" def test_stringify_mixed_content_with_large_text(): """Test mixed content with large text part and image.""" - large_text = " ".join(["word"] * 400) + large_text = "\n".join(["line content"] * 60) text_part = TextPart(text=large_text) image_part = ImageURLPart(image_url=ImageURLPart.ImageURL(url="https://example.com/image.jpg")) message = Message(role="user", content=[text_part, image_part]) result = message_stringify(message) - assert result == "[pasted 400 words][image_url]" + assert result == "[pasted 60 lines][image_url]" def test_stringify_multiple_text_parts_evaluated_separately(): """Test that each text part is evaluated separately for collapsing.""" # Two separate text parts, each large enough to collapse - text_part1 = TextPart(text=" ".join(["word"] * 350)) - text_part2 = TextPart(text=" ".join(["word"] * 350)) + text_part1 = TextPart(text="\n".join(["line"] * 60)) + text_part2 = TextPart(text="\n".join(["line"] * 60)) message = Message(role="user", content=[text_part1, text_part2]) result = message_stringify(message) # Each part should be collapsed separately - assert result == "[pasted 350 words][pasted 350 words]" + assert result == "[pasted 60 lines][pasted 60 lines]" From 19b9884fbc8553047ae7287798a3060c2bef8a23 Mon Sep 17 00:00:00 2001 From: Hongyi Shen Date: Wed, 5 Nov 2025 15:51:09 -0800 Subject: [PATCH 5/8] cleanup --- src/kimi_cli/constants.py | 3 + src/kimi_cli/ui/shell/prompt.py | 12 +-- src/kimi_cli/utils/message.py | 11 +- tests/test_message_utils.py | 172 ++++++-------------------------- 4 files changed, 38 insertions(+), 160 deletions(-) create mode 100644 src/kimi_cli/constants.py diff --git a/src/kimi_cli/constants.py b/src/kimi_cli/constants.py new file mode 100644 index 00000000..4ebebdf4 --- /dev/null +++ b/src/kimi_cli/constants.py @@ -0,0 +1,3 @@ +"""Shared constants used across kimi_cli.""" + +LARGE_PASTE_LINE_THRESHOLD = 50 diff --git a/src/kimi_cli/ui/shell/prompt.py b/src/kimi_cli/ui/shell/prompt.py index b6aff3d3..4b2e2a8c 100644 --- a/src/kimi_cli/ui/shell/prompt.py +++ b/src/kimi_cli/ui/shell/prompt.py @@ -41,7 +41,7 @@ from kimi_cli.ui.shell.metacmd import get_meta_commands from kimi_cli.utils.clipboard import is_clipboard_available from kimi_cli.utils.logging import logger -from kimi_cli.utils.message import LARGE_PASTE_LINE_THRESHOLD +from kimi_cli.constants import LARGE_PASTE_LINE_THRESHOLD from kimi_cli.utils.string import random_string PROMPT_SYMBOL = "✨" @@ -471,14 +471,11 @@ def _paste(event: KeyPressEvent) -> None: if self._try_paste_image(event): return - # Get text from clipboard clipboard_data = event.app.clipboard.get_data() text = clipboard_data.text - # Check if text is large (similar to image paste pattern) line_count = text.count('\n') + 1 if line_count > LARGE_PASTE_LINE_THRESHOLD: - # Create placeholder for large text (like we do for images) paste_id = random_string(8) self._attachment_parts[paste_id] = TextPart(text=text) placeholder = f"[text:{paste_id},{line_count} lines]" @@ -488,11 +485,8 @@ def _paste(event: KeyPressEvent) -> None: paste_id=paste_id, line_count=line_count, ) - - # Insert placeholder at cursor (preserves surrounding text) event.current_buffer.insert_text(placeholder) else: - # Normal paste for small text event.current_buffer.paste_clipboard_data(clipboard_data) shortcut_hints.append("ctrl-v: paste") @@ -504,7 +498,7 @@ def _delete_placeholder_at_cursor(buff) -> bool: for match in _ATTACHMENT_PLACEHOLDER_RE.finditer(doc.text): start, end = match.span() - if start <= cursor < end: + if start <= cursor <= end: attachment_id = match.group("id") buff.text = doc.text[:start] + doc.text[end:] buff.cursor_position = start @@ -658,7 +652,7 @@ async def prompt(self) -> UserInput: command = command.replace("\x00", "") # just in case null bytes are somehow inserted self._append_history_entry(command) - # Parse rich content parts (both text and image attachments) + # Parse rich content parts content: list[ContentPart] = [] remaining_command = command diff --git a/src/kimi_cli/utils/message.py b/src/kimi_cli/utils/message.py index a3c7a9cb..406f8488 100644 --- a/src/kimi_cli/utils/message.py +++ b/src/kimi_cli/utils/message.py @@ -1,7 +1,6 @@ from kosong.base.message import Message, TextPart -# Large paste display threshold (same as ui/shell/prompt.py) -LARGE_PASTE_LINE_THRESHOLD = 50 +from kimi_cli.constants import LARGE_PASTE_LINE_THRESHOLD def message_extract_text(message: Message) -> str: @@ -12,13 +11,7 @@ def message_extract_text(message: Message) -> str: def message_stringify(message: Message, context: str = "default") -> str: - """ - Get a string representation of a message. - - Args: - message: The message to stringify. - context: Display context - "replay" shows full text, others collapse large pastes. - """ + """Return a string view of a message, collapsing large pastes outside replay context.""" def _maybe_collapse(text: str) -> str: """Collapse text if it exceeds threshold (except in replay context).""" diff --git a/tests/test_message_utils.py b/tests/test_message_utils.py index d0b82bb6..c72e1c0c 100644 --- a/tests/test_message_utils.py +++ b/tests/test_message_utils.py @@ -5,165 +5,53 @@ from kimi_cli.utils.message import message_extract_text, message_stringify -def test_extract_text_from_string_content(): - """Test extracting text from message with string content.""" +def test_message_extract_text_handles_strings(): message = Message(role="user", content="Simple text") - result = message_extract_text(message) - assert result == "Simple text" + assert message_extract_text(message) == "Simple text" -def test_extract_text_from_content_parts(): - """Test extracting text from message with content parts.""" - text_part1 = TextPart(text="Hello") - text_part2 = TextPart(text="World") - image_part = ImageURLPart(image_url=ImageURLPart.ImageURL(url="https://example.com/image.jpg")) - - message = Message(role="user", content=[text_part1, image_part, text_part2]) - result = message_extract_text(message) - - assert result == "Hello\nWorld" - - -def test_extract_text_from_empty_content_parts(): - """Test extracting text from message with no text parts.""" - image_part = ImageURLPart(image_url=ImageURLPart.ImageURL(url="https://example.com/image.jpg")) - message = Message(role="user", content=[image_part]) - result = message_extract_text(message) - - assert result == "" - - -def test_stringify_string_content(): - """Test stringifying message with string content.""" - message = Message(role="user", content="Simple text") - result = message_stringify(message) - - assert result == "Simple text" - - -def test_stringify_text_parts(): - """Test stringifying message with text parts.""" - text_part1 = TextPart(text="Hello") - text_part2 = TextPart(text="World") - message = Message(role="user", content=[text_part1, text_part2]) - result = message_stringify(message) - - assert result == "HelloWorld" - - -def test_stringify_mixed_parts(): - """Test stringifying message with text and image parts.""" - text_part1 = TextPart(text="Hello") - image_part = ImageURLPart(image_url=ImageURLPart.ImageURL(url="https://example.com/image.jpg")) - text_part2 = TextPart(text="World") - - message = Message(role="user", content=[text_part1, image_part, text_part2]) - result = message_stringify(message) - - assert result == "Hello[image_url]World" - - -def test_stringify_only_image_parts(): - """Test stringifying message with only image parts.""" - image_part1 = ImageURLPart( - image_url=ImageURLPart.ImageURL(url="https://example.com/image1.jpg") +def test_message_extract_text_joins_text_parts(): + message = Message( + role="user", + content=[ + TextPart(text="Hello"), + ImageURLPart(image_url=ImageURLPart.ImageURL(url="https://example.com/image.jpg")), + TextPart(text="World"), + ], ) - image_part2 = ImageURLPart( - image_url=ImageURLPart.ImageURL(url="https://example.com/image2.jpg") - ) - - message = Message(role="user", content=[image_part1, image_part2]) - result = message_stringify(message) - assert result == "[image_url][image_url]" + assert message_extract_text(message) == "Hello\nWorld" -def test_stringify_empty_string(): - """Test stringifying message with empty string content.""" - message = Message(role="user", content="") - result = message_stringify(message) - - assert result == "" - - -def test_stringify_empty_parts(): - """Test stringifying message with empty content parts.""" - message = Message(role="user", content=[]) - result = message_stringify(message) - - assert result == "" - - -def test_extract_text_from_empty_string(): - """Test extracting text from empty string content.""" - message = Message(role="user", content="") - result = message_extract_text(message) - - assert result == "" - - -def test_stringify_small_text_not_collapsed(): - """Test that small text is not collapsed.""" - message = Message(role="user", content="Hello world this is a short message") - result = message_stringify(message) +def test_message_stringify_plain_text(): + message = Message(role="user", content="Simple text") - assert result == "Hello world this is a short message" + assert message_stringify(message) == "Simple text" -def test_stringify_large_text_collapsed_in_default_context(): - """Test that large text is collapsed in default/echo context.""" - # Create text with 60 lines (above default threshold of 50) - large_text = "\n".join(["line content"] * 60) +def test_message_stringify_collapses_large_text_by_default(): + large_text = "\n".join(["line"] * 60) message = Message(role="user", content=large_text) - result = message_stringify(message) - assert result == "[pasted 60 lines]" + assert message_stringify(message) == "[pasted 60 lines]" -def test_stringify_large_text_full_in_replay_context(): - """Test that large text is shown in full in replay context.""" - large_text = "\n".join(["line content"] * 60) +def test_message_stringify_respects_replay_context(): + large_text = "\n".join(["line"] * 60) message = Message(role="user", content=large_text) - result = message_stringify(message, context="replay") - assert result == large_text + assert message_stringify(message, context="replay") == large_text -def test_stringify_boundary_at_threshold(): - """Test behavior at exact threshold boundary (50 lines).""" - text_50 = "\n".join(["line"] * 50) - text_51 = "\n".join(["line"] * 51) - - # 50 lines should not be collapsed (threshold is >50) - result_50 = message_stringify(Message(role="user", content=text_50)) - assert result_50 == text_50 - - # 51 lines should be collapsed - result_51 = message_stringify(Message(role="user", content=text_51)) - assert result_51 == "[pasted 51 lines]" - - -def test_stringify_mixed_content_with_large_text(): - """Test mixed content with large text part and image.""" - large_text = "\n".join(["line content"] * 60) - text_part = TextPart(text=large_text) - image_part = ImageURLPart(image_url=ImageURLPart.ImageURL(url="https://example.com/image.jpg")) - - message = Message(role="user", content=[text_part, image_part]) - result = message_stringify(message) - - assert result == "[pasted 60 lines][image_url]" - - -def test_stringify_multiple_text_parts_evaluated_separately(): - """Test that each text part is evaluated separately for collapsing.""" - # Two separate text parts, each large enough to collapse - text_part1 = TextPart(text="\n".join(["line"] * 60)) - text_part2 = TextPart(text="\n".join(["line"] * 60)) - - message = Message(role="user", content=[text_part1, text_part2]) - result = message_stringify(message) +def test_message_stringify_handles_mixed_parts(): + message = Message( + role="user", + content=[ + TextPart(text="Hello"), + ImageURLPart(image_url=ImageURLPart.ImageURL(url="https://example.com/image.jpg")), + TextPart(text="\n".join(["line"] * 60)), + ], + ) - # Each part should be collapsed separately - assert result == "[pasted 60 lines][pasted 60 lines]" + assert message_stringify(message) == "Hello[image_url][pasted 60 lines]" From 578b4494720334bc0dcfd4136373cdeaf2bb7c0b Mon Sep 17 00:00:00 2001 From: Hongyi Shen Date: Wed, 5 Nov 2025 16:24:53 -0800 Subject: [PATCH 6/8] fix --- src/kimi_cli/constants.py | 3 - src/kimi_cli/ui/shell/prompt.py | 153 ++++++++++++++++++++---------- src/kimi_cli/utils/message.py | 3 +- tests/test_message_utils.py | 120 +++++++++++++++++++---- tests/test_prompt_placeholders.py | 55 +++++++++++ 5 files changed, 262 insertions(+), 72 deletions(-) delete mode 100644 src/kimi_cli/constants.py create mode 100644 tests/test_prompt_placeholders.py diff --git a/src/kimi_cli/constants.py b/src/kimi_cli/constants.py deleted file mode 100644 index 4ebebdf4..00000000 --- a/src/kimi_cli/constants.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Shared constants used across kimi_cli.""" - -LARGE_PASTE_LINE_THRESHOLD = 50 diff --git a/src/kimi_cli/ui/shell/prompt.py b/src/kimi_cli/ui/shell/prompt.py index 4b2e2a8c..d8d49c35 100644 --- a/src/kimi_cli/ui/shell/prompt.py +++ b/src/kimi_cli/ui/shell/prompt.py @@ -41,7 +41,7 @@ from kimi_cli.ui.shell.metacmd import get_meta_commands from kimi_cli.utils.clipboard import is_clipboard_available from kimi_cli.utils.logging import logger -from kimi_cli.constants import LARGE_PASTE_LINE_THRESHOLD +from kimi_cli.utils.message import LARGE_PASTE_LINE_THRESHOLD from kimi_cli.utils.string import random_string PROMPT_SYMBOL = "✨" @@ -464,60 +464,64 @@ def _insert_newline(event: KeyPressEvent) -> None: shortcut_hints.append("ctrl-j: newline") + # Smart delete for placeholders + def _delete_placeholder_at_cursor(buff, is_backspace: bool) -> bool: + """Delete placeholder at cursor position if present. Returns True if deleted. + + Args: + buff: The buffer to check + is_backspace: True for backspace key, False for delete key + """ + doc = buff.document + cursor = doc.cursor_position + + for match in _ATTACHMENT_PLACEHOLDER_RE.finditer(doc.text): + start, end = match.span() + + # Backspace: delete if cursor is inside OR right after placeholder + # Delete: delete if cursor is inside OR right before placeholder + if is_backspace: + should_delete = start < cursor <= end + else: + should_delete = start <= cursor < end + + if should_delete: + attachment_id = match.group("id") + buff.text = doc.text[:start] + doc.text[end:] + buff.cursor_position = start + self._attachment_parts.pop(attachment_id, None) + return True + + return False + + @_kb.add("backspace", eager=True) + def _smart_backspace(event: KeyPressEvent) -> None: + """Delete entire placeholder if cursor is within/after one, otherwise backspace normally.""" + if not _delete_placeholder_at_cursor(event.current_buffer, is_backspace=True): + event.current_buffer.delete_before_cursor(1) + # Always sync tracking after modifications + self._last_buffer_text = event.current_buffer.text + + @_kb.add("delete", eager=True) + def _smart_delete(event: KeyPressEvent) -> None: + """Delete entire placeholder if cursor is within/before one, otherwise delete normally.""" + if not _delete_placeholder_at_cursor(event.current_buffer, is_backspace=False): + event.current_buffer.delete(1) + # Always sync tracking after modifications + self._last_buffer_text = event.current_buffer.text + if is_clipboard_available(): @_kb.add("c-v", eager=True) def _paste(event: KeyPressEvent) -> None: if self._try_paste_image(event): return - + # For non-image pastes, use default behavior + # Large text detection happens in on_text_insert handler clipboard_data = event.app.clipboard.get_data() - text = clipboard_data.text - - line_count = text.count('\n') + 1 - if line_count > LARGE_PASTE_LINE_THRESHOLD: - paste_id = random_string(8) - self._attachment_parts[paste_id] = TextPart(text=text) - placeholder = f"[text:{paste_id},{line_count} lines]" - - logger.debug( - "Pasting large text as placeholder: {paste_id}, {line_count} lines", - paste_id=paste_id, - line_count=line_count, - ) - event.current_buffer.insert_text(placeholder) - else: - event.current_buffer.paste_clipboard_data(clipboard_data) - - shortcut_hints.append("ctrl-v: paste") - - def _delete_placeholder_at_cursor(buff) -> bool: - """Delete placeholder at cursor position if present. Returns True if deleted.""" - doc = buff.document - cursor = doc.cursor_position - - for match in _ATTACHMENT_PLACEHOLDER_RE.finditer(doc.text): - start, end = match.span() - if start <= cursor <= end: - attachment_id = match.group("id") - buff.text = doc.text[:start] + doc.text[end:] - buff.cursor_position = start - self._attachment_parts.pop(attachment_id, None) - return True - - return False - - @_kb.add("backspace", eager=True) - def _smart_backspace(event: KeyPressEvent) -> None: - """Delete entire placeholder if cursor is within one, otherwise backspace normally.""" - if not _delete_placeholder_at_cursor(event.current_buffer): - event.current_buffer.delete_before_cursor(1) - - @_kb.add("delete", eager=True) - def _smart_delete(event: KeyPressEvent) -> None: - """Delete entire placeholder if cursor is within one, otherwise delete normally.""" - if not _delete_placeholder_at_cursor(event.current_buffer): - event.current_buffer.delete(1) + event.current_buffer.paste_clipboard_data(clipboard_data) + + shortcut_hints.append("ctrl-v / cmd-v: paste") clipboard = PyperclipClipboard() else: @@ -545,6 +549,17 @@ def _switch_thinking(event: KeyPressEvent) -> None: bottom_toolbar=self._render_bottom_toolbar, ) + # Add handler to detect and collapse large text pastes + # This works for any paste method (Cmd-V, Ctrl-V, right-click, etc.) + self._pending_large_paste_replacement = False + self._last_buffer_text = "" + + def _on_text_insert_handler(buffer): + self._check_and_replace_large_paste(buffer) + self._last_buffer_text = buffer.document.text + + self._session.default_buffer.on_text_insert += _on_text_insert_handler + self._status_refresh_task: asyncio.Task | None = None self._current_toast: str | None = None self._current_toast_duration: float = 0.0 @@ -646,12 +661,54 @@ def _try_paste_image(self, event: KeyPressEvent) -> bool: event.app.invalidate() return True + def _check_and_replace_large_paste(self, buffer) -> None: + """Check if large text was just pasted and replace ONLY the pasted part with placeholder.""" + if self._pending_large_paste_replacement: + return + + current_text = buffer.document.text + previous_text = self._last_buffer_text + + # Only handle appends (includes empty buffer case since "".startswith("") is True) + if not current_text.startswith(previous_text): + return + + inserted_text = current_text[len(previous_text):] + inserted_line_count = inserted_text.count('\n') + 1 + + if inserted_line_count <= LARGE_PASTE_LINE_THRESHOLD: + return + + try: + self._pending_large_paste_replacement = True + + # Create attachment for the pasted text only + paste_id = random_string(8) + self._attachment_parts[paste_id] = TextPart(text=inserted_text) + + logger.debug( + "Detected large paste: {paste_id}, {line_count} lines", + paste_id=paste_id, + line_count=inserted_line_count, + ) + + # Replace buffer: keep prefix, replace inserted text with placeholder + placeholder = f"[text:{paste_id},{inserted_line_count} lines]" + buffer.text = previous_text + placeholder + buffer.cursor_position = len(buffer.text) + + finally: + self._pending_large_paste_replacement = False + async def prompt(self) -> UserInput: with patch_stdout(): command = str(await self._session.prompt_async()).strip() command = command.replace("\x00", "") # just in case null bytes are somehow inserted self._append_history_entry(command) + # Reset buffer tracking for next prompt + self._last_buffer_text = "" + # Parse rich content parts content: list[ContentPart] = [] remaining_command = command diff --git a/src/kimi_cli/utils/message.py b/src/kimi_cli/utils/message.py index 406f8488..dce66a75 100644 --- a/src/kimi_cli/utils/message.py +++ b/src/kimi_cli/utils/message.py @@ -1,6 +1,7 @@ from kosong.base.message import Message, TextPart -from kimi_cli.constants import LARGE_PASTE_LINE_THRESHOLD +# Collapse text pastes with more than this many lines +LARGE_PASTE_LINE_THRESHOLD = 50 def message_extract_text(message: Message) -> str: diff --git a/tests/test_message_utils.py b/tests/test_message_utils.py index c72e1c0c..6526b826 100644 --- a/tests/test_message_utils.py +++ b/tests/test_message_utils.py @@ -5,46 +5,125 @@ from kimi_cli.utils.message import message_extract_text, message_stringify -def test_message_extract_text_handles_strings(): +def test_extract_text_from_string_content(): + """Test extracting text from message with string content.""" message = Message(role="user", content="Simple text") + result = message_extract_text(message) - assert message_extract_text(message) == "Simple text" + assert result == "Simple text" -def test_message_extract_text_joins_text_parts(): - message = Message( - role="user", - content=[ - TextPart(text="Hello"), - ImageURLPart(image_url=ImageURLPart.ImageURL(url="https://example.com/image.jpg")), - TextPart(text="World"), - ], - ) +def test_extract_text_from_content_parts(): + """Test extracting text from message with content parts.""" + text_part1 = TextPart(text="Hello") + text_part2 = TextPart(text="World") + image_part = ImageURLPart(image_url=ImageURLPart.ImageURL(url="https://example.com/image.jpg")) + + message = Message(role="user", content=[text_part1, image_part, text_part2]) + result = message_extract_text(message) + + assert result == "Hello\nWorld" - assert message_extract_text(message) == "Hello\nWorld" +def test_extract_text_from_empty_content_parts(): + """Test extracting text from message with no text parts.""" + image_part = ImageURLPart(image_url=ImageURLPart.ImageURL(url="https://example.com/image.jpg")) + message = Message(role="user", content=[image_part]) + result = message_extract_text(message) -def test_message_stringify_plain_text(): + assert result == "" + + +def test_stringify_string_content(): + """Test stringifying message with string content.""" message = Message(role="user", content="Simple text") + result = message_stringify(message) + + assert result == "Simple text" + + +def test_stringify_text_parts(): + """Test stringifying message with text parts.""" + text_part1 = TextPart(text="Hello") + text_part2 = TextPart(text="World") + message = Message(role="user", content=[text_part1, text_part2]) + result = message_stringify(message) + + assert result == "HelloWorld" + + +def test_stringify_mixed_parts(): + """Test stringifying message with text and image parts.""" + text_part1 = TextPart(text="Hello") + image_part = ImageURLPart(image_url=ImageURLPart.ImageURL(url="https://example.com/image.jpg")) + text_part2 = TextPart(text="World") + + message = Message(role="user", content=[text_part1, image_part, text_part2]) + result = message_stringify(message) + + assert result == "Hello[image_url]World" + + +def test_stringify_only_image_parts(): + """Test stringifying message with only image parts.""" + image_part1 = ImageURLPart( + image_url=ImageURLPart.ImageURL(url="https://example.com/image1.jpg") + ) + image_part2 = ImageURLPart( + image_url=ImageURLPart.ImageURL(url="https://example.com/image2.jpg") + ) + + message = Message(role="user", content=[image_part1, image_part2]) + result = message_stringify(message) + + assert result == "[image_url][image_url]" + + +def test_stringify_empty_string(): + """Test stringifying message with empty string content.""" + message = Message(role="user", content="") + result = message_stringify(message) + + assert result == "" + + +def test_stringify_empty_parts(): + """Test stringifying message with empty content parts.""" + message = Message(role="user", content=[]) + result = message_stringify(message) + + assert result == "" + + +def test_extract_text_from_empty_string(): + """Test extracting text from empty string content.""" + message = Message(role="user", content="") + result = message_extract_text(message) - assert message_stringify(message) == "Simple text" + assert result == "" -def test_message_stringify_collapses_large_text_by_default(): +# New tests for large paste collapse feature +def test_stringify_collapses_large_text_by_default(): + """Test that large text is collapsed in default context.""" large_text = "\n".join(["line"] * 60) message = Message(role="user", content=large_text) + result = message_stringify(message) - assert message_stringify(message) == "[pasted 60 lines]" + assert result == "[pasted 60 lines]" -def test_message_stringify_respects_replay_context(): +def test_stringify_respects_replay_context(): + """Test that large text is shown in full in replay context.""" large_text = "\n".join(["line"] * 60) message = Message(role="user", content=large_text) + result = message_stringify(message, context="replay") - assert message_stringify(message, context="replay") == large_text + assert result == large_text -def test_message_stringify_handles_mixed_parts(): +def test_stringify_handles_mixed_parts_with_large_text(): + """Test mixed content with large text part and image.""" message = Message( role="user", content=[ @@ -53,5 +132,6 @@ def test_message_stringify_handles_mixed_parts(): TextPart(text="\n".join(["line"] * 60)), ], ) + result = message_stringify(message) - assert message_stringify(message) == "Hello[image_url][pasted 60 lines]" + assert result == "Hello[image_url][pasted 60 lines]" diff --git a/tests/test_prompt_placeholders.py b/tests/test_prompt_placeholders.py new file mode 100644 index 00000000..cf7b5571 --- /dev/null +++ b/tests/test_prompt_placeholders.py @@ -0,0 +1,55 @@ +"""Unit tests for prompt placeholder deletion logic (cursor boundary conditions).""" + +import re + +from kimi_cli.utils.message import LARGE_PASTE_LINE_THRESHOLD + +# The regex pattern from prompt.py +_ATTACHMENT_PLACEHOLDER_RE = re.compile( + r"\[(?Pimage|text):(?P[a-zA-Z0-9_\-\.]+)" + r"(?:,(?P\d+)x(?P\d+)|,(?P\d+) lines)?\]" +) + + +class TestCursorBoundaryLogic: + """Test cursor boundary conditions for placeholder deletion (the main bug we fixed).""" + + def test_backspace_at_end_deletes_placeholder(self): + """Test backspace with cursor right after placeholder deletes it (key bug fix).""" + text = "prefix [text:abc12345,60 lines]" + match = _ATTACHMENT_PLACEHOLDER_RE.search(text) + start, end = match.span() + + cursor = end # Right after placeholder + should_delete = start < cursor <= end # Backspace logic + assert should_delete + + def test_backspace_before_placeholder_does_not_delete(self): + """Test backspace before placeholder doesn't delete it.""" + text = "prefix [text:abc12345,60 lines]" + match = _ATTACHMENT_PLACEHOLDER_RE.search(text) + start, end = match.span() + + cursor = start # Right before placeholder + should_delete = start < cursor <= end # Backspace logic + assert not should_delete + + def test_delete_at_start_deletes_placeholder(self): + """Test delete with cursor before placeholder deletes it.""" + text = "prefix [text:abc12345,60 lines]" + match = _ATTACHMENT_PLACEHOLDER_RE.search(text) + start, end = match.span() + + cursor = start # Right before placeholder + should_delete = start <= cursor < end # Delete logic + assert should_delete + + def test_delete_after_placeholder_does_not_delete(self): + """Test delete after placeholder doesn't delete it.""" + text = "prefix [text:abc12345,60 lines] suffix" + match = _ATTACHMENT_PLACEHOLDER_RE.search(text) + start, end = match.span() + + cursor = end # Right after placeholder + should_delete = start <= cursor < end # Delete logic + assert not should_delete From 96dada3b51110b9df85d450e8f724a223546f364 Mon Sep 17 00:00:00 2001 From: Hongyi Shen Date: Wed, 5 Nov 2025 16:55:50 -0800 Subject: [PATCH 7/8] code hygeine --- src/kimi_cli/ui/shell/prompt.py | 18 ++------------ tests/test_message_utils.py | 15 ------------ tests/test_prompt_placeholders.py | 40 +++++++++++++------------------ 3 files changed, 18 insertions(+), 55 deletions(-) diff --git a/src/kimi_cli/ui/shell/prompt.py b/src/kimi_cli/ui/shell/prompt.py index d8d49c35..72740737 100644 --- a/src/kimi_cli/ui/shell/prompt.py +++ b/src/kimi_cli/ui/shell/prompt.py @@ -464,14 +464,8 @@ def _insert_newline(event: KeyPressEvent) -> None: shortcut_hints.append("ctrl-j: newline") - # Smart delete for placeholders def _delete_placeholder_at_cursor(buff, is_backspace: bool) -> bool: - """Delete placeholder at cursor position if present. Returns True if deleted. - - Args: - buff: The buffer to check - is_backspace: True for backspace key, False for delete key - """ + """Delete placeholder at cursor position if present, returning True if deleted.""" doc = buff.document cursor = doc.cursor_position @@ -499,7 +493,6 @@ def _smart_backspace(event: KeyPressEvent) -> None: """Delete entire placeholder if cursor is within/after one, otherwise backspace normally.""" if not _delete_placeholder_at_cursor(event.current_buffer, is_backspace=True): event.current_buffer.delete_before_cursor(1) - # Always sync tracking after modifications self._last_buffer_text = event.current_buffer.text @_kb.add("delete", eager=True) @@ -507,7 +500,6 @@ def _smart_delete(event: KeyPressEvent) -> None: """Delete entire placeholder if cursor is within/before one, otherwise delete normally.""" if not _delete_placeholder_at_cursor(event.current_buffer, is_backspace=False): event.current_buffer.delete(1) - # Always sync tracking after modifications self._last_buffer_text = event.current_buffer.text if is_clipboard_available(): @@ -516,7 +508,6 @@ def _smart_delete(event: KeyPressEvent) -> None: def _paste(event: KeyPressEvent) -> None: if self._try_paste_image(event): return - # For non-image pastes, use default behavior # Large text detection happens in on_text_insert handler clipboard_data = event.app.clipboard.get_data() event.current_buffer.paste_clipboard_data(clipboard_data) @@ -549,7 +540,6 @@ def _switch_thinking(event: KeyPressEvent) -> None: bottom_toolbar=self._render_bottom_toolbar, ) - # Add handler to detect and collapse large text pastes # This works for any paste method (Cmd-V, Ctrl-V, right-click, etc.) self._pending_large_paste_replacement = False self._last_buffer_text = "" @@ -662,7 +652,7 @@ def _try_paste_image(self, event: KeyPressEvent) -> bool: return True def _check_and_replace_large_paste(self, buffer) -> None: - """Check if large text was just pasted and replace ONLY the pasted part with placeholder.""" + """Check if large text was pasted and replace only the pasted part with placeholder.""" if self._pending_large_paste_replacement: return @@ -682,7 +672,6 @@ def _check_and_replace_large_paste(self, buffer) -> None: try: self._pending_large_paste_replacement = True - # Create attachment for the pasted text only paste_id = random_string(8) self._attachment_parts[paste_id] = TextPart(text=inserted_text) @@ -692,7 +681,6 @@ def _check_and_replace_large_paste(self, buffer) -> None: line_count=inserted_line_count, ) - # Replace buffer: keep prefix, replace inserted text with placeholder placeholder = f"[text:{paste_id},{inserted_line_count} lines]" buffer.text = previous_text + placeholder buffer.cursor_position = len(buffer.text) @@ -706,10 +694,8 @@ async def prompt(self) -> UserInput: command = command.replace("\x00", "") # just in case null bytes are somehow inserted self._append_history_entry(command) - # Reset buffer tracking for next prompt self._last_buffer_text = "" - # Parse rich content parts content: list[ContentPart] = [] remaining_command = command diff --git a/tests/test_message_utils.py b/tests/test_message_utils.py index 6526b826..bb870f9e 100644 --- a/tests/test_message_utils.py +++ b/tests/test_message_utils.py @@ -120,18 +120,3 @@ def test_stringify_respects_replay_context(): result = message_stringify(message, context="replay") assert result == large_text - - -def test_stringify_handles_mixed_parts_with_large_text(): - """Test mixed content with large text part and image.""" - message = Message( - role="user", - content=[ - TextPart(text="Hello"), - ImageURLPart(image_url=ImageURLPart.ImageURL(url="https://example.com/image.jpg")), - TextPart(text="\n".join(["line"] * 60)), - ], - ) - result = message_stringify(message) - - assert result == "Hello[image_url][pasted 60 lines]" diff --git a/tests/test_prompt_placeholders.py b/tests/test_prompt_placeholders.py index cf7b5571..655b37b2 100644 --- a/tests/test_prompt_placeholders.py +++ b/tests/test_prompt_placeholders.py @@ -14,42 +14,34 @@ class TestCursorBoundaryLogic: """Test cursor boundary conditions for placeholder deletion (the main bug we fixed).""" - def test_backspace_at_end_deletes_placeholder(self): - """Test backspace with cursor right after placeholder deletes it (key bug fix).""" + def test_backspace_boundary_conditions(self): + """Test backspace deletes when cursor is after placeholder, not before.""" text = "prefix [text:abc12345,60 lines]" match = _ATTACHMENT_PLACEHOLDER_RE.search(text) start, end = match.span() - cursor = end # Right after placeholder - should_delete = start < cursor <= end # Backspace logic + # Backspace with cursor right after placeholder should delete (the bug fix) + cursor = end + should_delete = start < cursor <= end assert should_delete - def test_backspace_before_placeholder_does_not_delete(self): - """Test backspace before placeholder doesn't delete it.""" - text = "prefix [text:abc12345,60 lines]" - match = _ATTACHMENT_PLACEHOLDER_RE.search(text) - start, end = match.span() - - cursor = start # Right before placeholder - should_delete = start < cursor <= end # Backspace logic + # Backspace with cursor before placeholder should not delete + cursor = start + should_delete = start < cursor <= end assert not should_delete - def test_delete_at_start_deletes_placeholder(self): - """Test delete with cursor before placeholder deletes it.""" + def test_delete_boundary_conditions(self): + """Test delete key deletes when cursor is before placeholder, not after.""" text = "prefix [text:abc12345,60 lines]" match = _ATTACHMENT_PLACEHOLDER_RE.search(text) start, end = match.span() - cursor = start # Right before placeholder - should_delete = start <= cursor < end # Delete logic + # Delete with cursor before placeholder should delete + cursor = start + should_delete = start <= cursor < end assert should_delete - def test_delete_after_placeholder_does_not_delete(self): - """Test delete after placeholder doesn't delete it.""" - text = "prefix [text:abc12345,60 lines] suffix" - match = _ATTACHMENT_PLACEHOLDER_RE.search(text) - start, end = match.span() - - cursor = end # Right after placeholder - should_delete = start <= cursor < end # Delete logic + # Delete with cursor after placeholder should not delete + cursor = end + should_delete = start <= cursor < end assert not should_delete From b0cd9db8a6054a24f4f7a5c2eaa1ffc2d97692d4 Mon Sep 17 00:00:00 2001 From: Hongyi Shen Date: Wed, 5 Nov 2025 17:09:32 -0800 Subject: [PATCH 8/8] format --- src/kimi_cli/ui/shell/prompt.py | 15 +++++++-------- src/kimi_cli/ui/shell/replay.py | 3 ++- src/kimi_cli/utils/message.py | 2 +- tests/test_message_utils.py | 1 - tests/test_prompt_placeholders.py | 4 ++-- 5 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/kimi_cli/ui/shell/prompt.py b/src/kimi_cli/ui/shell/prompt.py index 72740737..7dac3805 100644 --- a/src/kimi_cli/ui/shell/prompt.py +++ b/src/kimi_cli/ui/shell/prompt.py @@ -394,6 +394,8 @@ def toast(message: str, duration: float = 5.0) -> None: _toast_queue.put_nowait((message, duration)) +# Matches both image and text placeholders: +# [image:id,640x480] or [text:id,60 lines] _ATTACHMENT_PLACEHOLDER_RE = re.compile( r"\[(?Pimage|text):(?P[a-zA-Z0-9_\-\.]+)" r"(?:,(?P\d+)x(?P\d+)|,(?P\d+) lines)?\]" @@ -474,10 +476,7 @@ def _delete_placeholder_at_cursor(buff, is_backspace: bool) -> bool: # Backspace: delete if cursor is inside OR right after placeholder # Delete: delete if cursor is inside OR right before placeholder - if is_backspace: - should_delete = start < cursor <= end - else: - should_delete = start <= cursor < end + should_delete = start < cursor <= end if is_backspace else start <= cursor < end if should_delete: attachment_id = match.group("id") @@ -490,14 +489,14 @@ def _delete_placeholder_at_cursor(buff, is_backspace: bool) -> bool: @_kb.add("backspace", eager=True) def _smart_backspace(event: KeyPressEvent) -> None: - """Delete entire placeholder if cursor is within/after one, otherwise backspace normally.""" + """Delete entire placeholder if cursor is within/after one, else backspace normally.""" if not _delete_placeholder_at_cursor(event.current_buffer, is_backspace=True): event.current_buffer.delete_before_cursor(1) self._last_buffer_text = event.current_buffer.text @_kb.add("delete", eager=True) def _smart_delete(event: KeyPressEvent) -> None: - """Delete entire placeholder if cursor is within/before one, otherwise delete normally.""" + """Delete entire placeholder if cursor is within/before one, else delete normally.""" if not _delete_placeholder_at_cursor(event.current_buffer, is_backspace=False): event.current_buffer.delete(1) self._last_buffer_text = event.current_buffer.text @@ -663,8 +662,8 @@ def _check_and_replace_large_paste(self, buffer) -> None: if not current_text.startswith(previous_text): return - inserted_text = current_text[len(previous_text):] - inserted_line_count = inserted_text.count('\n') + 1 + inserted_text = current_text[len(previous_text) :] + inserted_line_count = inserted_text.count("\n") + 1 if inserted_line_count <= LARGE_PASTE_LINE_THRESHOLD: return diff --git a/src/kimi_cli/ui/shell/replay.py b/src/kimi_cli/ui/shell/replay.py index ef9782b9..d63078de 100644 --- a/src/kimi_cli/ui/shell/replay.py +++ b/src/kimi_cli/ui/shell/replay.py @@ -42,7 +42,8 @@ async def replay_recent_history(history: Sequence[Message]) -> None: for run in runs: wire = Wire() console.print( - f"{getpass.getuser()}{PROMPT_SYMBOL} {message_stringify(run.user_message, context='replay')}" + f"{getpass.getuser()}{PROMPT_SYMBOL} " + f"{message_stringify(run.user_message, context='replay')}" ) ui_task = asyncio.create_task( visualize(wire.ui_side, initial_status=StatusSnapshot(context_usage=0.0)) diff --git a/src/kimi_cli/utils/message.py b/src/kimi_cli/utils/message.py index dce66a75..9933246a 100644 --- a/src/kimi_cli/utils/message.py +++ b/src/kimi_cli/utils/message.py @@ -18,7 +18,7 @@ def _maybe_collapse(text: str) -> str: """Collapse text if it exceeds threshold (except in replay context).""" if context == "replay": return text - line_count = text.count('\n') + 1 + line_count = text.count("\n") + 1 if line_count > LARGE_PASTE_LINE_THRESHOLD: return f"[pasted {line_count} lines]" return text diff --git a/tests/test_message_utils.py b/tests/test_message_utils.py index bb870f9e..01c5edec 100644 --- a/tests/test_message_utils.py +++ b/tests/test_message_utils.py @@ -103,7 +103,6 @@ def test_extract_text_from_empty_string(): assert result == "" -# New tests for large paste collapse feature def test_stringify_collapses_large_text_by_default(): """Test that large text is collapsed in default context.""" large_text = "\n".join(["line"] * 60) diff --git a/tests/test_prompt_placeholders.py b/tests/test_prompt_placeholders.py index 655b37b2..0dd04676 100644 --- a/tests/test_prompt_placeholders.py +++ b/tests/test_prompt_placeholders.py @@ -2,8 +2,6 @@ import re -from kimi_cli.utils.message import LARGE_PASTE_LINE_THRESHOLD - # The regex pattern from prompt.py _ATTACHMENT_PLACEHOLDER_RE = re.compile( r"\[(?Pimage|text):(?P[a-zA-Z0-9_\-\.]+)" @@ -18,6 +16,7 @@ def test_backspace_boundary_conditions(self): """Test backspace deletes when cursor is after placeholder, not before.""" text = "prefix [text:abc12345,60 lines]" match = _ATTACHMENT_PLACEHOLDER_RE.search(text) + assert match is not None start, end = match.span() # Backspace with cursor right after placeholder should delete (the bug fix) @@ -34,6 +33,7 @@ def test_delete_boundary_conditions(self): """Test delete key deletes when cursor is before placeholder, not after.""" text = "prefix [text:abc12345,60 lines]" match = _ATTACHMENT_PLACEHOLDER_RE.search(text) + assert match is not None start, end = match.span() # Delete with cursor before placeholder should delete