kimi-cli 0.41__py3-none-any.whl → 0.43__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 +28 -0
- kimi_cli/__init__.py +169 -102
- kimi_cli/agents/{koder → default}/agent.yaml +1 -1
- kimi_cli/agentspec.py +19 -8
- kimi_cli/cli.py +51 -37
- kimi_cli/config.py +33 -14
- kimi_cli/exception.py +16 -0
- kimi_cli/llm.py +31 -3
- kimi_cli/metadata.py +5 -68
- kimi_cli/session.py +81 -0
- kimi_cli/soul/__init__.py +22 -4
- kimi_cli/soul/agent.py +18 -23
- kimi_cli/soul/context.py +0 -5
- kimi_cli/soul/kimisoul.py +40 -25
- kimi_cli/soul/message.py +1 -1
- kimi_cli/soul/{globals.py → runtime.py} +13 -11
- kimi_cli/tools/file/glob.py +1 -1
- kimi_cli/tools/file/patch.py +1 -1
- kimi_cli/tools/file/read.py +1 -1
- kimi_cli/tools/file/replace.py +1 -1
- kimi_cli/tools/file/write.py +1 -1
- kimi_cli/tools/task/__init__.py +29 -21
- kimi_cli/tools/web/search.py +3 -0
- kimi_cli/ui/acp/__init__.py +24 -28
- kimi_cli/ui/print/__init__.py +27 -30
- kimi_cli/ui/shell/__init__.py +58 -42
- kimi_cli/ui/shell/keyboard.py +82 -14
- kimi_cli/ui/shell/metacmd.py +3 -8
- kimi_cli/ui/shell/prompt.py +208 -6
- kimi_cli/ui/shell/replay.py +104 -0
- 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 +13 -0
- {kimi_cli-0.41.dist-info → kimi_cli-0.43.dist-info}/METADATA +21 -20
- {kimi_cli-0.41.dist-info → kimi_cli-0.43.dist-info}/RECORD +41 -38
- kimi_cli/agents/koder/README.md +0 -3
- /kimi_cli/agents/{koder → default}/sub.yaml +0 -0
- /kimi_cli/agents/{koder → default}/system.md +0 -0
- {kimi_cli-0.41.dist-info → kimi_cli-0.43.dist-info}/WHEEL +0 -0
- {kimi_cli-0.41.dist-info → kimi_cli-0.43.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()
|
|
@@ -419,20 +438,34 @@ class CustomPromptSession:
|
|
|
419
438
|
completion = buff.complete_state.completions[0]
|
|
420
439
|
buff.apply_completion(completion)
|
|
421
440
|
|
|
422
|
-
@_kb.add("
|
|
423
|
-
|
|
441
|
+
@_kb.add("escape", "enter", eager=True)
|
|
442
|
+
@_kb.add("c-j", eager=True)
|
|
443
|
+
def _insert_newline(event: KeyPressEvent) -> None:
|
|
444
|
+
"""Insert a newline when Alt-Enter or Ctrl-J is pressed."""
|
|
445
|
+
event.current_buffer.insert_text("\n")
|
|
446
|
+
|
|
447
|
+
@_kb.add("c-x", eager=True)
|
|
448
|
+
def _switch_mode(event: KeyPressEvent) -> None:
|
|
424
449
|
self._mode = self._mode.toggle()
|
|
425
450
|
# Apply mode-specific settings
|
|
426
451
|
self._apply_mode(event)
|
|
427
452
|
# Redraw UI
|
|
428
453
|
event.app.invalidate()
|
|
429
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
|
+
|
|
430
462
|
self._session = PromptSession(
|
|
431
463
|
message=self._render_message,
|
|
432
|
-
prompt_continuation=FormattedText([("fg:#4d4d4d", "... ")]),
|
|
464
|
+
# prompt_continuation=FormattedText([("fg:#4d4d4d", "... ")]),
|
|
433
465
|
completer=self._agent_mode_completer,
|
|
434
466
|
complete_while_typing=True,
|
|
435
467
|
key_bindings=_kb,
|
|
468
|
+
clipboard=PyperclipClipboard(),
|
|
436
469
|
history=history,
|
|
437
470
|
bottom_toolbar=self._render_bottom_toolbar,
|
|
438
471
|
)
|
|
@@ -442,7 +475,7 @@ class CustomPromptSession:
|
|
|
442
475
|
self._current_toast_duration: float = 0.0
|
|
443
476
|
|
|
444
477
|
def _render_message(self) -> FormattedText:
|
|
445
|
-
symbol =
|
|
478
|
+
symbol = PROMPT_SYMBOL if self._mode == PromptMode.AGENT else "$"
|
|
446
479
|
return FormattedText([("bold", f"{getpass.getuser()}{symbol} ")])
|
|
447
480
|
|
|
448
481
|
def _apply_mode(self, event: KeyPressEvent | None = None) -> None:
|
|
@@ -495,12 +528,75 @@ class CustomPromptSession:
|
|
|
495
528
|
if self._status_refresh_task is not None and not self._status_refresh_task.done():
|
|
496
529
|
self._status_refresh_task.cancel()
|
|
497
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
|
|
498
571
|
|
|
499
572
|
async def prompt(self) -> UserInput:
|
|
500
573
|
with patch_stdout():
|
|
501
574
|
command = str(await self._session.prompt_async()).strip()
|
|
502
575
|
self._append_history_entry(command)
|
|
503
|
-
|
|
576
|
+
|
|
577
|
+
# Parse rich content parts
|
|
578
|
+
content: list[ContentPart] = []
|
|
579
|
+
remaining_command = command
|
|
580
|
+
while match := _ATTACHMENT_PLACEHOLDER_RE.search(remaining_command):
|
|
581
|
+
start, end = match.span()
|
|
582
|
+
if start > 0:
|
|
583
|
+
content.append(TextPart(text=remaining_command[:start]))
|
|
584
|
+
attachment_id = match.group("id")
|
|
585
|
+
part = self._attachment_parts.get(attachment_id)
|
|
586
|
+
if part is not None:
|
|
587
|
+
content.append(part)
|
|
588
|
+
else:
|
|
589
|
+
logger.warning(
|
|
590
|
+
"Attachment placeholder found but no matching attachment part: {placeholder}",
|
|
591
|
+
placeholder=match.group(0),
|
|
592
|
+
)
|
|
593
|
+
content.append(TextPart(text=match.group(0)))
|
|
594
|
+
remaining_command = remaining_command[end:]
|
|
595
|
+
|
|
596
|
+
if remaining_command.strip():
|
|
597
|
+
content.append(TextPart(text=remaining_command.strip()))
|
|
598
|
+
|
|
599
|
+
return UserInput(mode=self._mode, content=content, command=command)
|
|
504
600
|
|
|
505
601
|
def _append_history_entry(self, text: str) -> None:
|
|
506
602
|
entry = _HistoryEntry(content=text.strip())
|
|
@@ -549,7 +645,9 @@ class CustomPromptSession:
|
|
|
549
645
|
self._current_toast = None
|
|
550
646
|
else:
|
|
551
647
|
shortcuts = [
|
|
552
|
-
"ctrl-
|
|
648
|
+
"ctrl-x: switch mode",
|
|
649
|
+
"ctrl-j: newline",
|
|
650
|
+
"ctrl-v: paste",
|
|
553
651
|
"ctrl-d: exit",
|
|
554
652
|
]
|
|
555
653
|
for shortcut in shortcuts:
|
|
@@ -572,3 +670,107 @@ class CustomPromptSession:
|
|
|
572
670
|
def _format_status(status: StatusSnapshot) -> str:
|
|
573
671
|
bounded = max(0.0, min(status.context_usage, 1.0))
|
|
574
672
|
return f"context: {bounded:.1%}"
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def ensure_new_line() -> None:
|
|
676
|
+
"""Ensure the next prompt starts at column 0 regardless of prior command output."""
|
|
677
|
+
|
|
678
|
+
if not sys.stdout.isatty() or not sys.stdin.isatty():
|
|
679
|
+
return
|
|
680
|
+
|
|
681
|
+
needs_break = True
|
|
682
|
+
if sys.platform == "win32":
|
|
683
|
+
column = _cursor_column_windows()
|
|
684
|
+
needs_break = column not in (None, 0)
|
|
685
|
+
else:
|
|
686
|
+
column = _cursor_column_unix()
|
|
687
|
+
needs_break = column not in (None, 1)
|
|
688
|
+
|
|
689
|
+
if needs_break:
|
|
690
|
+
_write_newline()
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _cursor_column_unix() -> int | None:
|
|
694
|
+
assert sys.platform != "win32"
|
|
695
|
+
|
|
696
|
+
import select
|
|
697
|
+
import termios
|
|
698
|
+
import tty
|
|
699
|
+
|
|
700
|
+
_CURSOR_QUERY = "\x1b[6n"
|
|
701
|
+
_CURSOR_POSITION_RE = re.compile(r"\x1b\[(\d+);(\d+)R")
|
|
702
|
+
|
|
703
|
+
fd = sys.stdin.fileno()
|
|
704
|
+
oldterm = termios.tcgetattr(fd)
|
|
705
|
+
|
|
706
|
+
try:
|
|
707
|
+
tty.setcbreak(fd)
|
|
708
|
+
sys.stdout.write(_CURSOR_QUERY)
|
|
709
|
+
sys.stdout.flush()
|
|
710
|
+
|
|
711
|
+
response = ""
|
|
712
|
+
deadline = time.monotonic() + 0.2
|
|
713
|
+
while time.monotonic() < deadline:
|
|
714
|
+
timeout = max(0.01, deadline - time.monotonic())
|
|
715
|
+
ready, _, _ = select.select([sys.stdin], [], [], timeout)
|
|
716
|
+
if not ready:
|
|
717
|
+
continue
|
|
718
|
+
try:
|
|
719
|
+
chunk = os.read(fd, 32)
|
|
720
|
+
except OSError:
|
|
721
|
+
break
|
|
722
|
+
if not chunk:
|
|
723
|
+
break
|
|
724
|
+
response += chunk.decode(errors="ignore")
|
|
725
|
+
match = _CURSOR_POSITION_RE.search(response)
|
|
726
|
+
if match:
|
|
727
|
+
return int(match.group(2))
|
|
728
|
+
finally:
|
|
729
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, oldterm)
|
|
730
|
+
|
|
731
|
+
return None
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _cursor_column_windows() -> int | None:
|
|
735
|
+
assert sys.platform == "win32"
|
|
736
|
+
|
|
737
|
+
import ctypes
|
|
738
|
+
from ctypes import wintypes
|
|
739
|
+
|
|
740
|
+
kernel32 = ctypes.windll.kernel32
|
|
741
|
+
_STD_OUTPUT_HANDLE = -11 # Windows API constant for standard output handle
|
|
742
|
+
handle = kernel32.GetStdHandle(_STD_OUTPUT_HANDLE)
|
|
743
|
+
invalid_handle_value = ctypes.c_void_p(-1).value
|
|
744
|
+
if handle in (0, invalid_handle_value):
|
|
745
|
+
return None
|
|
746
|
+
|
|
747
|
+
class COORD(ctypes.Structure):
|
|
748
|
+
_fields_ = [("X", wintypes.SHORT), ("Y", wintypes.SHORT)]
|
|
749
|
+
|
|
750
|
+
class SMALL_RECT(ctypes.Structure):
|
|
751
|
+
_fields_ = [
|
|
752
|
+
("Left", wintypes.SHORT),
|
|
753
|
+
("Top", wintypes.SHORT),
|
|
754
|
+
("Right", wintypes.SHORT),
|
|
755
|
+
("Bottom", wintypes.SHORT),
|
|
756
|
+
]
|
|
757
|
+
|
|
758
|
+
class CONSOLE_SCREEN_BUFFER_INFO(ctypes.Structure):
|
|
759
|
+
_fields_ = [
|
|
760
|
+
("dwSize", COORD),
|
|
761
|
+
("dwCursorPosition", COORD),
|
|
762
|
+
("wAttributes", wintypes.WORD),
|
|
763
|
+
("srWindow", SMALL_RECT),
|
|
764
|
+
("dwMaximumWindowSize", COORD),
|
|
765
|
+
]
|
|
766
|
+
|
|
767
|
+
csbi = CONSOLE_SCREEN_BUFFER_INFO()
|
|
768
|
+
if not kernel32.GetConsoleScreenBufferInfo(handle, ctypes.byref(csbi)):
|
|
769
|
+
return None
|
|
770
|
+
|
|
771
|
+
return int(csbi.dwCursorPosition.X)
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _write_newline() -> None:
|
|
775
|
+
sys.stdout.write("\n")
|
|
776
|
+
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/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
|
@@ -25,6 +25,7 @@ class Wire:
|
|
|
25
25
|
return self._ui_side
|
|
26
26
|
|
|
27
27
|
def shutdown(self) -> None:
|
|
28
|
+
logger.debug("Shutting down wire")
|
|
28
29
|
self._queue.shutdown()
|
|
29
30
|
|
|
30
31
|
|
|
@@ -55,3 +56,15 @@ class WireUISide:
|
|
|
55
56
|
if not isinstance(msg, ContentPart | ToolCallPart):
|
|
56
57
|
logger.debug("Receiving wire message: {msg}", msg=msg)
|
|
57
58
|
return msg
|
|
59
|
+
|
|
60
|
+
def receive_nowait(self) -> WireMessage | None:
|
|
61
|
+
"""
|
|
62
|
+
Try receive a message without waiting. If no message is available, return None.
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
msg = self._queue.get_nowait()
|
|
66
|
+
except asyncio.QueueEmpty:
|
|
67
|
+
return None
|
|
68
|
+
if not isinstance(msg, ContentPart | ToolCallPart):
|
|
69
|
+
logger.debug("Receiving wire message: {msg}", msg=msg)
|
|
70
|
+
return msg
|