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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Only write entries that are worth mentioning to users.

## Unreleased

- Shell: Long pasted-text placeholders are now persisted in the prompt cache, so history recall, a restarted prompt session, or pasting into approval/question prompt feedback sends the original pasted content instead of the literal `[Pasted text ...]` token

## 1.47.0 (2026-06-05)

- Shell: Guide users to the new standalone Kimi Code — adds a `/upgrade` command that installs it (migrating your config & sessions automatically), a welcome-screen nudge, and a once-per-day tip shown on exit
Expand Down
2 changes: 1 addition & 1 deletion src/kimi_cli/ui/shell/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1397,7 +1397,7 @@ def _activate_prompt_approval_modal(self) -> None:
current_request,
on_response=self._handle_prompt_approval_response,
buffer_state_provider=self._get_default_buffer_text_and_cursor,
text_expander=self._prompt_session._get_placeholder_manager().serialize_for_history, # pyright: ignore[reportPrivateUsage]
text_expander=self._prompt_session._get_placeholder_manager().expand_for_editor, # pyright: ignore[reportPrivateUsage]
)
self._prompt_session.attach_modal(self._approval_modal)
else:
Expand Down
86 changes: 72 additions & 14 deletions src/kimi_cli/ui/shell/placeholders.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@
_PASTED_TEXT_PLACEHOLDER_RE = re.compile(
r"\[Pasted text #(?P<id>\d+)(?: \+(?P<lines>\d+) lines?)?\]"
)
_PERSISTED_PASTED_TEXT_PLACEHOLDER_RE = re.compile(
r"\[Pasted text:(?P<id>[a-zA-Z0-9_\-\.]+)(?: \+(?P<lines>\d+) lines?)?\]"
)
_MISSING_PASTED_TEXT_MESSAGE = "[Missing pasted text: original placeholder content is unavailable]"

_TEXT_PASTE_CHAR_THRESHOLD = get_env_int("KIMI_CLI_PASTE_CHAR_THRESHOLD", 1000)
_TEXT_PASTE_LINE_THRESHOLD = get_env_int("KIMI_CLI_PASTE_LINE_THRESHOLD", 15)
Expand Down Expand Up @@ -72,6 +76,13 @@ def build_pasted_text_placeholder(paste_id: int, text: str) -> str:
return f"[Pasted text #{paste_id} +{line_count} lines]"


def build_persisted_pasted_text_placeholder(attachment_id: str, text: str) -> str:
line_count = count_text_lines(text)
if line_count <= 1:
return f"[Pasted text:{attachment_id}]"
return f"[Pasted text:{attachment_id} +{line_count} lines]"


def _guess_image_mime(path: Path) -> str:
mime, _ = mimetypes.guess_type(path.name)
if mime:
Expand All @@ -88,7 +99,7 @@ def _build_image_part(image_bytes: bytes, mime_type: str) -> ImageURLPart:
)


type CachedAttachmentKind = Literal["image"]
type CachedAttachmentKind = Literal["image", "pasted_text"]


@dataclass(slots=True)
Expand All @@ -109,7 +120,10 @@ def __init__(
) -> None:
self._root = root or _DEFAULT_PROMPT_CACHE_ROOT
self._legacy_roots = tuple(legacy_roots or (_LEGACY_PROMPT_CACHE_ROOT,))
self._dir_map: dict[CachedAttachmentKind, str] = {"image": "images"}
self._dir_map: dict[CachedAttachmentKind, str] = {
"image": "images",
"pasted_text": "pasted-text",
}
self._payload_map: dict[tuple[CachedAttachmentKind, str, str], CachedAttachment] = {}

def _dir_for(self, kind: CachedAttachmentKind, *, root: Path | None = None) -> Path:
Expand Down Expand Up @@ -171,6 +185,9 @@ def store_image(self, image: Image.Image) -> CachedAttachment | None:
image.save(png_bytes, format="PNG")
return self.store_bytes("image", ".png", png_bytes.getvalue())

def store_pasted_text(self, text: str) -> CachedAttachment | None:
return self.store_bytes("pasted_text", ".txt", text.encode("utf-8"))

