Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
39 changes: 39 additions & 0 deletions src/kimi_cli/ui/shell/placeholders.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,12 @@ class PlaceholderTokenMatch:
match: re.Match[str]


@dataclass(slots=True, frozen=True)
class PlaceholderSpan:
start: int
end: int


class PlaceholderHandler(Protocol):
def find_next(self, text: str, start: int = 0) -> PlaceholderTokenMatch | None: ...

Expand Down Expand Up @@ -441,6 +447,9 @@ def __init__(self, attachment_cache: AttachmentCache | None = None) -> None:
self._text_handler,
self._image_handler,
)
self._span_index_text: str | None = None
self._span_by_start: dict[int, PlaceholderSpan] = {}
self._span_by_end: dict[int, PlaceholderSpan] = {}

@property
def attachment_cache(self) -> AttachmentCache:
Expand All @@ -452,6 +461,36 @@ def maybe_placeholderize_pasted_text(self, text: str) -> str:
def create_image_placeholder(self, image: Image.Image) -> str | None:
return self._image_handler.create_placeholder(image)

def find_placeholder_ending_at(self, text: str, cursor_position: int) -> PlaceholderSpan | None:
self._ensure_span_index(text)
return self._span_by_end.get(cursor_position)

def find_placeholder_starting_at(
self, text: str, cursor_position: int
) -> PlaceholderSpan | None:
self._ensure_span_index(text)
return self._span_by_start.get(cursor_position)

def find_selected_placeholder(self, text: str, start: int, end: int) -> PlaceholderSpan | None:
self._ensure_span_index(text)
span = self._span_by_start.get(start)
return span if span is not None and span.end == end else None

def _ensure_span_index(self, text: str) -> None:
if text == self._span_index_text:
return
self._span_index_text = text
self._span_by_start.clear()
self._span_by_end.clear()
if "[" not in text:
return
cursor = 0
while match := self._find_next_match(text, cursor):
span = PlaceholderSpan(start=match.start, end=match.end)
self._span_by_start[match.start] = span
self._span_by_end[match.end] = span
cursor = match.end

