kimi-cli 0.42__py3-none-any.whl → 0.44__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of kimi-cli might be problematic. Click here for more details.
- kimi_cli/CHANGELOG.md +23 -0
- kimi_cli/__init__.py +0 -155
- kimi_cli/app.py +195 -0
- kimi_cli/cli.py +6 -5
- kimi_cli/config.py +7 -2
- kimi_cli/llm.py +37 -7
- kimi_cli/soul/__init__.py +22 -4
- kimi_cli/soul/agent.py +4 -2
- kimi_cli/soul/kimisoul.py +21 -4
- kimi_cli/soul/message.py +1 -1
- kimi_cli/soul/runtime.py +7 -5
- kimi_cli/tools/task/__init__.py +2 -1
- kimi_cli/tools/web/search.py +1 -1
- kimi_cli/ui/acp/__init__.py +24 -28
- kimi_cli/ui/print/__init__.py +25 -33
- kimi_cli/ui/shell/__init__.py +55 -36
- kimi_cli/ui/shell/keyboard.py +82 -14
- kimi_cli/ui/shell/prompt.py +199 -3
- kimi_cli/ui/shell/replay.py +104 -0
- kimi_cli/ui/shell/setup.py +1 -1
- kimi_cli/ui/shell/visualize.py +54 -57
- kimi_cli/utils/message.py +14 -0
- kimi_cli/utils/signals.py +41 -0
- kimi_cli/utils/string.py +8 -0
- kimi_cli/wire/__init__.py +1 -0
- {kimi_cli-0.42.dist-info → kimi_cli-0.44.dist-info}/METADATA +7 -6
- {kimi_cli-0.42.dist-info → kimi_cli-0.44.dist-info}/RECORD +29 -26
- {kimi_cli-0.42.dist-info → kimi_cli-0.44.dist-info}/WHEEL +0 -0
- {kimi_cli-0.42.dist-info → kimi_cli-0.44.dist-info}/entry_points.txt +0 -0
kimi_cli/ui/shell/prompt.py
CHANGED
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import base64
|
|
2
3
|
import contextlib
|
|
3
4
|
import getpass
|
|
4
5
|
import json
|
|
5
6
|
import os
|
|
6
7
|
import re
|
|
8
|
+
import sys
|
|
7
9
|
import time
|
|
8
10
|
from collections.abc import Callable
|
|
9
11
|
from datetime import datetime
|
|
10
12
|
from enum import Enum
|
|
11
13
|
from hashlib import md5
|
|
14
|
+
from io import BytesIO
|
|
12
15
|
from pathlib import Path
|
|
13
16
|
from typing import override
|
|
14
17
|
|
|
18
|
+
from kosong.base.message import ContentPart, ImageURLPart, TextPart
|
|
19
|
+
from PIL import Image, ImageGrab
|
|
15
20
|
from prompt_toolkit import PromptSession
|
|
16
21
|
from prompt_toolkit.application.current import get_app_or_none
|
|
22
|
+
from prompt_toolkit.clipboard.pyperclip import PyperclipClipboard
|
|
17
23
|
from prompt_toolkit.completion import (
|
|
18
24
|
Completer,
|
|
19
25
|
Completion,
|
|
@@ -34,6 +40,9 @@ from kimi_cli.share import get_share_dir
|
|
|
34
40
|
from kimi_cli.soul import StatusSnapshot
|
|
35
41
|
from kimi_cli.ui.shell.metacmd import get_meta_commands
|
|
36
42
|
from kimi_cli.utils.logging import logger
|
|
43
|
+
from kimi_cli.utils.string import random_string
|
|
44
|
+
|
|
45
|
+
PROMPT_SYMBOL = "✨"
|
|
37
46
|
|
|
38
47
|
|
|
39
48
|
class MetaCommandCompleter(Completer):
|
|
@@ -360,6 +369,9 @@ class PromptMode(Enum):
|
|
|
360
369
|
class UserInput(BaseModel):
|
|
361
370
|
mode: PromptMode
|
|
362
371
|
command: str
|
|
372
|
+
"""The plain text representation of the user input."""
|
|
373
|
+
content: list[ContentPart]
|
|
374
|
+
"""The rich content parts."""
|
|
363
375
|
|
|
364
376
|
def __str__(self) -> str:
|
|
365
377
|
return self.command
|
|
@@ -377,6 +389,11 @@ def toast(message: str, duration: float = 5.0) -> None:
|
|
|
377
389
|
_toast_queue.put_nowait((message, duration))
|
|
378
390
|
|
|
379
391
|
|
|
392
|
+
_ATTACHMENT_PLACEHOLDER_RE = re.compile(
|
|
393
|
+
r"\[(?P<type>image):(?P<id>[a-zA-Z0-9_\-\.]+)(?:,(?P<width>\d+)x(?P<height>\d+))?\]"
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
380
397
|
class CustomPromptSession:
|
|
381
398
|
def __init__(self, status_provider: Callable[[], StatusSnapshot]):
|
|
382
399
|
history_dir = get_share_dir() / "user-history"
|
|
@@ -386,6 +403,8 @@ class CustomPromptSession:
|
|
|
386
403
|
self._status_provider = status_provider
|
|
387
404
|
self._last_history_content: str | None = None
|
|
388
405
|
self._mode: PromptMode = PromptMode.AGENT
|
|
406
|
+
self._attachment_parts: dict[str, ContentPart] = {}
|
|
407
|
+
"""Mapping from attachment id to ContentPart."""
|
|
389
408
|
|
|
390
409
|
history_entries = _load_history_entries(self._history_file)
|
|
391
410
|
history = InMemoryHistory()
|
|
@@ -433,12 +452,20 @@ class CustomPromptSession:
|
|
|
433
452
|
# Redraw UI
|
|
434
453
|
event.app.invalidate()
|
|
435
454
|
|
|
455
|
+
@_kb.add("c-v", eager=True)
|
|
456
|
+
def _paste(event: KeyPressEvent) -> None:
|
|
457
|
+
if self._try_paste_image(event):
|
|
458
|
+
return
|
|
459
|
+
clipboard_data = event.app.clipboard.get_data()
|
|
460
|
+
event.current_buffer.paste_clipboard_data(clipboard_data)
|
|
461
|
+
|
|
436
462
|
self._session = PromptSession(
|
|
437
463
|
message=self._render_message,
|
|
438
|
-
prompt_continuation=FormattedText([("fg:#4d4d4d", "... ")]),
|
|
464
|
+
# prompt_continuation=FormattedText([("fg:#4d4d4d", "... ")]),
|
|
439
465
|
completer=self._agent_mode_completer,
|
|
440
466
|
complete_while_typing=True,
|
|
441
467
|
key_bindings=_kb,
|
|
468
|
+
clipboard=PyperclipClipboard(),
|
|
442
469
|
history=history,
|
|
443
470
|
bottom_toolbar=self._render_bottom_toolbar,
|
|
444
471
|
)
|
|
@@ -448,7 +475,7 @@ class CustomPromptSession:
|
|
|
448
475
|
self._current_toast_duration: float = 0.0
|
|
449
476
|
|
|
450
477
|
def _render_message(self) -> FormattedText:
|
|
451
|
-
symbol =
|
|
478
|
+
symbol = PROMPT_SYMBOL if self._mode == PromptMode.AGENT else "$"
|
|
452
479
|
return FormattedText([("bold", f"{getpass.getuser()}{symbol} ")])
|
|
453
480
|
|
|
454
481
|
def _apply_mode(self, event: KeyPressEvent | None = None) -> None:
|
|
@@ -501,12 +528,76 @@ class CustomPromptSession:
|
|
|
501
528
|
if self._status_refresh_task is not None and not self._status_refresh_task.done():
|
|
502
529
|
self._status_refresh_task.cancel()
|
|
503
530
|
self._status_refresh_task = None
|
|
531
|
+
self._attachment_parts.clear()
|
|
532
|
+
|
|
533
|
+
def _try_paste_image(self, event: KeyPressEvent) -> bool:
|
|
534
|
+
"""Try to paste an image from the clipboard. Return True if successful."""
|
|
535
|
+
# Try get image from clipboard
|
|
536
|
+
image = ImageGrab.grabclipboard()
|
|
537
|
+
if isinstance(image, list):
|
|
538
|
+
for item in image:
|
|
539
|
+
try:
|
|
540
|
+
with Image.open(item) as img:
|
|
541
|
+
image = img.copy()
|
|
542
|
+
break
|
|
543
|
+
except Exception:
|
|
544
|
+
continue
|
|
545
|
+
else:
|
|
546
|
+
image = None
|
|
547
|
+
|
|
548
|
+
if image is None:
|
|
549
|
+
return False
|
|
550
|
+
|
|
551
|
+
attachment_id = f"{random_string(8)}.png"
|
|
552
|
+
png_bytes = BytesIO()
|
|
553
|
+
image.save(png_bytes, format="PNG")
|
|
554
|
+
png_base64 = base64.b64encode(png_bytes.getvalue()).decode("ascii")
|
|
555
|
+
image_part = ImageURLPart(
|
|
556
|
+
image_url=ImageURLPart.ImageURL(
|
|
557
|
+
url=f"data:image/png;base64,{png_base64}", id=attachment_id
|
|
558
|
+
)
|
|
559
|
+
)
|
|
560
|
+
self._attachment_parts[attachment_id] = image_part
|
|
561
|
+
logger.debug(
|
|
562
|
+
"Pasted image from clipboard: {attachment_id}, {image_size}",
|
|
563
|
+
attachment_id=attachment_id,
|
|
564
|
+
image_size=image.size,
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
placeholder = f"[image:{attachment_id},{image.width}x{image.height}]"
|
|
568
|
+
event.current_buffer.insert_text(placeholder)
|
|
569
|
+
event.app.invalidate()
|
|
570
|
+
return True
|
|
504
571
|
|
|
505
572
|
async def prompt(self) -> UserInput:
|
|
506
573
|
with patch_stdout():
|
|
507
574
|
command = str(await self._session.prompt_async()).strip()
|
|
575
|
+
command = command.replace("\x00", "") # just in case null bytes are somehow inserted
|
|
508
576
|
self._append_history_entry(command)
|
|
509
|
-
|
|
577
|
+
|
|
578
|
+
# Parse rich content parts
|
|
579
|
+
content: list[ContentPart] = []
|
|
580
|
+
remaining_command = command
|
|
581
|
+
while match := _ATTACHMENT_PLACEHOLDER_RE.search(remaining_command):
|
|
582
|
+
start, end = match.span()
|
|
583
|
+
if start > 0:
|
|
584
|
+
content.append(TextPart(text=remaining_command[:start]))
|
|
585
|
+
attachment_id = match.group("id")
|
|
586
|
+
part = self._attachment_parts.get(attachment_id)
|
|
587
|
+
if part is not None:
|
|
588
|
+
content.append(part)
|
|
589
|
+
else:
|
|
590
|
+
logger.warning(
|
|
591
|
+
"Attachment placeholder found but no matching attachment part: {placeholder}",
|
|
592
|
+
placeholder=match.group(0),
|
|
593
|
+
)
|
|
594
|
+
content.append(TextPart(text=match.group(0)))
|
|
595
|
+
remaining_command = remaining_command[end:]
|
|
596
|
+
|
|
597
|
+
if remaining_command.strip():
|
|
598
|
+
content.append(TextPart(text=remaining_command.strip()))
|
|
599
|
+
|
|
600
|
+
return UserInput(mode=self._mode, content=content, command=command)
|
|
510
601
|
|
|
511
602
|
def _append_history_entry(self, text: str) -> None:
|
|
512
603
|
entry = _HistoryEntry(content=text.strip())
|
|
@@ -557,6 +648,7 @@ class CustomPromptSession:
|
|
|
557
648
|
shortcuts = [
|
|
558
649
|
"ctrl-x: switch mode",
|
|
559
650
|
"ctrl-j: newline",
|
|
651
|
+
"ctrl-v: paste",
|
|
560
652
|
"ctrl-d: exit",
|
|
561
653
|
]
|
|
562
654
|
for shortcut in shortcuts:
|
|
@@ -579,3 +671,107 @@ class CustomPromptSession:
|
|
|
579
671
|
def _format_status(status: StatusSnapshot) -> str:
|
|
580
672
|
bounded = max(0.0, min(status.context_usage, 1.0))
|
|
581
673
|
return f"context: {bounded:.1%}"
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def ensure_new_line() -> None:
|
|
677
|
+
"""Ensure the next prompt starts at column 0 regardless of prior command output."""
|
|
678
|
+
|
|
679
|
+
if not sys.stdout.isatty() or not sys.stdin.isatty():
|
|
680
|
+
return
|
|
681
|
+
|
|
682
|
+
needs_break = True
|
|
683
|
+
if sys.platform == "win32":
|
|
684
|
+
column = _cursor_column_windows()
|
|
685
|
+
needs_break = column not in (None, 0)
|
|
686
|
+
else:
|
|
687
|
+
column = _cursor_column_unix()
|
|
688
|
+
needs_break = column not in (None, 1)
|
|
689
|
+
|
|
690
|
+
if needs_break:
|
|
691
|
+
_write_newline()
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _cursor_column_unix() -> int | None:
|
|
695
|
+
assert sys.platform != "win32"
|
|
696
|
+
|
|
697
|
+
import select
|
|
698
|
+
import termios
|
|
699
|
+
import tty
|
|
700
|
+
|
|
701
|
+
_CURSOR_QUERY = "\x1b[6n"
|
|
702
|
+
_CURSOR_POSITION_RE = re.compile(r"\x1b\[(\d+);(\d+)R")
|
|
703
|
+
|
|
704
|
+
fd = sys.stdin.fileno()
|
|
705
|
+
oldterm = termios.tcgetattr(fd)
|
|
706
|
+
|
|
707
|
+
try:
|
|
708
|
+
tty.setcbreak(fd)
|
|
709
|
+
sys.stdout.write(_CURSOR_QUERY)
|
|
710
|
+
sys.stdout.flush()
|
|
711
|
+
|
|
712
|
+
response = ""
|
|
713
|
+
deadline = time.monotonic() + 0.2
|
|
714
|
+
while time.monotonic() < deadline:
|
|
715
|
+
timeout = max(0.01, deadline - time.monotonic())
|
|
716
|
+
ready, _, _ = select.select([sys.stdin], [], [], timeout)
|
|
717
|
+
if not ready:
|
|
718
|
+
continue
|
|
719
|
+
try:
|
|
720
|
+
chunk = os.read(fd, 32)
|
|
721
|
+
except OSError:
|
|
722
|
+
break
|
|
723
|
+
if not chunk:
|
|
724
|
+
break
|
|
725
|
+
response += chunk.decode(errors="ignore")
|
|
726
|
+
match = _CURSOR_POSITION_RE.search(response)
|
|
727
|
+
if match:
|
|
728
|
+
return int(match.group(2))
|
|
729
|
+
finally:
|
|
730
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, oldterm)
|
|
731
|
+
|
|
732
|
+
return None
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _cursor_column_windows() -> int | None:
|
|
736
|
+
assert sys.platform == "win32"
|
|
737
|
+
|
|
738
|
+
import ctypes
|
|
739
|
+
from ctypes import wintypes
|
|
740
|
+
|
|
741
|
+
kernel32 = ctypes.windll.kernel32
|
|
742
|
+
_STD_OUTPUT_HANDLE = -11 # Windows API constant for standard output handle
|
|
743
|
+
handle = kernel32.GetStdHandle(_STD_OUTPUT_HANDLE)
|
|
744
|
+
invalid_handle_value = ctypes.c_void_p(-1).value
|
|
745
|
+
if handle in (0, invalid_handle_value):
|
|
746
|
+
return None
|
|
747
|
+
|
|
748
|
+
class COORD(ctypes.Structure):
|
|
749
|
+
_fields_ = [("X", wintypes.SHORT), ("Y", wintypes.SHORT)]
|
|
750
|
+
|
|
751
|
+
class SMALL_RECT(ctypes.Structure):
|
|
752
|
+
_fields_ = [
|
|
753
|
+
("Left", wintypes.SHORT),
|
|
754
|
+
("Top", wintypes.SHORT),
|
|
755
|
+
("Right", wintypes.SHORT),
|
|
756
|
+
("Bottom", wintypes.SHORT),
|
|
757
|
+
]
|
|
758
|
+
|
|
759
|
+
class CONSOLE_SCREEN_BUFFER_INFO(ctypes.Structure):
|
|
760
|
+
_fields_ = [
|
|
761
|
+
("dwSize", COORD),
|
|
762
|
+
("dwCursorPosition", COORD),
|
|
763
|
+
("wAttributes", wintypes.WORD),
|
|
764
|
+
("srWindow", SMALL_RECT),
|
|
765
|
+
("dwMaximumWindowSize", COORD),
|
|
766
|
+
]
|
|
767
|
+
|
|
768
|
+
csbi = CONSOLE_SCREEN_BUFFER_INFO()
|
|
769
|
+
if not kernel32.GetConsoleScreenBufferInfo(handle, ctypes.byref(csbi)):
|
|
770
|
+
return None
|
|
771
|
+
|
|
772
|
+
return int(csbi.dwCursorPosition.X)
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def _write_newline() -> None:
|
|
776
|
+
sys.stdout.write("\n")
|
|
777
|
+
sys.stdout.flush()
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextlib
|
|
3
|
+
import getpass
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from kosong.base.message import Message, TextPart
|
|
8
|
+
from kosong.tooling import ToolError, ToolOk
|
|
9
|
+
|
|
10
|
+
from kimi_cli.soul import StatusSnapshot
|
|
11
|
+
from kimi_cli.ui.shell.console import console
|
|
12
|
+
from kimi_cli.ui.shell.prompt import PROMPT_SYMBOL
|
|
13
|
+
from kimi_cli.ui.shell.visualize import visualize
|
|
14
|
+
from kimi_cli.utils.message import message_extract_text, message_stringify
|
|
15
|
+
from kimi_cli.wire import Wire
|
|
16
|
+
from kimi_cli.wire.message import ContentPart, StepBegin, ToolCall, ToolResult
|
|
17
|
+
|
|
18
|
+
MAX_REPLAY_RUNS = 5
|
|
19
|
+
|
|
20
|
+
type _ReplayEvent = StepBegin | ToolCall | ContentPart | ToolResult
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class _ReplayRun:
|
|
25
|
+
user_message: Message
|
|
26
|
+
events: list[_ReplayEvent]
|
|
27
|
+
n_steps: int = 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def replay_recent_history(history: Sequence[Message]) -> None:
|
|
31
|
+
"""
|
|
32
|
+
Replay the most recent user-initiated runs from the provided message history.
|
|
33
|
+
"""
|
|
34
|
+
start_idx = _find_replay_start(history)
|
|
35
|
+
if start_idx is None:
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
runs = _build_replay_runs(history[start_idx:])
|
|
39
|
+
if not runs:
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
for run in runs:
|
|
43
|
+
wire = Wire()
|
|
44
|
+
console.print(f"{getpass.getuser()}{PROMPT_SYMBOL} {message_stringify(run.user_message)}")
|
|
45
|
+
ui_task = asyncio.create_task(
|
|
46
|
+
visualize(wire.ui_side, initial_status=StatusSnapshot(context_usage=0.0))
|
|
47
|
+
)
|
|
48
|
+
for event in run.events:
|
|
49
|
+
wire.soul_side.send(event)
|
|
50
|
+
await asyncio.sleep(0) # yield to UI loop
|
|
51
|
+
wire.shutdown()
|
|
52
|
+
with contextlib.suppress(asyncio.QueueShutDown):
|
|
53
|
+
await ui_task
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _is_user_message(message: Message) -> bool:
|
|
57
|
+
# FIXME: should consider non-text tool call results which are sent as user messages
|
|
58
|
+
if message.role != "user":
|
|
59
|
+
return False
|
|
60
|
+
return not message_extract_text(message).startswith("<system>CHECKPOINT")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _find_replay_start(history: Sequence[Message]) -> int | None:
|
|
64
|
+
indices = [idx for idx, message in enumerate(history) if _is_user_message(message)]
|
|
65
|
+
if not indices:
|
|
66
|
+
return None
|
|
67
|
+
# only replay last MAX_REPLAY_RUNS messages
|
|
68
|
+
return indices[max(0, len(indices) - MAX_REPLAY_RUNS)]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _build_replay_runs(history: Sequence[Message]) -> list[_ReplayRun]:
|
|
72
|
+
runs: list[_ReplayRun] = []
|
|
73
|
+
current_run: _ReplayRun | None = None
|
|
74
|
+
for message in history:
|
|
75
|
+
if _is_user_message(message):
|
|
76
|
+
# start a new run
|
|
77
|
+
if current_run is not None:
|
|
78
|
+
runs.append(current_run)
|
|
79
|
+
current_run = _ReplayRun(user_message=message, events=[])
|
|
80
|
+
elif message.role == "assistant":
|
|
81
|
+
if current_run is None:
|
|
82
|
+
continue
|
|
83
|
+
current_run.n_steps += 1
|
|
84
|
+
current_run.events.append(StepBegin(n=current_run.n_steps))
|
|
85
|
+
if isinstance(message.content, str):
|
|
86
|
+
current_run.events.append(TextPart(text=message.content))
|
|
87
|
+
else:
|
|
88
|
+
current_run.events.extend(message.content)
|
|
89
|
+
current_run.events.extend(message.tool_calls or [])
|
|
90
|
+
elif message.role == "tool":
|
|
91
|
+
if current_run is None:
|
|
92
|
+
continue
|
|
93
|
+
assert message.tool_call_id is not None
|
|
94
|
+
if isinstance(message.content, list) and any(
|
|
95
|
+
isinstance(part, TextPart) and part.text.startswith("<system>ERROR")
|
|
96
|
+
for part in message.content
|
|
97
|
+
):
|
|
98
|
+
result = ToolError(message="", output="", brief="")
|
|
99
|
+
else:
|
|
100
|
+
result = ToolOk(output=message.content)
|
|
101
|
+
current_run.events.append(ToolResult(tool_call_id=message.tool_call_id, result=result))
|
|
102
|
+
if current_run is not None:
|
|
103
|
+
runs.append(current_run)
|
|
104
|
+
return runs
|
kimi_cli/ui/shell/setup.py
CHANGED
kimi_cli/ui/shell/visualize.py
CHANGED
|
@@ -8,7 +8,6 @@ from kimi_cli.soul import StatusSnapshot
|
|
|
8
8
|
from kimi_cli.ui.shell.console import console
|
|
9
9
|
from kimi_cli.ui.shell.keyboard import listen_for_keyboard
|
|
10
10
|
from kimi_cli.ui.shell.liveview import StepLiveView, StepLiveViewWithMarkdown
|
|
11
|
-
from kimi_cli.utils.logging import logger
|
|
12
11
|
from kimi_cli.wire import WireUISide
|
|
13
12
|
from kimi_cli.wire.message import (
|
|
14
13
|
ApprovalRequest,
|
|
@@ -46,69 +45,67 @@ async def visualize(
|
|
|
46
45
|
):
|
|
47
46
|
"""
|
|
48
47
|
A loop to consume agent events and visualize the agent behavior.
|
|
49
|
-
This loop never raise any exception except asyncio.CancelledError.
|
|
50
48
|
|
|
51
49
|
Args:
|
|
52
50
|
wire: Communication channel with the agent
|
|
53
51
|
initial_status: Initial status snapshot
|
|
54
52
|
cancel_event: Event that can be set (e.g., by ESC key) to cancel the run
|
|
55
53
|
"""
|
|
54
|
+
|
|
56
55
|
latest_status = initial_status
|
|
57
|
-
try:
|
|
58
|
-
# expect a StepBegin
|
|
59
|
-
assert isinstance(await wire.receive(), StepBegin)
|
|
60
|
-
|
|
61
|
-
while True:
|
|
62
|
-
# TODO: Maybe we can always have a StepLiveView here.
|
|
63
|
-
# No need to recreate for each step.
|
|
64
|
-
with StepLiveViewWithMarkdown(latest_status, cancel_event) as step:
|
|
65
|
-
async with _keyboard_listener(step):
|
|
66
|
-
# spin the moon at the beginning of each step
|
|
67
|
-
with console.status("", spinner="moon"):
|
|
68
|
-
msg = await wire.receive()
|
|
69
56
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
case TextPart(text=text):
|
|
82
|
-
step.append_text(text)
|
|
83
|
-
case ContentPart():
|
|
84
|
-
# TODO: support more content parts
|
|
85
|
-
step.append_text(f"[{msg.__class__.__name__}]")
|
|
86
|
-
case ToolCall():
|
|
87
|
-
step.append_tool_call(msg)
|
|
88
|
-
case ToolCallPart():
|
|
89
|
-
step.append_tool_call_part(msg)
|
|
90
|
-
case ToolResult():
|
|
91
|
-
step.append_tool_result(msg)
|
|
92
|
-
case ApprovalRequest():
|
|
93
|
-
step.request_approval(msg)
|
|
94
|
-
case StatusUpdate(status=status):
|
|
95
|
-
latest_status = status
|
|
96
|
-
step.update_status(latest_status)
|
|
97
|
-
case _:
|
|
98
|
-
break # break the step loop
|
|
99
|
-
msg = await wire.receive()
|
|
57
|
+
# expect a StepBegin
|
|
58
|
+
assert isinstance(await wire.receive(), StepBegin)
|
|
59
|
+
|
|
60
|
+
while True:
|
|
61
|
+
# TODO: Maybe we can always have a StepLiveView here.
|
|
62
|
+
# No need to recreate for each step.
|
|
63
|
+
with StepLiveViewWithMarkdown(latest_status, cancel_event) as step:
|
|
64
|
+
async with _keyboard_listener(step):
|
|
65
|
+
# spin the moon at the beginning of each step
|
|
66
|
+
with console.status("", spinner="moon"):
|
|
67
|
+
msg = await wire.receive()
|
|
100
68
|
|
|
101
|
-
|
|
69
|
+
if isinstance(msg, CompactionBegin):
|
|
70
|
+
with console.status("[cyan]Compacting...[/cyan]"):
|
|
71
|
+
msg = await wire.receive()
|
|
102
72
|
if isinstance(msg, StepInterrupted):
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
73
|
+
break
|
|
74
|
+
assert isinstance(msg, CompactionEnd)
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
# visualization loop for one step
|
|
78
|
+
while True:
|
|
79
|
+
match msg:
|
|
80
|
+
case TextPart(text=text):
|
|
81
|
+
step.append_text(text)
|
|
82
|
+
case ContentPart():
|
|
83
|
+
# TODO: support more content parts
|
|
84
|
+
step.append_text(f"[{msg.__class__.__name__}]")
|
|
85
|
+
case ToolCall():
|
|
86
|
+
step.append_tool_call(msg)
|
|
87
|
+
case ToolCallPart():
|
|
88
|
+
step.append_tool_call_part(msg)
|
|
89
|
+
case ToolResult():
|
|
90
|
+
step.append_tool_result(msg)
|
|
91
|
+
case ApprovalRequest():
|
|
92
|
+
step.request_approval(msg)
|
|
93
|
+
case StatusUpdate(status=status):
|
|
94
|
+
latest_status = status
|
|
95
|
+
step.update_status(latest_status)
|
|
96
|
+
case _:
|
|
97
|
+
break # break the step loop
|
|
98
|
+
msg = await wire.receive()
|
|
99
|
+
|
|
100
|
+
# cleanup the step live view
|
|
101
|
+
if isinstance(msg, StepInterrupted):
|
|
102
|
+
step.interrupt()
|
|
103
|
+
else:
|
|
104
|
+
step.finish()
|
|
105
|
+
|
|
106
|
+
if isinstance(msg, StepInterrupted):
|
|
107
|
+
# for StepInterrupted, the visualization loop should end immediately
|
|
108
|
+
break
|
|
109
|
+
|
|
110
|
+
assert isinstance(msg, StepBegin), "expect a StepBegin"
|
|
111
|
+
# start a new step
|
kimi_cli/utils/message.py
CHANGED
|
@@ -6,3 +6,17 @@ def message_extract_text(message: Message) -> str:
|
|
|
6
6
|
if isinstance(message.content, str):
|
|
7
7
|
return message.content
|
|
8
8
|
return "\n".join(part.text for part in message.content if isinstance(part, TextPart))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def message_stringify(message: Message) -> str:
|
|
12
|
+
"""Get a string representation of a message."""
|
|
13
|
+
parts: list[str] = []
|
|
14
|
+
if isinstance(message.content, str):
|
|
15
|
+
parts.append(message.content)
|
|
16
|
+
else:
|
|
17
|
+
for part in message.content:
|
|
18
|
+
if isinstance(part, TextPart):
|
|
19
|
+
parts.append(part.text)
|
|
20
|
+
else:
|
|
21
|
+
parts.append(f"[{part.type}]")
|
|
22
|
+
return "".join(parts)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextlib
|
|
3
|
+
import signal
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def install_sigint_handler(
|
|
8
|
+
loop: asyncio.AbstractEventLoop, handler: Callable[[], None]
|
|
9
|
+
) -> Callable[[], None]:
|
|
10
|
+
"""
|
|
11
|
+
Install a SIGINT handler that works on Unix and Windows.
|
|
12
|
+
|
|
13
|
+
On Unix event loops, prefer `loop.add_signal_handler`.
|
|
14
|
+
On Windows (or other platforms) where it is not implemented, fall back to
|
|
15
|
+
`signal.signal`. The fallback cannot be removed from the loop, but we
|
|
16
|
+
restore the previous handler on uninstall.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
A function that removes the installed handler. It is guaranteed that
|
|
20
|
+
no exceptions are raised when calling the returned function.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
loop.add_signal_handler(signal.SIGINT, handler)
|
|
25
|
+
|
|
26
|
+
def remove() -> None:
|
|
27
|
+
with contextlib.suppress(RuntimeError):
|
|
28
|
+
loop.remove_signal_handler(signal.SIGINT)
|
|
29
|
+
|
|
30
|
+
return remove
|
|
31
|
+
except RuntimeError:
|
|
32
|
+
# Windows ProactorEventLoop and some environments do not support
|
|
33
|
+
# add_signal_handler. Use synchronous signal handling as a fallback.
|
|
34
|
+
previous = signal.getsignal(signal.SIGINT)
|
|
35
|
+
signal.signal(signal.SIGINT, lambda signum, frame: handler())
|
|
36
|
+
|
|
37
|
+
def remove() -> None:
|
|
38
|
+
with contextlib.suppress(RuntimeError):
|
|
39
|
+
signal.signal(signal.SIGINT, previous)
|
|
40
|
+
|
|
41
|
+
return remove
|
kimi_cli/utils/string.py
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import random
|
|
1
2
|
import re
|
|
3
|
+
import string
|
|
2
4
|
|
|
3
5
|
_NEWLINE_RE = re.compile(r"[\r\n]+")
|
|
4
6
|
|
|
@@ -10,3 +12,9 @@ def shorten_middle(text: str, width: int, remove_newline: bool = True) -> str:
|
|
|
10
12
|
if remove_newline:
|
|
11
13
|
text = _NEWLINE_RE.sub(" ", text)
|
|
12
14
|
return text[: width // 2] + "..." + text[-width // 2 :]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def random_string(length: int = 8) -> str:
|
|
18
|
+
"""Generate a random string of fixed length."""
|
|
19
|
+
letters = string.ascii_lowercase
|
|
20
|
+
return "".join(random.choice(letters) for _ in range(length))
|
kimi_cli/wire/__init__.py
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: kimi-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.44
|
|
4
4
|
Summary: Kimi CLI is your next CLI agent.
|
|
5
|
-
Requires-Dist: agent-client-protocol==0.
|
|
5
|
+
Requires-Dist: agent-client-protocol==0.6.2
|
|
6
6
|
Requires-Dist: aiofiles==25.1.0
|
|
7
|
-
Requires-Dist: aiohttp==3.13.
|
|
7
|
+
Requires-Dist: aiohttp==3.13.2
|
|
8
8
|
Requires-Dist: click==8.3.0
|
|
9
|
-
Requires-Dist: kosong==0.
|
|
9
|
+
Requires-Dist: kosong==0.16.1
|
|
10
10
|
Requires-Dist: loguru==0.7.3
|
|
11
11
|
Requires-Dist: patch-ng==1.19.0
|
|
12
12
|
Requires-Dist: prompt-toolkit==3.0.52
|
|
13
|
+
Requires-Dist: pillow==12.0.0
|
|
13
14
|
Requires-Dist: pyyaml==6.0.3
|
|
14
15
|
Requires-Dist: rich==14.2.0
|
|
15
16
|
Requires-Dist: ripgrepy==2.2.0
|
|
@@ -84,7 +85,7 @@ After setup, Kimi CLI will be ready to use. You can send `/help` to get more inf
|
|
|
84
85
|
|
|
85
86
|
### Shell mode
|
|
86
87
|
|
|
87
|
-
Kimi CLI is not only a coding agent, but also a shell. You can switch the mode by pressing `Ctrl-
|
|
88
|
+
Kimi CLI is not only a coding agent, but also a shell. You can switch the mode by pressing `Ctrl-X`. In shell mode, you can directly run shell commands without leaving Kimi CLI.
|
|
88
89
|
|
|
89
90
|
> [!NOTE]
|
|
90
91
|
> Built-in shell commands like `cd` are not supported yet.
|
|
@@ -109,7 +110,7 @@ Then add `kimi-cli` to your Zsh plugin list in `~/.zshrc`:
|
|
|
109
110
|
plugins=(... kimi-cli)
|
|
110
111
|
```
|
|
111
112
|
|
|
112
|
-
After restarting Zsh, you can switch to agent mode by pressing `Ctrl-
|
|
113
|
+
After restarting Zsh, you can switch to agent mode by pressing `Ctrl-X`.
|
|
113
114
|
|
|
114
115
|
### ACP support
|
|
115
116
|
|