def _candidate_paths(self, kind: CachedAttachmentKind, attachment_id: str) -> list[Path]:
roots = (self._root, *self._legacy_roots)
return [self._dir_for(kind, root=root) / attachment_id for root in roots]
Expand Down Expand Up @@ -205,6 +222,13 @@ def load_content_parts(
return wrap_media_part(part, tag="image", attrs={"path": str(path)})
return None

def load_pasted_text(self, attachment_id: str) -> str | None:
payload = self.load_bytes("pasted_text", attachment_id)
if payload is None:
return None
_path, text_bytes = payload
return text_bytes.decode("utf-8", errors="replace")


def parse_attachment_kind(raw_kind: str) -> CachedAttachmentKind | None:
if raw_kind == "image":
Expand Down Expand Up @@ -246,13 +270,24 @@ def token(self) -> str:
return build_pasted_text_placeholder(self.paste_id, self.text)


@dataclass(slots=True)
class ResolvedPastedTextEntry:
text: str
token: str


class PastedTextPlaceholderHandler:
def __init__(self) -> None:
def __init__(self, attachment_cache: AttachmentCache) -> None:
self._attachment_cache = attachment_cache
self._entries: dict[int, PastedTextEntry] = {}
self._next_id = 1

def create_placeholder(self, text: str) -> str:
normalized = sanitize_surrogates(normalize_pasted_text(text))
cached = self._attachment_cache.store_pasted_text(normalized)
if cached is not None:
return build_persisted_pasted_text_placeholder(cached.attachment_id, normalized)

entry = PastedTextEntry(paste_id=self._next_id, text=normalized)
self._entries[entry.paste_id] = entry
self._next_id += 1
Expand All @@ -269,19 +304,18 @@ def entry_for_id(self, paste_id: int) -> PastedTextEntry | None:

def iter_entries_for_command(
self, command: str
) -> list[tuple[PlaceholderTokenMatch, PastedTextEntry]]:
entries: list[tuple[PlaceholderTokenMatch, PastedTextEntry]] = []
) -> list[tuple[PlaceholderTokenMatch, ResolvedPastedTextEntry]]:
entries: list[tuple[PlaceholderTokenMatch, ResolvedPastedTextEntry]] = []
cursor = 0
while match := self.find_next(command, cursor):
paste_id = int(match.match.group("id"))
entry = self.entry_for_id(paste_id)
entry = self._resolved_entry_for_match(match)
if entry is not None:
entries.append((match, entry))
cursor = match.end
return entries

def find_next(self, text: str, start: int = 0) -> PlaceholderTokenMatch | None:
match = _PASTED_TEXT_PLACEHOLDER_RE.search(text, start)
match = self._find_earliest_text_match(text, start)
if match is None:
return None
return PlaceholderTokenMatch(
Expand All @@ -293,18 +327,30 @@ def find_next(self, text: str, start: int = 0) -> PlaceholderTokenMatch | None:
)

def resolve_content(self, match: PlaceholderTokenMatch) -> list[ContentPart] | None:
paste_id = int(match.match.group("id"))
entry = self.entry_for_id(paste_id)
if entry is None:
return None
return [TextPart(text=entry.text)]
return [TextPart(text=self.expand_text(match))]

def expand_text(self, match: PlaceholderTokenMatch) -> str | None:
return self._text_for_match(match) or _MISSING_PASTED_TEXT_MESSAGE

def _text_for_match(self, match: PlaceholderTokenMatch) -> str | None:
if match.match.re is _PERSISTED_PASTED_TEXT_PLACEHOLDER_RE:
return self._attachment_cache.load_pasted_text(match.match.group("id"))

paste_id = int(match.match.group("id"))
entry = self.entry_for_id(paste_id)
return None if entry is None else entry.text

def _resolved_entry_for_match(
self, match: PlaceholderTokenMatch
) -> ResolvedPastedTextEntry | None:
text = self._text_for_match(match)
if text is None:
return None
return ResolvedPastedTextEntry(text=text, token=match.raw)