def resolve_command(self, command: str) -> ResolvedPromptCommand:
content: list[ContentPart] = []
resolved_chunks: list[str] = []
Expand Down
147 changes: 147 additions & 0 deletions src/kimi_cli/ui/shell/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
from kimi_cli.ui.shell import placeholders as prompt_placeholders
from kimi_cli.ui.shell.console import console
from kimi_cli.ui.shell.placeholders import (
PlaceholderSpan,
PromptPlaceholderManager,
normalize_pasted_text,
sanitize_surrogates,
Expand Down Expand Up @@ -1453,6 +1454,30 @@ def _(event: KeyPressEvent) -> None:
def _(event: KeyPressEvent) -> None:
self._handle_running_prompt_key("6", event)

@_kb.add(
Keys.Backspace,
eager=True,
filter=Condition(self._should_handle_placeholder_backspace),
)
def _(event: KeyPressEvent) -> None:
self._handle_placeholder_backspace(event)

@_kb.add(
Keys.Delete,
eager=True,
filter=Condition(self._should_handle_placeholder_delete),
)
def _(event: KeyPressEvent) -> None:
self._handle_placeholder_delete(event)

@_kb.add("left", eager=True, filter=Condition(self._should_handle_placeholder_left))
def _(event: KeyPressEvent) -> None:
self._handle_placeholder_left(event)

@_kb.add("right", eager=True, filter=Condition(self._should_handle_placeholder_right))
def _(event: KeyPressEvent) -> None:
self._handle_placeholder_right(event)

@_kb.add(Keys.BracketedPaste, eager=True)
def _(event: KeyPressEvent) -> None:
self._handle_bracketed_paste(event)
Expand Down Expand Up @@ -1860,6 +1885,128 @@ def _get_placeholder_manager(self) -> PromptPlaceholderManager:
self._attachment_cache = manager.attachment_cache
return manager

def _placeholder_editing_enabled(self) -> bool:
return self._mode == PromptMode.AGENT and self._active_prompt_delegate() is None

def _active_edit_buffer(self) -> Buffer | None:
if not self._placeholder_editing_enabled() or not hasattr(self, "_session"):
return None
return self._session.default_buffer

def _selected_placeholder_span(self, buffer: Buffer) -> PlaceholderSpan | None:
if buffer.selection_state is None:
return None
start, end = buffer.document.selection_range()
return self._get_placeholder_manager().find_selected_placeholder(buffer.text, start, end)

def _has_non_placeholder_selection(self, buffer: Buffer) -> bool:
return (
buffer.selection_state is not None and self._selected_placeholder_span(buffer) is None
)

def _select_placeholder_span(
self, buffer: Buffer, span: PlaceholderSpan, *, enter_from_left: bool
) -> None:
buffer.exit_selection()
buffer.cursor_position = span.start if enter_from_left else span.end
buffer.start_selection()
buffer.cursor_position = span.end if enter_from_left else span.start

def _collapse_selected_placeholder(self, buffer: Buffer, *, to_right: bool) -> None:
span = self._selected_placeholder_span(buffer)
if span is None:
return
buffer.exit_selection()
buffer.cursor_position = span.end if to_right else span.start

def _placeholder_span_at_boundary(
self, buffer: Buffer, *, boundary: Literal["start", "end"]
) -> PlaceholderSpan | None:
manager = self._get_placeholder_manager()
if boundary == "start":
return manager.find_placeholder_starting_at(buffer.text, buffer.cursor_position)
return manager.find_placeholder_ending_at(buffer.text, buffer.cursor_position)

def _should_handle_placeholder_key(self, *, boundary: Literal["start", "end"]) -> bool:
buffer = self._active_edit_buffer()
if buffer is None:
return False
if self._has_non_placeholder_selection(buffer):
return False
if self._selected_placeholder_span(buffer) is not None:
return True
return self._placeholder_span_at_boundary(buffer, boundary=boundary) is not None

def _handle_placeholder_key(
self,
event: KeyPressEvent,
*,
boundary: Literal["start", "end"],
selected_action: Literal["delete", "collapse_left", "collapse_right"],
enter_from_left: bool,
) -> None:
buffer = event.current_buffer
if self._has_non_placeholder_selection(buffer):
return
selected_span = self._selected_placeholder_span(buffer)
if selected_span is not None:
match selected_action:
case "delete":
buffer.cut_selection()
case "collapse_left":
self._collapse_selected_placeholder(buffer, to_right=False)
case "collapse_right":
self._collapse_selected_placeholder(buffer, to_right=True)
else:
span = self._placeholder_span_at_boundary(buffer, boundary=boundary)
if span is not None:
self._select_placeholder_span(buffer, span, enter_from_left=enter_from_left)
event.app.invalidate()

def _should_handle_placeholder_backspace(self) -> bool:
return self._should_handle_placeholder_key(boundary="end")

def _handle_placeholder_backspace(self, event: KeyPressEvent) -> None:
self._handle_placeholder_key(
event,
boundary="end",
selected_action="delete",
enter_from_left=False,
)

def _should_handle_placeholder_delete(self) -> bool:
return self._should_handle_placeholder_key(boundary="start")

def _handle_placeholder_delete(self, event: KeyPressEvent) -> None:
self._handle_placeholder_key(
event,
boundary="start",
selected_action="delete",
enter_from_left=True,
)

def _should_handle_placeholder_left(self) -> bool:
return self._should_handle_placeholder_key(boundary="end")

def _handle_placeholder_left(self, event: KeyPressEvent) -> None:
self._handle_placeholder_key(
event,
boundary="end",
selected_action="collapse_left",
enter_from_left=False,
)

def _should_handle_placeholder_right(self) -> bool:
return self._should_handle_placeholder_key(boundary="start")

def _handle_placeholder_right(self, event: KeyPressEvent) -> None:
self._handle_placeholder_key(
event,
boundary="start",
selected_action="collapse_right",
enter_from_left=True,
)

def _insert_pasted_text(self, buffer: Buffer, text: str) -> None:
normalized = normalize_pasted_text(text)
if self._mode != PromptMode.AGENT:
Expand Down
Loading
Loading