From 66dbefa8c70c038733b5a2543b3a090f47991cf4 Mon Sep 17 00:00:00 2001 From: bigcat Date: Tue, 4 Nov 2025 15:10:59 +0800 Subject: [PATCH 1/5] feat: preview change --- src/kimi_cli/soul/agent.py | 2 ++ src/kimi_cli/soul/kimisoul.py | 8 +++++ src/kimi_cli/soul/preview.py | 52 ++++++++++++++++++++++++++++++ src/kimi_cli/soul/runtime.py | 3 ++ src/kimi_cli/tools/file/patch.py | 16 ++++++++- src/kimi_cli/tools/file/replace.py | 28 ++++++++++------ src/kimi_cli/tools/file/write.py | 12 ++++++- src/kimi_cli/ui/shell/liveview.py | 35 +++++++++++++++++++- src/kimi_cli/ui/shell/visualize.py | 3 ++ src/kimi_cli/wire/message.py | 13 +++++++- tests/conftest.py | 25 +++++++++----- 11 files changed, 176 insertions(+), 21 deletions(-) create mode 100644 src/kimi_cli/soul/preview.py diff --git a/src/kimi_cli/soul/agent.py b/src/kimi_cli/soul/agent.py index 7156eadc..4261ca49 100644 --- a/src/kimi_cli/soul/agent.py +++ b/src/kimi_cli/soul/agent.py @@ -11,6 +11,7 @@ from kimi_cli.session import Session from kimi_cli.soul.approval import Approval from kimi_cli.soul.denwarenji import DenwaRenji +from kimi_cli.soul.preview import Preview from kimi_cli.soul.runtime import BuiltinSystemPromptArgs, Runtime from kimi_cli.soul.toolset import CustomToolset from kimi_cli.utils.logging import logger @@ -54,6 +55,7 @@ async def load_agent( Session: runtime.session, DenwaRenji: runtime.denwa_renji, Approval: runtime.approval, + Preview: runtime.preview, } tools = agent_spec.tools if agent_spec.exclude_tools: diff --git a/src/kimi_cli/soul/kimisoul.py b/src/kimi_cli/soul/kimisoul.py index 330630ce..d69c363b 100644 --- a/src/kimi_cli/soul/kimisoul.py +++ b/src/kimi_cli/soul/kimisoul.py @@ -65,6 +65,7 @@ def __init__( self._runtime = runtime self._denwa_renji = runtime.denwa_renji self._approval = runtime.approval + self._preview = runtime.preview self._context = context self._loop_control = runtime.config.loop_control self._compaction = SimpleCompaction() # TODO: maybe configurable and composable @@ -129,10 +130,16 @@ async def _pipe_approval_to_wire(): request = await self._approval.fetch_request() wire_send(request) + async def _pipe_preview_to_wire(): + while True: + request = await self._preview.fetch_request() + wire_send(request) + step_no = 1 while True: wire_send(StepBegin(step_no)) approval_task = asyncio.create_task(_pipe_approval_to_wire()) + preview_task = asyncio.create_task(_pipe_preview_to_wire()) # FIXME: It's possible that a subagent's approval task steals approval request # from the main agent. We must ensure that the Task tool will redirect them # to the main wire. See `_SubWire` for more details. Later we need to figure @@ -163,6 +170,7 @@ async def _pipe_approval_to_wire(): raise finally: approval_task.cancel() # stop piping approval requests to the wire + preview_task.cancel() # stop piping preview requests to the wire if finished: return diff --git a/src/kimi_cli/soul/preview.py b/src/kimi_cli/soul/preview.py new file mode 100644 index 00000000..979c1fcd --- /dev/null +++ b/src/kimi_cli/soul/preview.py @@ -0,0 +1,52 @@ +import asyncio +import difflib + +from pygments.lexers import get_lexer_for_filename + +from kimi_cli.wire.message import PreviewRequest + + +class Preview: + def __init__(self): + self._preview_queue = asyncio.Queue[PreviewRequest]() + + async def get_lexer(self, file_path: str): + try: + lexer = get_lexer_for_filename(file_path) + return lexer.name.lower() + except Exception: + return "text" + + async def preview_text(self, file_path: str, content: str, content_type: str = ""): + title = file_path + if not content_type: + content_type = await self.get_lexer(file_path) + self._preview_queue.put_nowait(PreviewRequest(title, content, content_type)) + + async def preview_diff(self, file_path: str, before: str, after: str): + diff = difflib.unified_diff( + before.splitlines(keepends=True), + after.splitlines(keepends=True), + fromfile=file_path + ) + + breaker = "" + # ignore redundant lines + while not breaker.startswith("@@"): + breaker = next(diff) + + code = "" + delta = ["+", "-"] + for line in diff: + line = f"{line[0]} {line[1:]}" if line[0] in delta else f" {line}" + code += line + + title = f"Edit {file_path}" + self._preview_queue.put_nowait(PreviewRequest(title, code, "diff")) + + + async def fetch_request(self) -> PreviewRequest: + """ + Fetch an approval request from the queue. Intended to be called by the soul. + """ + return await self._preview_queue.get() \ No newline at end of file diff --git a/src/kimi_cli/soul/runtime.py b/src/kimi_cli/soul/runtime.py index d26df051..4db364aa 100644 --- a/src/kimi_cli/soul/runtime.py +++ b/src/kimi_cli/soul/runtime.py @@ -10,6 +10,7 @@ from kimi_cli.session import Session from kimi_cli.soul.approval import Approval from kimi_cli.soul.denwarenji import DenwaRenji +from kimi_cli.soul.preview import Preview from kimi_cli.utils.logging import logger @@ -68,6 +69,7 @@ class Runtime(NamedTuple): builtin_args: BuiltinSystemPromptArgs denwa_renji: DenwaRenji approval: Approval + preview: Preview @staticmethod async def create( @@ -93,4 +95,5 @@ async def create( ), denwa_renji=DenwaRenji(), approval=Approval(yolo=yolo), + preview=Preview(), ) diff --git a/src/kimi_cli/tools/file/patch.py b/src/kimi_cli/tools/file/patch.py index cb2aaaa5..6496f234 100644 --- a/src/kimi_cli/tools/file/patch.py +++ b/src/kimi_cli/tools/file/patch.py @@ -7,6 +7,7 @@ from pydantic import BaseModel, Field from kimi_cli.soul.approval import Approval +from kimi_cli.soul.preview import Preview from kimi_cli.soul.runtime import BuiltinSystemPromptArgs from kimi_cli.tools.file import FileActions from kimi_cli.tools.utils import ToolRejectedError @@ -22,10 +23,17 @@ class PatchFile(CallableTool2[Params]): description: str = (Path(__file__).parent / "patch.md").read_text(encoding="utf-8") params: type[Params] = Params - def __init__(self, builtin_args: BuiltinSystemPromptArgs, approval: Approval, **kwargs): + def __init__( + self, + builtin_args: BuiltinSystemPromptArgs, + approval: Approval, + preview: Preview, + **kwargs + ): super().__init__(**kwargs) self._work_dir = builtin_args.KIMI_WORK_DIR self._approval = approval + self._preview = preview def _validate_path(self, path: Path) -> ToolError | None: """Validate that the path is safe to patch.""" @@ -73,6 +81,12 @@ async def __call__(self, params: Params) -> ToolReturnType: message=f"`{params.path}` is not a file.", brief="Invalid path", ) + + await self._preview.preview_text( + f"Patch file `{params.path}`", + params.diff, + "diff", + ) # Request approval if not await self._approval.request( diff --git a/src/kimi_cli/tools/file/replace.py b/src/kimi_cli/tools/file/replace.py index 503ebf26..c4e513e1 100644 --- a/src/kimi_cli/tools/file/replace.py +++ b/src/kimi_cli/tools/file/replace.py @@ -6,6 +6,7 @@ from pydantic import BaseModel, Field from kimi_cli.soul.approval import Approval +from kimi_cli.soul.preview import Preview from kimi_cli.soul.runtime import BuiltinSystemPromptArgs from kimi_cli.tools.file import FileActions from kimi_cli.tools.utils import ToolRejectedError @@ -32,10 +33,17 @@ class StrReplaceFile(CallableTool2[Params]): description: str = (Path(__file__).parent / "replace.md").read_text(encoding="utf-8") params: type[Params] = Params - def __init__(self, builtin_args: BuiltinSystemPromptArgs, approval: Approval, **kwargs): + def __init__( + self, + builtin_args: BuiltinSystemPromptArgs, + approval: Approval, + preview: Preview, + **kwargs + ): super().__init__(**kwargs) self._work_dir = builtin_args.KIMI_WORK_DIR self._approval = approval + self._preview = preview def _validate_path(self, path: Path) -> ToolError | None: """Validate that the path is safe to edit.""" @@ -91,14 +99,6 @@ async def __call__(self, params: Params) -> ToolReturnType: brief="Invalid path", ) - # Request approval - if not await self._approval.request( - self.name, - FileActions.EDIT, - f"Edit file `{params.path}`", - ): - return ToolRejectedError() - # Read the file content async with aiofiles.open(p, encoding="utf-8", errors="replace") as f: content = await f.read() @@ -116,6 +116,16 @@ async def __call__(self, params: Params) -> ToolReturnType: message="No replacements were made. The old string was not found in the file.", brief="No replacements made", ) + + await self._preview.preview_diff(params.path, original_content, content) + + # Request approval + if not await self._approval.request( + self.name, + FileActions.EDIT, + f"Edit file `{params.path}`", + ): + return ToolRejectedError() # Write the modified content back to the file async with aiofiles.open(p, mode="w", encoding="utf-8") as f: diff --git a/src/kimi_cli/tools/file/write.py b/src/kimi_cli/tools/file/write.py index c4d26bb7..b76ec3bc 100644 --- a/src/kimi_cli/tools/file/write.py +++ b/src/kimi_cli/tools/file/write.py @@ -6,6 +6,7 @@ from pydantic import BaseModel, Field from kimi_cli.soul.approval import Approval +from kimi_cli.soul.preview import Preview from kimi_cli.soul.runtime import BuiltinSystemPromptArgs from kimi_cli.tools.file import FileActions from kimi_cli.tools.utils import ToolRejectedError @@ -29,10 +30,17 @@ class WriteFile(CallableTool2[Params]): description: str = (Path(__file__).parent / "write.md").read_text(encoding="utf-8") params: type[Params] = Params - def __init__(self, builtin_args: BuiltinSystemPromptArgs, approval: Approval, **kwargs): + def __init__( + self, + builtin_args: BuiltinSystemPromptArgs, + approval: Approval, preview: + Preview, + **kwargs + ): super().__init__(**kwargs) self._work_dir = builtin_args.KIMI_WORK_DIR self._approval = approval + self._preview = preview def _validate_path(self, path: Path) -> ToolError | None: """Validate that the path is safe to write.""" @@ -89,6 +97,8 @@ async def __call__(self, params: Params) -> ToolReturnType: brief="Invalid write mode", ) + await self._preview.preview_text(params.path, params.content) + # Request approval if not await self._approval.request( self.name, diff --git a/src/kimi_cli/ui/shell/liveview.py b/src/kimi_cli/ui/shell/liveview.py index 082b5a7b..f72637f5 100644 --- a/src/kimi_cli/ui/shell/liveview.py +++ b/src/kimi_cli/ui/shell/liveview.py @@ -13,13 +13,14 @@ from rich.panel import Panel from rich.spinner import Spinner from rich.status import Status +from rich.syntax import Syntax from rich.text import Text from kimi_cli.soul import StatusSnapshot from kimi_cli.tools import extract_subtitle from kimi_cli.ui.shell.console import console from kimi_cli.ui.shell.keyboard import KeyEvent -from kimi_cli.wire.message import ApprovalRequest, ApprovalResponse +from kimi_cli.wire.message import ApprovalRequest, ApprovalResponse, PreviewRequest class _ToolCallDisplay: @@ -213,6 +214,38 @@ def append_text(self, text: str, mode: Literal["text", "think"] = "text"): if (prev_is_empty and self._line_buffer) or (not prev_is_empty and not self._line_buffer): self._live.update(self._compose()) + def append_preview(self, msg: PreviewRequest): + MAX_TITLE_LENGTH = 70 + content_type = msg.content_type + if content_type == "markdown": + body = _LeftAlignedMarkdown( + msg.content, + justify="left", + style="grey50 italic" if self._last_text_mode == "think" else "none", + ) + elif content_type in {"text", "text only"}: + body = Text(msg.content) + else: + body = Syntax( + msg.content, + content_type, + theme="monokai", + line_numbers=True, + ) + + width = int(console.width * 0.8) + title = msg.title + if len(title) > MAX_TITLE_LENGTH: + title = "..." + title[-MAX_TITLE_LENGTH:] + + panel = Panel( + body, + title=title, + border_style="wheat4", + width=width, + ) + self._push_out(panel) + def append_tool_call(self, tool_call: ToolCall): self._tool_calls[tool_call.id] = _ToolCallDisplay(tool_call) self._last_tool_call = self._tool_calls[tool_call.id] diff --git a/src/kimi_cli/ui/shell/visualize.py b/src/kimi_cli/ui/shell/visualize.py index ab389b63..13859036 100644 --- a/src/kimi_cli/ui/shell/visualize.py +++ b/src/kimi_cli/ui/shell/visualize.py @@ -13,6 +13,7 @@ ApprovalRequest, CompactionBegin, CompactionEnd, + PreviewRequest, StatusUpdate, StepBegin, StepInterrupted, @@ -94,6 +95,8 @@ async def visualize( step.append_tool_result(msg) case ApprovalRequest(): step.request_approval(msg) + case PreviewRequest(): + step.append_preview(msg) case StatusUpdate(status=status): latest_status = status step.update_status(latest_status) diff --git a/src/kimi_cli/wire/message.py b/src/kimi_cli/wire/message.py index 02720a15..b6038360 100644 --- a/src/kimi_cli/wire/message.py +++ b/src/kimi_cli/wire/message.py @@ -87,5 +87,16 @@ def resolved(self) -> bool: """Whether the request is resolved.""" return self._future.done() +class PreviewType(Enum): + DIFF = "diff" + TEXT = "text" -type WireMessage = Event | ApprovalRequest +class PreviewRequest: + def __init__(self, title: str, content: str, content_type: str = "markdown"): + self.id = str(uuid.uuid4()) + self.title = title + self.content = content + self.content_type = content_type + + +type WireMessage = Event | ApprovalRequest | PreviewRequest diff --git a/tests/conftest.py b/tests/conftest.py index 8c68d337..25ec624d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,6 +14,7 @@ from kimi_cli.session import Session from kimi_cli.soul.approval import Approval from kimi_cli.soul.denwarenji import DenwaRenji +from kimi_cli.soul.preview import Preview from kimi_cli.soul.runtime import BuiltinSystemPromptArgs, Runtime from kimi_cli.tools.bash import Bash from kimi_cli.tools.dmail import SendDMail @@ -93,23 +94,31 @@ def approval() -> Approval: return Approval(yolo=True) +@pytest.fixture +def preview() -> Preview: + """Create a Preview instance.""" + return Preview() + + @pytest.fixture def runtime( config: Config, llm: LLM, + session: Session, builtin_args: BuiltinSystemPromptArgs, denwa_renji: DenwaRenji, - session: Session, approval: Approval, + preview: Preview, ) -> Runtime: """Create a Runtime instance.""" return Runtime( config=config, llm=llm, + session=session, builtin_args=builtin_args, denwa_renji=denwa_renji, - session=session, approval=approval, + preview=preview, ) @@ -186,29 +195,29 @@ def grep_tool() -> Grep: @pytest.fixture def write_file_tool( - builtin_args: BuiltinSystemPromptArgs, approval: Approval + builtin_args: BuiltinSystemPromptArgs, approval: Approval, preview: Preview, ) -> Generator[WriteFile]: """Create a WriteFile tool instance.""" with tool_call_context("WriteFile"): - yield WriteFile(builtin_args, approval) + yield WriteFile(builtin_args, approval, preview) @pytest.fixture def str_replace_file_tool( - builtin_args: BuiltinSystemPromptArgs, approval: Approval + builtin_args: BuiltinSystemPromptArgs, approval: Approval, preview: Preview, ) -> Generator[StrReplaceFile]: """Create a StrReplaceFile tool instance.""" with tool_call_context("StrReplaceFile"): - yield StrReplaceFile(builtin_args, approval) + yield StrReplaceFile(builtin_args, approval, preview) @pytest.fixture def patch_file_tool( - builtin_args: BuiltinSystemPromptArgs, approval: Approval + builtin_args: BuiltinSystemPromptArgs, approval: Approval, preview: Preview, ) -> Generator[PatchFile]: """Create a PatchFile tool instance.""" with tool_call_context("PatchFile"): - yield PatchFile(builtin_args, approval) + yield PatchFile(builtin_args, approval, preview) @pytest.fixture From 38f412ba843186de1736c15a5552c24b83b3e12d Mon Sep 17 00:00:00 2001 From: bigcat Date: Tue, 4 Nov 2025 18:56:52 +0800 Subject: [PATCH 2/5] Fix: resolve integration issues with new upstream feature --- src/kimi_cli/soul/preview.py | 19 ++++++++----------- src/kimi_cli/tools/file/patch.py | 12 ++++++------ src/kimi_cli/tools/file/replace.py | 12 ++++++------ src/kimi_cli/tools/file/write.py | 10 +++++----- src/kimi_cli/ui/shell/liveview.py | 12 ++++++------ src/kimi_cli/ui/shell/visualize.py | 4 ++-- src/kimi_cli/wire/message.py | 22 +++++++++++++++++++--- tests/conftest.py | 12 +++++++++--- 8 files changed, 61 insertions(+), 42 deletions(-) diff --git a/src/kimi_cli/soul/preview.py b/src/kimi_cli/soul/preview.py index 979c1fcd..38c889be 100644 --- a/src/kimi_cli/soul/preview.py +++ b/src/kimi_cli/soul/preview.py @@ -3,12 +3,12 @@ from pygments.lexers import get_lexer_for_filename -from kimi_cli.wire.message import PreviewRequest +from kimi_cli.wire.message import PreviewChange class Preview: def __init__(self): - self._preview_queue = asyncio.Queue[PreviewRequest]() + self._preview_queue = asyncio.Queue[PreviewChange]() async def get_lexer(self, file_path: str): try: @@ -21,13 +21,11 @@ async def preview_text(self, file_path: str, content: str, content_type: str = " title = file_path if not content_type: content_type = await self.get_lexer(file_path) - self._preview_queue.put_nowait(PreviewRequest(title, content, content_type)) + self._preview_queue.put_nowait(PreviewChange(title, content, content_type)) async def preview_diff(self, file_path: str, before: str, after: str): diff = difflib.unified_diff( - before.splitlines(keepends=True), - after.splitlines(keepends=True), - fromfile=file_path + before.splitlines(keepends=True), after.splitlines(keepends=True), fromfile=file_path ) breaker = "" @@ -40,13 +38,12 @@ async def preview_diff(self, file_path: str, before: str, after: str): for line in diff: line = f"{line[0]} {line[1:]}" if line[0] in delta else f" {line}" code += line - - title = f"Edit {file_path}" - self._preview_queue.put_nowait(PreviewRequest(title, code, "diff")) + title = f"Edit {file_path}" + self._preview_queue.put_nowait(PreviewChange(title, code, "diff")) - async def fetch_request(self) -> PreviewRequest: + async def fetch_request(self) -> PreviewChange: """ Fetch an approval request from the queue. Intended to be called by the soul. """ - return await self._preview_queue.get() \ No newline at end of file + return await self._preview_queue.get() diff --git a/src/kimi_cli/tools/file/patch.py b/src/kimi_cli/tools/file/patch.py index 906e5f92..bccf2685 100644 --- a/src/kimi_cli/tools/file/patch.py +++ b/src/kimi_cli/tools/file/patch.py @@ -54,11 +54,11 @@ class PatchFile(CallableTool2[Params]): params: type[Params] = Params def __init__( - self, - builtin_args: BuiltinSystemPromptArgs, - approval: Approval, - preview: Preview, - **kwargs: Any + self, + builtin_args: BuiltinSystemPromptArgs, + approval: Approval, + preview: Preview, + **kwargs: Any, ): super().__init__(**kwargs) self._work_dir = builtin_args.KIMI_WORK_DIR @@ -111,7 +111,7 @@ async def __call__(self, params: Params) -> ToolReturnType: message=f"`{params.path}` is not a file.", brief="Invalid path", ) - + await self._preview.preview_text( f"Patch file `{params.path}`", params.diff, diff --git a/src/kimi_cli/tools/file/replace.py b/src/kimi_cli/tools/file/replace.py index e50dc1ad..62b4eb36 100644 --- a/src/kimi_cli/tools/file/replace.py +++ b/src/kimi_cli/tools/file/replace.py @@ -34,11 +34,11 @@ class StrReplaceFile(CallableTool2[Params]): params: type[Params] = Params def __init__( - self, - builtin_args: BuiltinSystemPromptArgs, - approval: Approval, - preview: Preview, - **kwargs: Any + self, + builtin_args: BuiltinSystemPromptArgs, + approval: Approval, + preview: Preview, + **kwargs: Any, ): super().__init__(**kwargs) self._work_dir = builtin_args.KIMI_WORK_DIR @@ -116,7 +116,7 @@ async def __call__(self, params: Params) -> ToolReturnType: message="No replacements were made. The old string was not found in the file.", brief="No replacements made", ) - + await self._preview.preview_diff(params.path, original_content, content) # Request approval diff --git a/src/kimi_cli/tools/file/write.py b/src/kimi_cli/tools/file/write.py index 24c79088..4037fedb 100644 --- a/src/kimi_cli/tools/file/write.py +++ b/src/kimi_cli/tools/file/write.py @@ -31,11 +31,11 @@ class WriteFile(CallableTool2[Params]): params: type[Params] = Params def __init__( - self, - builtin_args: BuiltinSystemPromptArgs, - approval: Approval, - preview: Preview, - **kwargs: Any + self, + builtin_args: BuiltinSystemPromptArgs, + approval: Approval, + preview: Preview, + **kwargs: Any, ): super().__init__(**kwargs) self._work_dir = builtin_args.KIMI_WORK_DIR diff --git a/src/kimi_cli/ui/shell/liveview.py b/src/kimi_cli/ui/shell/liveview.py index f72637f5..f581a742 100644 --- a/src/kimi_cli/ui/shell/liveview.py +++ b/src/kimi_cli/ui/shell/liveview.py @@ -20,7 +20,7 @@ from kimi_cli.tools import extract_subtitle from kimi_cli.ui.shell.console import console from kimi_cli.ui.shell.keyboard import KeyEvent -from kimi_cli.wire.message import ApprovalRequest, ApprovalResponse, PreviewRequest +from kimi_cli.wire.message import ApprovalRequest, ApprovalResponse, PreviewChange class _ToolCallDisplay: @@ -214,7 +214,7 @@ def append_text(self, text: str, mode: Literal["text", "think"] = "text"): if (prev_is_empty and self._line_buffer) or (not prev_is_empty and not self._line_buffer): self._live.update(self._compose()) - def append_preview(self, msg: PreviewRequest): + def append_preview(self, msg: PreviewChange): MAX_TITLE_LENGTH = 70 content_type = msg.content_type if content_type == "markdown": @@ -227,9 +227,9 @@ def append_preview(self, msg: PreviewRequest): body = Text(msg.content) else: body = Syntax( - msg.content, - content_type, - theme="monokai", + msg.content, + content_type, + theme="monokai", line_numbers=True, ) @@ -237,7 +237,7 @@ def append_preview(self, msg: PreviewRequest): title = msg.title if len(title) > MAX_TITLE_LENGTH: title = "..." + title[-MAX_TITLE_LENGTH:] - + panel = Panel( body, title=title, diff --git a/src/kimi_cli/ui/shell/visualize.py b/src/kimi_cli/ui/shell/visualize.py index 13859036..8b17b305 100644 --- a/src/kimi_cli/ui/shell/visualize.py +++ b/src/kimi_cli/ui/shell/visualize.py @@ -13,7 +13,7 @@ ApprovalRequest, CompactionBegin, CompactionEnd, - PreviewRequest, + PreviewChange, StatusUpdate, StepBegin, StepInterrupted, @@ -95,7 +95,7 @@ async def visualize( step.append_tool_result(msg) case ApprovalRequest(): step.request_approval(msg) - case PreviewRequest(): + case PreviewChange(): step.append_preview(msg) case StatusUpdate(status=status): latest_status = status diff --git a/src/kimi_cli/wire/message.py b/src/kimi_cli/wire/message.py index 1db705d6..c229a1be 100644 --- a/src/kimi_cli/wire/message.py +++ b/src/kimi_cli/wire/message.py @@ -43,7 +43,7 @@ class StatusUpdate(NamedTuple): type ControlFlowEvent = StepBegin | StepInterrupted | CompactionBegin | CompactionEnd | StatusUpdate -type Event = ControlFlowEvent | ContentPart | ToolCall | ToolCallPart | ToolResult +type Event = ControlFlowEvent | ContentPart | ToolCall | ToolCallPart | ToolResult | PreviewChange class ApprovalResponse(Enum): @@ -88,11 +88,13 @@ def resolved(self) -> bool: """Whether the request is resolved.""" return self._future.done() + class PreviewType(Enum): DIFF = "diff" TEXT = "text" -class PreviewRequest: + +class PreviewChange: def __init__(self, title: str, content: str, content_type: str = "markdown"): self.id = str(uuid.uuid4()) self.title = title @@ -100,7 +102,7 @@ def __init__(self, title: str, content: str, content_type: str = "markdown"): self.content_type = content_type -type WireMessage = Event | ApprovalRequest | PreviewRequest +type WireMessage = Event | ApprovalRequest | PreviewChange def serialize_event(event: Event) -> dict[str, Any]: @@ -141,6 +143,11 @@ def serialize_event(event: Event) -> dict[str, Any]: "type": "tool_result", "payload": serialize_tool_result(event), } + case PreviewChange(): + return { + "type": "preview_request", + "payload": serialize_preview_request(event), + } def serialize_approval_request(request: ApprovalRequest) -> dict[str, Any]: @@ -187,3 +194,12 @@ def _serialize_tool_output( return output.model_dump(mode="json", exclude_none=True) else: # Sequence[ContentPart] return [part.model_dump(mode="json", exclude_none=True) for part in output] + + +def serialize_preview_request(request: PreviewChange) -> dict[str, Any]: + return { + "id": request.id, + "title": request.title, + "content": request.content, + "content_type": request.content_type, + } diff --git a/tests/conftest.py b/tests/conftest.py index fbc45347..1471844b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -195,7 +195,9 @@ def grep_tool() -> Grep: @pytest.fixture def write_file_tool( - builtin_args: BuiltinSystemPromptArgs, approval: Approval, preview: Preview, + builtin_args: BuiltinSystemPromptArgs, + approval: Approval, + preview: Preview, ) -> Generator[WriteFile]: """Create a WriteFile tool instance.""" with tool_call_context("WriteFile"): @@ -204,7 +206,9 @@ def write_file_tool( @pytest.fixture def str_replace_file_tool( - builtin_args: BuiltinSystemPromptArgs, approval: Approval, preview: Preview, + builtin_args: BuiltinSystemPromptArgs, + approval: Approval, + preview: Preview, ) -> Generator[StrReplaceFile]: """Create a StrReplaceFile tool instance.""" with tool_call_context("StrReplaceFile"): @@ -213,7 +217,9 @@ def str_replace_file_tool( @pytest.fixture def patch_file_tool( - builtin_args: BuiltinSystemPromptArgs, approval: Approval, preview: Preview, + builtin_args: BuiltinSystemPromptArgs, + approval: Approval, + preview: Preview, ) -> Generator[PatchFile]: """Create a PatchFile tool instance.""" with tool_call_context("PatchFile"): From 8ec5a424356882243d86ae466438b2c76dcdcc33 Mon Sep 17 00:00:00 2001 From: bigcat Date: Wed, 5 Nov 2025 18:58:10 +0800 Subject: [PATCH 3/5] fix: line number & pretty UI --- src/kimi_cli/soul/preview.py | 35 +++++++++------ src/kimi_cli/ui/shell/liveview.py | 72 ++++++++++++++++++++++++++++--- src/kimi_cli/wire/message.py | 10 +++-- 3 files changed, 96 insertions(+), 21 deletions(-) diff --git a/src/kimi_cli/soul/preview.py b/src/kimi_cli/soul/preview.py index 38c889be..bd0961c2 100644 --- a/src/kimi_cli/soul/preview.py +++ b/src/kimi_cli/soul/preview.py @@ -1,11 +1,27 @@ import asyncio import difflib +import re from pygments.lexers import get_lexer_for_filename from kimi_cli.wire.message import PreviewChange +def parse_diff_header(diff_line: str) -> tuple[int, int, int, int]: + pattern = r"@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@" + match = re.match(pattern, diff_line) + + if not match: + return (0, 0, 0, 0) + + old_start, old_lines, new_start, new_lines = match.groups() + + old_lines = int(old_lines) if old_lines else 1 + new_lines = int(new_lines) if new_lines else 1 + + return (int(old_start), old_lines, int(new_start), new_lines) + + class Preview: def __init__(self): self._preview_queue = asyncio.Queue[PreviewChange]() @@ -21,6 +37,7 @@ async def preview_text(self, file_path: str, content: str, content_type: str = " title = file_path if not content_type: content_type = await self.get_lexer(file_path) + self._preview_queue.put_nowait(PreviewChange(title, content, content_type)) async def preview_diff(self, file_path: str, before: str, after: str): @@ -28,19 +45,13 @@ async def preview_diff(self, file_path: str, before: str, after: str): before.splitlines(keepends=True), after.splitlines(keepends=True), fromfile=file_path ) - breaker = "" - # ignore redundant lines - while not breaker.startswith("@@"): - breaker = next(diff) - - code = "" - delta = ["+", "-"] - for line in diff: - line = f"{line[0]} {line[1:]}" if line[0] in delta else f" {line}" - code += line + # ignore --- +++ + next(diff) + next(diff) - title = f"Edit {file_path}" - self._preview_queue.put_nowait(PreviewChange(title, code, "diff")) + content = "".join(diff) + content_type = await self.get_lexer(file_path) + self._preview_queue.put_nowait(PreviewChange(file_path, content, content_type, "diff")) async def fetch_request(self) -> PreviewChange: """ diff --git a/src/kimi_cli/ui/shell/liveview.py b/src/kimi_cli/ui/shell/liveview.py index f581a742..114e7560 100644 --- a/src/kimi_cli/ui/shell/liveview.py +++ b/src/kimi_cli/ui/shell/liveview.py @@ -1,4 +1,5 @@ import asyncio +import re from collections import deque from typing import Literal @@ -11,9 +12,11 @@ from rich.markdown import Heading, Markdown from rich.markup import escape from rich.panel import Panel +from rich.rule import Rule from rich.spinner import Spinner from rich.status import Status from rich.syntax import Syntax +from rich.table import Table from rich.text import Text from kimi_cli.soul import StatusSnapshot @@ -215,7 +218,6 @@ def append_text(self, text: str, mode: Literal["text", "think"] = "text"): self._live.update(self._compose()) def append_preview(self, msg: PreviewChange): - MAX_TITLE_LENGTH = 70 content_type = msg.content_type if content_type == "markdown": body = _LeftAlignedMarkdown( @@ -225,22 +227,20 @@ def append_preview(self, msg: PreviewChange): ) elif content_type in {"text", "text only"}: body = Text(msg.content) + elif msg.style == "diff": + body = DifferView.render(msg.file_path, msg.content, msg.content_type) else: body = Syntax( msg.content, content_type, theme="monokai", line_numbers=True, + background_color="default", ) width = int(console.width * 0.8) - title = msg.title - if len(title) > MAX_TITLE_LENGTH: - title = "..." + title[-MAX_TITLE_LENGTH:] - panel = Panel( body, - title=title, border_style="wheat4", width=width, ) @@ -448,3 +448,63 @@ class _LeftAlignedMarkdown(Markdown): elements = dict(Markdown.elements) elements["heading_open"] = _LeftAlignedHeading + + +class DifferView: + RED = "#3A0003" + GREEN = "#242F12" + GRAY = "grey50" + + @staticmethod + def parse_diff_header(diff_line: str) -> tuple[int, int, int, int]: + pattern = r"@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@" + match = re.match(pattern, diff_line) + + if not match: + return (0, 0, 0, 0) + + old_start, old_lines, new_start, new_lines = match.groups() + + old_lines = int(old_lines) if old_lines else 1 + new_lines = int(new_lines) if new_lines else 1 + + return (int(old_start), old_lines, int(new_start), new_lines) + + @staticmethod + def render(file: str, code: str, lexer: str) -> RenderableType: + table = Table.grid(padding=(0, 1)) + table.add_column(style=DifferView.GRAY, no_wrap=True) # line number + table.add_column(style=DifferView.GRAY, no_wrap=True) # Diff marker + table.add_column() # code + + ln = oln = nln = 0 + for line in code.splitlines(): + if line.startswith("@@"): + ln = oln = nln = DifferView.parse_diff_header(line)[0] - 1 + if len(table.rows) > 0: + table.add_row(Rule(style=DifferView.GRAY)) + continue + + if line.startswith("+"): + marker = Text("+", style="green") + syntax = Syntax(line[1:], lexer, theme="monokai", background_color=DifferView.GREEN) + nln += 1 + ln = nln + elif line.startswith("-"): + syntax = Syntax(line[1:], lexer, theme="monokai", background_color=DifferView.RED) + marker = Text("-", style="red") + oln += 1 + ln = oln + else: + marker = " " + syntax = Syntax( + line[1:], lexer, theme="monokai", word_wrap=True, background_color="default" + ) + oln += 1 + nln += 1 + ln += 1 + + table.add_row(Text(str(ln), style=DifferView.GRAY), marker, syntax) + + text = Text(f"Edit {file}\n", style=DifferView.GRAY) + return Group(text, table) diff --git a/src/kimi_cli/wire/message.py b/src/kimi_cli/wire/message.py index c229a1be..552bc83a 100644 --- a/src/kimi_cli/wire/message.py +++ b/src/kimi_cli/wire/message.py @@ -95,11 +95,14 @@ class PreviewType(Enum): class PreviewChange: - def __init__(self, title: str, content: str, content_type: str = "markdown"): + def __init__( + self, file_path: str, content: str, content_type: str = "markdown", style: str = "default" + ): self.id = str(uuid.uuid4()) - self.title = title + self.file_path = file_path self.content = content self.content_type = content_type + self.style = style type WireMessage = Event | ApprovalRequest | PreviewChange @@ -199,7 +202,8 @@ def _serialize_tool_output( def serialize_preview_request(request: PreviewChange) -> dict[str, Any]: return { "id": request.id, - "title": request.title, + "file_path": request.file_path, "content": request.content, "content_type": request.content_type, + "style": request.style, } From 87116a87f098890cbbcdb7cc71e5366268026a18 Mon Sep 17 00:00:00 2001 From: bigcat Date: Wed, 5 Nov 2025 23:24:10 +0800 Subject: [PATCH 4/5] fix: concurrent issue --- src/kimi_cli/soul/preview.py | 34 +++++++++---------------------- src/kimi_cli/tools/file/patch.py | 1 + src/kimi_cli/ui/shell/liveview.py | 14 +++++++++++-- src/kimi_cli/wire/message.py | 6 ++++++ 4 files changed, 29 insertions(+), 26 deletions(-) diff --git a/src/kimi_cli/soul/preview.py b/src/kimi_cli/soul/preview.py index bd0961c2..93fa0d82 100644 --- a/src/kimi_cli/soul/preview.py +++ b/src/kimi_cli/soul/preview.py @@ -1,27 +1,11 @@ import asyncio import difflib -import re from pygments.lexers import get_lexer_for_filename from kimi_cli.wire.message import PreviewChange -def parse_diff_header(diff_line: str) -> tuple[int, int, int, int]: - pattern = r"@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@" - match = re.match(pattern, diff_line) - - if not match: - return (0, 0, 0, 0) - - old_start, old_lines, new_start, new_lines = match.groups() - - old_lines = int(old_lines) if old_lines else 1 - new_lines = int(new_lines) if new_lines else 1 - - return (int(old_start), old_lines, int(new_start), new_lines) - - class Preview: def __init__(self): self._preview_queue = asyncio.Queue[PreviewChange]() @@ -33,25 +17,27 @@ async def get_lexer(self, file_path: str): except Exception: return "text" - async def preview_text(self, file_path: str, content: str, content_type: str = ""): + async def preview_text( + self, file_path: str, content: str, content_type: str = "", style: str = "" + ): title = file_path if not content_type: content_type = await self.get_lexer(file_path) - self._preview_queue.put_nowait(PreviewChange(title, content, content_type)) + msg = PreviewChange(title, content, content_type, style) + self._preview_queue.put_nowait(msg) + await msg.wait() async def preview_diff(self, file_path: str, before: str, after: str): diff = difflib.unified_diff( - before.splitlines(keepends=True), after.splitlines(keepends=True), fromfile=file_path + before.splitlines(keepends=True), after.splitlines(keepends=True) ) - # ignore --- +++ - next(diff) - next(diff) - content = "".join(diff) content_type = await self.get_lexer(file_path) - self._preview_queue.put_nowait(PreviewChange(file_path, content, content_type, "diff")) + msg = PreviewChange(file_path, content, content_type, "diff") + self._preview_queue.put_nowait(msg) + await msg.wait() async def fetch_request(self) -> PreviewChange: """ diff --git a/src/kimi_cli/tools/file/patch.py b/src/kimi_cli/tools/file/patch.py index bccf2685..c22ac2c9 100644 --- a/src/kimi_cli/tools/file/patch.py +++ b/src/kimi_cli/tools/file/patch.py @@ -115,6 +115,7 @@ async def __call__(self, params: Params) -> ToolReturnType: await self._preview.preview_text( f"Patch file `{params.path}`", params.diff, + "", "diff", ) diff --git a/src/kimi_cli/ui/shell/liveview.py b/src/kimi_cli/ui/shell/liveview.py index 114e7560..19e3aa0f 100644 --- a/src/kimi_cli/ui/shell/liveview.py +++ b/src/kimi_cli/ui/shell/liveview.py @@ -228,7 +228,7 @@ def append_preview(self, msg: PreviewChange): elif content_type in {"text", "text only"}: body = Text(msg.content) elif msg.style == "diff": - body = DifferView.render(msg.file_path, msg.content, msg.content_type) + body = DifferView.get_view(msg.file_path, msg.content, msg.content_type) else: body = Syntax( msg.content, @@ -236,6 +236,7 @@ def append_preview(self, msg: PreviewChange): theme="monokai", line_numbers=True, background_color="default", + padding=(0, 0), ) width = int(console.width * 0.8) @@ -245,6 +246,7 @@ def append_preview(self, msg: PreviewChange): width=width, ) self._push_out(panel) + msg.resolve() def append_tool_call(self, tool_call: ToolCall): self._tool_calls[tool_call.id] = _ToolCallDisplay(tool_call) @@ -471,7 +473,12 @@ def parse_diff_header(diff_line: str) -> tuple[int, int, int, int]: return (int(old_start), old_lines, int(new_start), new_lines) @staticmethod - def render(file: str, code: str, lexer: str) -> RenderableType: + def get_view(file: str, code: str, lexer: str) -> RenderableType: + if not code: + return Group( + Text(f"Edit {file}\n", style="grey50"), Text("nothing changed", style="grey50") + ) + table = Table.grid(padding=(0, 1)) table.add_column(style=DifferView.GRAY, no_wrap=True) # line number table.add_column(style=DifferView.GRAY, no_wrap=True) # Diff marker @@ -479,6 +486,9 @@ def render(file: str, code: str, lexer: str) -> RenderableType: ln = oln = nln = 0 for line in code.splitlines(): + if line.startswith("---") or line.startswith("+++"): + continue + if line.startswith("@@"): ln = oln = nln = DifferView.parse_diff_header(line)[0] - 1 if len(table.rows) > 0: diff --git a/src/kimi_cli/wire/message.py b/src/kimi_cli/wire/message.py index 552bc83a..ddc9e5b3 100644 --- a/src/kimi_cli/wire/message.py +++ b/src/kimi_cli/wire/message.py @@ -103,7 +103,13 @@ def __init__( self.content = content self.content_type = content_type self.style = style + self._future = asyncio.Future[bool]() + async def wait(self) -> bool: + return await self._future + + def resolve(self) -> bool: + return self._future.done() type WireMessage = Event | ApprovalRequest | PreviewChange From 12d5b442377b037ab04d79e4cedb0ad00dd50677 Mon Sep 17 00:00:00 2001 From: bigcat Date: Thu, 6 Nov 2025 00:39:20 +0800 Subject: [PATCH 5/5] fix: test --- src/kimi_cli/soul/preview.py | 12 ++++++++---- src/kimi_cli/wire/message.py | 12 ++++-------- tests/conftest.py | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/kimi_cli/soul/preview.py b/src/kimi_cli/soul/preview.py index 93fa0d82..6ae7909f 100644 --- a/src/kimi_cli/soul/preview.py +++ b/src/kimi_cli/soul/preview.py @@ -7,8 +7,9 @@ class Preview: - def __init__(self): + def __init__(self, yolo: bool = False): self._preview_queue = asyncio.Queue[PreviewChange]() + self._yolo = yolo async def get_lexer(self, file_path: str): try: @@ -20,6 +21,9 @@ async def get_lexer(self, file_path: str): async def preview_text( self, file_path: str, content: str, content_type: str = "", style: str = "" ): + if self._yolo: + return + title = file_path if not content_type: content_type = await self.get_lexer(file_path) @@ -29,6 +33,9 @@ async def preview_text( await msg.wait() async def preview_diff(self, file_path: str, before: str, after: str): + if self._yolo: + return + diff = difflib.unified_diff( before.splitlines(keepends=True), after.splitlines(keepends=True) ) @@ -40,7 +47,4 @@ async def preview_diff(self, file_path: str, before: str, after: str): await msg.wait() async def fetch_request(self) -> PreviewChange: - """ - Fetch an approval request from the queue. Intended to be called by the soul. - """ return await self._preview_queue.get() diff --git a/src/kimi_cli/wire/message.py b/src/kimi_cli/wire/message.py index ddc9e5b3..45407307 100644 --- a/src/kimi_cli/wire/message.py +++ b/src/kimi_cli/wire/message.py @@ -89,14 +89,9 @@ def resolved(self) -> bool: return self._future.done() -class PreviewType(Enum): - DIFF = "diff" - TEXT = "text" - - class PreviewChange: def __init__( - self, file_path: str, content: str, content_type: str = "markdown", style: str = "default" + self, file_path: str, content: str, content_type: str = "markdown", style: str = "auto" ): self.id = str(uuid.uuid4()) self.file_path = file_path @@ -108,8 +103,9 @@ def __init__( async def wait(self) -> bool: return await self._future - def resolve(self) -> bool: - return self._future.done() + def resolve(self) -> None: + self._future.set_result(True) + type WireMessage = Event | ApprovalRequest | PreviewChange diff --git a/tests/conftest.py b/tests/conftest.py index 1471844b..bf65f410 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -97,7 +97,7 @@ def approval() -> Approval: @pytest.fixture def preview() -> Preview: """Create a Preview instance.""" - return Preview() + return Preview(yolo=True) @pytest.fixture