def serialize_for_history(self, match: PlaceholderTokenMatch) -> str | None:
if match.match.re is _PERSISTED_PASTED_TEXT_PLACEHOLDER_RE:
return match.raw
Comment on lines +352 to +353

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 Expand persisted paste tokens outside history writes

When a long paste is entered into a running approval/question modal, those delegates pass serialize_for_history as their text_expander (src/kimi_cli/ui/shell/visualize/_approval_panel.py and _question_panel.py). With this branch returning the raw persisted token here, reject feedback or “Other” answers containing pasted text now submit [Pasted text:…] instead of the original content, whereas legacy in-memory tokens were expanded. Keep history preservation separate from the expansion path used by these modal callers.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks, fixed in 4b63493d.

Both modal call sites now use expand_for_editor as the text_expander, so approval feedback and question "Other" answers expand cache-backed pasted-text tokens before submission while serialize_for_history remains scoped to history writes. I also added regression coverage for persisted pasted-text tokens in both modal paths.

Comment on lines +352 to +353

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.

🔴 serialize_for_history used as text_expander no longer expands persisted placeholders, sending raw tokens to the model

The PR changed serialize_for_history (line 352-353) to return match.raw for persisted placeholders, which is correct for the history serialization use case. However, serialize_for_history is also used as the text_expander callback in the approval panel (src/kimi_cli/ui/shell/__init__.py:1399) and question panel (src/kimi_cli/ui/shell/visualize/_interactive.py:514). These callers expect placeholders to be expanded to their full text content before sending to the model. Since persisted tokens are now the default (happy path), when a user pastes long text in a question or approval feedback field, the model receives the literal placeholder string [Pasted text:abc123.txt +15 lines] instead of the actual pasted content. The correct method to use as text_expander would be expand_for_editor, which always expands both legacy and persisted text placeholders via expand_text.

Prompt for agents
The issue is that serialize_for_history now preserves persisted placeholder tokens (returning match.raw), but it is also used as a text_expander callback in two places that need full expansion:

1. src/kimi_cli/ui/shell/__init__.py:1399 - ApprovalPromptDelegate text_expander
2. src/kimi_cli/ui/shell/visualize/_interactive.py:514 - QuestionPromptDelegate text_expander

Both of these call sites should use expand_for_editor instead of serialize_for_history as the text_expander, since expand_for_editor always expands text placeholders (both legacy and persisted) to their full content while keeping image tokens as-is. Change both sites from:
  text_expander=self._prompt_session._get_placeholder_manager().serialize_for_history
to:
  text_expander=self._prompt_session._get_placeholder_manager().expand_for_editor
Open in Devin Review

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks, fixed in 4b63493d.

Both ApprovalPromptDelegate and QuestionPromptDelegate are now wired to expand_for_editor for modal submissions, so persisted pasted-text placeholders are expanded before reaching the model. I also added regression tests covering persisted tokens in approval feedback and question "Other" answers.

return self.expand_text(match)

def expand_for_editor(self, match: PlaceholderTokenMatch) -> str | None:
Expand Down Expand Up @@ -335,6 +381,18 @@ def refold_after_editor(self, edited_text: str, original_command: str) -> str:
result = result[:start] + token + result[end:]
return result

@staticmethod
def _find_earliest_text_match(text: str, start: int) -> re.Match[str] | None:
legacy_match = _PASTED_TEXT_PLACEHOLDER_RE.search(text, start)
persisted_match = _PERSISTED_PASTED_TEXT_PLACEHOLDER_RE.search(text, start)
if legacy_match is None:
return persisted_match
if persisted_match is None:
return legacy_match
if persisted_match.start() < legacy_match.start():
return persisted_match
return legacy_match

def _expanded_text_and_intervals(
self, command: str
) -> tuple[str, list[tuple[int, int, str, str]]]:
Expand Down Expand Up @@ -435,7 +493,7 @@ class ResolvedPromptCommand:
class PromptPlaceholderManager:
def __init__(self, attachment_cache: AttachmentCache | None = None) -> None:
self._attachment_cache = attachment_cache or AttachmentCache()
self._text_handler = PastedTextPlaceholderHandler()
self._text_handler = PastedTextPlaceholderHandler(self._attachment_cache)
self._image_handler = ImagePlaceholderHandler(self._attachment_cache)
self._handlers: tuple[PlaceholderHandler, ...] = (
self._text_handler,
Expand Down
2 changes: 1 addition & 1 deletion src/kimi_cli/ui/shell/visualize/_interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -511,7 +511,7 @@ def _on_question_panel_state_changed(self) -> None:
on_advance=self._advance_question,
on_invalidate=self._flush_prompt_refresh,
buffer_text_provider=lambda: self._prompt_session._session.default_buffer.text, # pyright: ignore[reportPrivateUsage]
text_expander=self._prompt_session._get_placeholder_manager().serialize_for_history, # pyright: ignore[reportPrivateUsage]
text_expander=self._prompt_session._get_placeholder_manager().expand_for_editor, # pyright: ignore[reportPrivateUsage]
)
self._prompt_session.attach_modal(self._question_modal)
else:
Expand Down
6 changes: 5 additions & 1 deletion tests/ui_and_conv/test_modal_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,16 @@


class _FakePlaceholderManager:
"""Minimal placeholder manager stub — serialize_for_history is identity."""
"""Minimal placeholder manager stub — expansion helpers are identity."""

@staticmethod
def serialize_for_history(text: str) -> str:
return text

@staticmethod
def expand_for_editor(text: str) -> str:
return text


def _make_approval_request(request_id: str = "req-1", **kwargs: Any) -> ApprovalRequest:
defaults = {
Expand Down
128 changes: 126 additions & 2 deletions tests/ui_and_conv/test_prompt_clipboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,24 @@ def invalidate(self) -> None:
class _FakeAttachmentCache(shell_prompt.AttachmentCache):
def __init__(self, store_result: shell_prompt.CachedAttachment | None) -> None:
self.store_result = store_result
self.stored_text: str | None = None

def store_image(self, image: Image.Image) -> shell_prompt.CachedAttachment | None:
return self.store_result

def store_pasted_text(self, text: str) -> shell_prompt.CachedAttachment | None:
self.stored_text = text
return shell_prompt.CachedAttachment(
kind="pasted_text",
attachment_id="paste123.txt",
path=Path("/tmp/paste123.txt"),
)

def load_pasted_text(self, attachment_id: str) -> str | None:
if attachment_id == "paste123.txt":
return self.stored_text
return None


def _make_prompt_session(
mode: PromptMode, *, supports_image: bool = True
Expand Down Expand Up @@ -279,7 +293,7 @@ def test_insert_pasted_text_placeholderizes_long_text_in_agent_mode() -> None:

assert len(buffer.inserted) == 1
inserted = buffer.inserted[0]
assert inserted == "[Pasted text #1 +15 lines]"
assert inserted == "[Pasted text:paste123.txt +15 lines]"

user_input = ps._build_user_input(inserted)
assert user_input.command == inserted
Expand Down Expand Up @@ -321,7 +335,7 @@ def test_handle_bracketed_paste_placeholderizes_long_text_in_agent_mode() -> Non

ps._handle_bracketed_paste(cast(KeyPressEvent, event))

assert buffer.inserted == ["[Pasted text #1 +15 lines]"]
assert buffer.inserted == ["[Pasted text:paste123.txt +15 lines]"]
assert app.invalidated is True
resolved_text = "\n".join([f"line{i}" for i in range(1, 16)])
user_input = ps._build_user_input(buffer.inserted[0])
Expand Down Expand Up @@ -391,3 +405,113 @@ def capture_submit_other(text: str) -> bool:
assert expand_calls[0] == "prefix [Pasted text #1 +19 lines]"
assert len(submitted_texts) == 1
assert full_text in submitted_texts[0]


async def test_question_delegate_expands_persisted_pasted_text() -> None:
"""Cache-backed pasted-text tokens submitted via the question 'Other' field
must reach the model as full text.

The modal wires the manager's ``expand_for_editor`` callback. Using
``serialize_for_history`` would leave persisted tokens folded and send
``[Pasted text:...]`` to the model verbatim."""
from unittest.mock import patch

from kimi_cli.ui.shell.placeholders import (
PromptPlaceholderManager,
build_persisted_pasted_text_placeholder,
)
from kimi_cli.ui.shell.visualize import QuestionPromptDelegate, QuestionRequestPanel
from kimi_cli.wire.types import QuestionItem, QuestionRequest

full_text = "\n".join(f"line{i}" for i in range(1, 20))
cache = _FakeAttachmentCache(None)
cached = cache.store_pasted_text(full_text)
assert cached is not None
manager = PromptPlaceholderManager(cache)
token = build_persisted_pasted_text_placeholder(cached.attachment_id, full_text)

# Only expand_for_editor restores the payload; serialize_for_history keeps
# the compact token (correct for history, wrong for modal submission).
assert token == "[Pasted text:paste123.txt +19 lines]"
assert manager.serialize_for_history(token) == token
assert manager.expand_for_editor(token) == full_text

question = QuestionItem(question="Review?", options=[], other_label="Revise")
request = QuestionRequest(id="q1", tool_call_id="tc1", questions=[question])
panel = QuestionRequestPanel(request)
delegate = QuestionPromptDelegate(
panel,
on_advance=lambda: None,
on_invalidate=lambda: None,
text_expander=manager.expand_for_editor,
)
panel.select_index(len(panel._options) - 1)

submitted_texts: list[str] = []
original_submit_other = panel.submit_other

def capture_submit_other(text: str) -> bool:
submitted_texts.append(text)
return original_submit_other(text)

buffer = _DummyBuffer()
buffer.text = f"prefix {token}" # type: ignore[attr-defined]
buffer.set_document = lambda *a, **kw: None # type: ignore[attr-defined]

with patch.object(panel, "submit_other", capture_submit_other):
delegate._submit_other_input(cast("Buffer", buffer))

assert len(submitted_texts) == 1
assert full_text in submitted_texts[0]
assert token not in submitted_texts[0]


async def test_approval_delegate_expands_persisted_pasted_text_feedback() -> None:
"""Cache-backed pasted-text tokens typed into reject feedback must be
expanded to full text before reaching the model."""
from prompt_toolkit.buffer import Buffer as PTBuffer
from prompt_toolkit.document import Document

from kimi_cli.ui.shell.placeholders import (
PromptPlaceholderManager,
build_persisted_pasted_text_placeholder,
)
from kimi_cli.ui.shell.visualize import ApprovalPromptDelegate
from kimi_cli.wire.types import ApprovalRequest

full_text = "\n".join(f"line{i}" for i in range(1, 20))
cache = _FakeAttachmentCache(None)
cached = cache.store_pasted_text(full_text)
assert cached is not None
manager = PromptPlaceholderManager(cache)
token = build_persisted_pasted_text_placeholder(cached.attachment_id, full_text)
assert token == "[Pasted text:paste123.txt +19 lines]"
assert manager.serialize_for_history(token) == token
assert manager.expand_for_editor(token) == full_text

request = ApprovalRequest(
id="req-1",
tool_call_id="call-1",
sender="Shell",
action="run command",
description="echo hello",
)
buf = PTBuffer()
responses: list[tuple[str, str, str]] = []
delegate = ApprovalPromptDelegate(
request,
on_response=lambda req, resp, feedback="": responses.append((req.id, resp, feedback)),
buffer_state_provider=lambda: (buf.text, buf.cursor_position),
text_expander=manager.expand_for_editor,
)
delegate._panel.selected_index = 3 # "Reject + feedback" enables inline input

feedback = f"please fix {token}"
buf.set_document(Document(text=feedback, cursor_position=len(feedback)), bypass_readonly=True)
event = type("_Event", (), {"current_buffer": buf})()
delegate.handle_running_prompt_key("enter", event)

assert len(responses) == 1
assert responses[0][1] == "reject"
assert full_text in responses[0][2]
assert token not in responses[0][2]
Loading