kimi-cli 0.44__py3-none-any.whl → 0.78__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 +349 -40
- kimi_cli/__init__.py +6 -0
- kimi_cli/acp/AGENTS.md +91 -0
- kimi_cli/acp/__init__.py +13 -0
- kimi_cli/acp/convert.py +111 -0
- kimi_cli/acp/kaos.py +270 -0
- kimi_cli/acp/mcp.py +46 -0
- kimi_cli/acp/server.py +335 -0
- kimi_cli/acp/session.py +445 -0
- kimi_cli/acp/tools.py +158 -0
- kimi_cli/acp/types.py +13 -0
- kimi_cli/agents/default/agent.yaml +4 -4
- kimi_cli/agents/default/sub.yaml +2 -1
- kimi_cli/agents/default/system.md +79 -21
- kimi_cli/agents/okabe/agent.yaml +17 -0
- kimi_cli/agentspec.py +53 -25
- kimi_cli/app.py +180 -52
- kimi_cli/cli/__init__.py +595 -0
- kimi_cli/cli/__main__.py +8 -0
- kimi_cli/cli/info.py +63 -0
- kimi_cli/cli/mcp.py +349 -0
- kimi_cli/config.py +153 -17
- kimi_cli/constant.py +3 -0
- kimi_cli/exception.py +23 -2
- kimi_cli/flow/__init__.py +117 -0
- kimi_cli/flow/d2.py +376 -0
- kimi_cli/flow/mermaid.py +218 -0
- kimi_cli/llm.py +129 -23
- kimi_cli/metadata.py +32 -7
- kimi_cli/platforms.py +262 -0
- kimi_cli/prompts/__init__.py +2 -0
- kimi_cli/prompts/compact.md +4 -5
- kimi_cli/session.py +223 -31
- kimi_cli/share.py +2 -0
- kimi_cli/skill.py +145 -0
- kimi_cli/skills/kimi-cli-help/SKILL.md +55 -0
- kimi_cli/skills/skill-creator/SKILL.md +351 -0
- kimi_cli/soul/__init__.py +51 -20
- kimi_cli/soul/agent.py +213 -85
- kimi_cli/soul/approval.py +86 -17
- kimi_cli/soul/compaction.py +64 -53
- kimi_cli/soul/context.py +38 -5
- kimi_cli/soul/denwarenji.py +2 -0
- kimi_cli/soul/kimisoul.py +442 -60
- kimi_cli/soul/message.py +54 -54
- kimi_cli/soul/slash.py +72 -0
- kimi_cli/soul/toolset.py +387 -6
- kimi_cli/toad.py +74 -0
- kimi_cli/tools/AGENTS.md +5 -0
- kimi_cli/tools/__init__.py +42 -34
- kimi_cli/tools/display.py +25 -0
- kimi_cli/tools/dmail/__init__.py +10 -10
- kimi_cli/tools/dmail/dmail.md +11 -9
- kimi_cli/tools/file/__init__.py +1 -3
- kimi_cli/tools/file/glob.py +20 -23
- kimi_cli/tools/file/grep.md +1 -1
- kimi_cli/tools/file/{grep.py → grep_local.py} +51 -23
- kimi_cli/tools/file/read.md +24 -6
- kimi_cli/tools/file/read.py +134 -50
- kimi_cli/tools/file/replace.md +1 -1
- kimi_cli/tools/file/replace.py +36 -29
- kimi_cli/tools/file/utils.py +282 -0
- kimi_cli/tools/file/write.py +43 -22
- kimi_cli/tools/multiagent/__init__.py +7 -0
- kimi_cli/tools/multiagent/create.md +11 -0
- kimi_cli/tools/multiagent/create.py +50 -0
- kimi_cli/tools/{task/__init__.py → multiagent/task.py} +48 -53
- kimi_cli/tools/shell/__init__.py +120 -0
- kimi_cli/tools/{bash → shell}/bash.md +1 -2
- kimi_cli/tools/shell/powershell.md +25 -0
- kimi_cli/tools/test.py +4 -4
- kimi_cli/tools/think/__init__.py +2 -2
- kimi_cli/tools/todo/__init__.py +14 -8
- kimi_cli/tools/utils.py +64 -24
- kimi_cli/tools/web/fetch.py +68 -13
- kimi_cli/tools/web/search.py +10 -12
- kimi_cli/ui/acp/__init__.py +65 -412
- kimi_cli/ui/print/__init__.py +37 -49
- kimi_cli/ui/print/visualize.py +179 -0
- kimi_cli/ui/shell/__init__.py +141 -84
- kimi_cli/ui/shell/console.py +2 -0
- kimi_cli/ui/shell/debug.py +28 -23
- kimi_cli/ui/shell/keyboard.py +5 -1
- kimi_cli/ui/shell/prompt.py +220 -194
- kimi_cli/ui/shell/replay.py +111 -46
- kimi_cli/ui/shell/setup.py +89 -82
- kimi_cli/ui/shell/slash.py +422 -0
- kimi_cli/ui/shell/update.py +4 -2
- kimi_cli/ui/shell/usage.py +271 -0
- kimi_cli/ui/shell/visualize.py +574 -72
- kimi_cli/ui/wire/__init__.py +267 -0
- kimi_cli/ui/wire/jsonrpc.py +142 -0
- kimi_cli/ui/wire/protocol.py +1 -0
- kimi_cli/utils/__init__.py +0 -0
- kimi_cli/utils/aiohttp.py +2 -0
- kimi_cli/utils/aioqueue.py +72 -0
- kimi_cli/utils/broadcast.py +37 -0
- kimi_cli/utils/changelog.py +12 -7
- kimi_cli/utils/clipboard.py +12 -0
- kimi_cli/utils/datetime.py +37 -0
- kimi_cli/utils/environment.py +58 -0
- kimi_cli/utils/envvar.py +12 -0
- kimi_cli/utils/frontmatter.py +44 -0
- kimi_cli/utils/logging.py +7 -6
- kimi_cli/utils/message.py +9 -14
- kimi_cli/utils/path.py +99 -9
- kimi_cli/utils/pyinstaller.py +6 -0
- kimi_cli/utils/rich/__init__.py +33 -0
- kimi_cli/utils/rich/columns.py +99 -0
- kimi_cli/utils/rich/markdown.py +961 -0
- kimi_cli/utils/rich/markdown_sample.md +108 -0
- kimi_cli/utils/rich/markdown_sample_short.md +2 -0
- kimi_cli/utils/signals.py +2 -0
- kimi_cli/utils/slashcmd.py +124 -0
- kimi_cli/utils/string.py +2 -0
- kimi_cli/utils/term.py +168 -0
- kimi_cli/utils/typing.py +20 -0
- kimi_cli/wire/__init__.py +98 -29
- kimi_cli/wire/serde.py +45 -0
- kimi_cli/wire/types.py +299 -0
- kimi_cli-0.78.dist-info/METADATA +200 -0
- kimi_cli-0.78.dist-info/RECORD +135 -0
- kimi_cli-0.78.dist-info/entry_points.txt +4 -0
- kimi_cli/cli.py +0 -250
- kimi_cli/soul/runtime.py +0 -96
- kimi_cli/tools/bash/__init__.py +0 -99
- kimi_cli/tools/file/patch.md +0 -8
- kimi_cli/tools/file/patch.py +0 -143
- kimi_cli/tools/mcp.py +0 -85
- kimi_cli/ui/shell/liveview.py +0 -386
- kimi_cli/ui/shell/metacmd.py +0 -262
- kimi_cli/wire/message.py +0 -91
- kimi_cli-0.44.dist-info/METADATA +0 -188
- kimi_cli-0.44.dist-info/RECORD +0 -89
- kimi_cli-0.44.dist-info/entry_points.txt +0 -3
- /kimi_cli/tools/{task → multiagent}/task.md +0 -0
- {kimi_cli-0.44.dist-info → kimi_cli-0.78.dist-info}/WHEEL +0 -0
kimi_cli/ui/shell/prompt.py
CHANGED
|
@@ -1,59 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import asyncio
|
|
2
4
|
import base64
|
|
3
|
-
import contextlib
|
|
4
5
|
import getpass
|
|
5
6
|
import json
|
|
6
7
|
import os
|
|
7
8
|
import re
|
|
8
|
-
import sys
|
|
9
9
|
import time
|
|
10
|
-
from collections
|
|
10
|
+
from collections import deque
|
|
11
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
11
13
|
from datetime import datetime
|
|
12
14
|
from enum import Enum
|
|
13
15
|
from hashlib import md5
|
|
14
16
|
from io import BytesIO
|
|
15
17
|
from pathlib import Path
|
|
16
|
-
from typing import override
|
|
18
|
+
from typing import Any, Literal, override
|
|
17
19
|
|
|
18
|
-
from
|
|
20
|
+
from kaos.path import KaosPath
|
|
19
21
|
from PIL import Image, ImageGrab
|
|
20
22
|
from prompt_toolkit import PromptSession
|
|
21
23
|
from prompt_toolkit.application.current import get_app_or_none
|
|
24
|
+
from prompt_toolkit.buffer import Buffer
|
|
22
25
|
from prompt_toolkit.clipboard.pyperclip import PyperclipClipboard
|
|
23
26
|
from prompt_toolkit.completion import (
|
|
27
|
+
CompleteEvent,
|
|
24
28
|
Completer,
|
|
25
29
|
Completion,
|
|
26
|
-
DummyCompleter,
|
|
27
30
|
FuzzyCompleter,
|
|
28
31
|
WordCompleter,
|
|
29
32
|
merge_completers,
|
|
30
33
|
)
|
|
31
34
|
from prompt_toolkit.document import Document
|
|
32
|
-
from prompt_toolkit.filters import
|
|
35
|
+
from prompt_toolkit.filters import has_completions
|
|
33
36
|
from prompt_toolkit.formatted_text import FormattedText
|
|
34
37
|
from prompt_toolkit.history import InMemoryHistory
|
|
35
38
|
from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
|
|
36
39
|
from prompt_toolkit.patch_stdout import patch_stdout
|
|
40
|
+
from prompt_toolkit.styles import Style
|
|
37
41
|
from pydantic import BaseModel, ValidationError
|
|
38
42
|
|
|
43
|
+
from kimi_cli.llm import ModelCapability
|
|
39
44
|
from kimi_cli.share import get_share_dir
|
|
40
45
|
from kimi_cli.soul import StatusSnapshot
|
|
41
|
-
from kimi_cli.ui.shell.
|
|
46
|
+
from kimi_cli.ui.shell.console import console
|
|
47
|
+
from kimi_cli.utils.clipboard import is_clipboard_available
|
|
42
48
|
from kimi_cli.utils.logging import logger
|
|
49
|
+
from kimi_cli.utils.slashcmd import SlashCommand
|
|
43
50
|
from kimi_cli.utils.string import random_string
|
|
51
|
+
from kimi_cli.wire.types import ContentPart, ImageURLPart, TextPart
|
|
44
52
|
|
|
45
53
|
PROMPT_SYMBOL = "✨"
|
|
54
|
+
PROMPT_SYMBOL_SHELL = "$"
|
|
55
|
+
PROMPT_SYMBOL_THINKING = "💫"
|
|
46
56
|
|
|
47
57
|
|
|
48
|
-
class
|
|
49
|
-
"""
|
|
50
|
-
|
|
58
|
+
class SlashCommandCompleter(Completer):
|
|
59
|
+
"""
|
|
60
|
+
A completer that:
|
|
61
|
+
- Shows one line per slash command in the form: "/name (alias1, alias2)"
|
|
51
62
|
- Matches by primary name or any alias while inserting the canonical "/name"
|
|
52
63
|
- Only activates when the current token starts with '/'
|
|
53
64
|
"""
|
|
54
65
|
|
|
66
|
+
def __init__(self, available_commands: Sequence[SlashCommand[Any]]) -> None:
|
|
67
|
+
super().__init__()
|
|
68
|
+
self._available_commands = available_commands
|
|
69
|
+
|
|
55
70
|
@override
|
|
56
|
-
def get_completions(
|
|
71
|
+
def get_completions(
|
|
72
|
+
self, document: Document, complete_event: CompleteEvent
|
|
73
|
+
) -> Iterable[Completion]:
|
|
57
74
|
text = document.text_before_cursor
|
|
58
75
|
|
|
59
76
|
# Only autocomplete when the input buffer has no other content.
|
|
@@ -73,7 +90,7 @@ class MetaCommandCompleter(Completer):
|
|
|
73
90
|
typed = token[1:]
|
|
74
91
|
typed_lower = typed.lower()
|
|
75
92
|
|
|
76
|
-
for cmd in sorted(
|
|
93
|
+
for cmd in sorted(self._available_commands, key=lambda c: c.name):
|
|
77
94
|
names = [cmd.name] + list(cmd.aliases)
|
|
78
95
|
if typed == "" or any(n.lower().startswith(typed_lower) for n in names):
|
|
79
96
|
yield Completion(
|
|
@@ -84,7 +101,7 @@ class MetaCommandCompleter(Completer):
|
|
|
84
101
|
)
|
|
85
102
|
|
|
86
103
|
|
|
87
|
-
class
|
|
104
|
+
class LocalFileMentionCompleter(Completer):
|
|
88
105
|
"""Offer fuzzy `@` path completion by indexing workspace files."""
|
|
89
106
|
|
|
90
107
|
_FRAGMENT_PATTERN = re.compile(r"[^\s@]+")
|
|
@@ -276,7 +293,7 @@ class FileMentionCompleter(Completer):
|
|
|
276
293
|
|
|
277
294
|
if index > 0:
|
|
278
295
|
prev = text[index - 1]
|
|
279
|
-
if prev.isalnum() or prev in
|
|
296
|
+
if prev.isalnum() or prev in LocalFileMentionCompleter._TRIGGER_GUARDS:
|
|
280
297
|
return None
|
|
281
298
|
|
|
282
299
|
fragment = text[index + 1 :]
|
|
@@ -298,7 +315,9 @@ class FileMentionCompleter(Completer):
|
|
|
298
315
|
return False
|
|
299
316
|
|
|
300
317
|
@override
|
|
301
|
-
def get_completions(
|
|
318
|
+
def get_completions(
|
|
319
|
+
self, document: Document, complete_event: CompleteEvent
|
|
320
|
+
) -> Iterable[Completion]:
|
|
302
321
|
fragment = self._extract_fragment(document.text_before_cursor)
|
|
303
322
|
if fragment is None:
|
|
304
323
|
return
|
|
@@ -308,7 +327,26 @@ class FileMentionCompleter(Completer):
|
|
|
308
327
|
mention_doc = Document(text=fragment, cursor_position=len(fragment))
|
|
309
328
|
self._fragment_hint = fragment
|
|
310
329
|
try:
|
|
311
|
-
|
|
330
|
+
# First, ask the fuzzy completer for candidates.
|
|
331
|
+
candidates = list(self._fuzzy.get_completions(mention_doc, complete_event))
|
|
332
|
+
|
|
333
|
+
# re-rank: prefer basename matches
|
|
334
|
+
frag_lower = fragment.lower()
|
|
335
|
+
|
|
336
|
+
def _rank(c: Completion) -> tuple[int, ...]:
|
|
337
|
+
path = c.text
|
|
338
|
+
base = path.rstrip("/").split("/")[-1].lower()
|
|
339
|
+
if base.startswith(frag_lower):
|
|
340
|
+
cat = 0
|
|
341
|
+
elif frag_lower in base:
|
|
342
|
+
cat = 1
|
|
343
|
+
else:
|
|
344
|
+
cat = 2
|
|
345
|
+
# preserve original FuzzyCompleter's order in the same category
|
|
346
|
+
return (cat,)
|
|
347
|
+
|
|
348
|
+
candidates.sort(key=_rank)
|
|
349
|
+
yield from candidates
|
|
312
350
|
finally:
|
|
313
351
|
self._fragment_hint = None
|
|
314
352
|
|
|
@@ -359,7 +397,7 @@ class PromptMode(Enum):
|
|
|
359
397
|
AGENT = "agent"
|
|
360
398
|
SHELL = "shell"
|
|
361
399
|
|
|
362
|
-
def toggle(self) ->
|
|
400
|
+
def toggle(self) -> PromptMode:
|
|
363
401
|
return PromptMode.SHELL if self == PromptMode.AGENT else PromptMode.AGENT
|
|
364
402
|
|
|
365
403
|
def __str__(self) -> str:
|
|
@@ -381,12 +419,49 @@ class UserInput(BaseModel):
|
|
|
381
419
|
|
|
382
420
|
|
|
383
421
|
_REFRESH_INTERVAL = 1.0
|
|
384
|
-
_toast_queue: asyncio.Queue[tuple[str, float]] = asyncio.Queue()
|
|
385
422
|
|
|
386
423
|
|
|
387
|
-
|
|
424
|
+
@dataclass(slots=True)
|
|
425
|
+
class _ToastEntry:
|
|
426
|
+
topic: str | None
|
|
427
|
+
"""There can be only one toast of each non-None topic in the queue."""
|
|
428
|
+
message: str
|
|
429
|
+
duration: float
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
_toast_queues: dict[Literal["left", "right"], deque[_ToastEntry]] = {
|
|
433
|
+
"left": deque(),
|
|
434
|
+
"right": deque(),
|
|
435
|
+
}
|
|
436
|
+
"""The queue of toasts to show, including the one currently being shown (the first one)."""
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def toast(
|
|
440
|
+
message: str,
|
|
441
|
+
duration: float = 5.0,
|
|
442
|
+
topic: str | None = None,
|
|
443
|
+
immediate: bool = False,
|
|
444
|
+
position: Literal["left", "right"] = "left",
|
|
445
|
+
) -> None:
|
|
446
|
+
queue = _toast_queues[position]
|
|
388
447
|
duration = max(duration, _REFRESH_INTERVAL)
|
|
389
|
-
|
|
448
|
+
entry = _ToastEntry(topic=topic, message=message, duration=duration)
|
|
449
|
+
if topic is not None:
|
|
450
|
+
# Remove existing toasts with the same topic
|
|
451
|
+
for existing in list(queue):
|
|
452
|
+
if existing.topic == topic:
|
|
453
|
+
queue.remove(existing)
|
|
454
|
+
if immediate:
|
|
455
|
+
queue.appendleft(entry)
|
|
456
|
+
else:
|
|
457
|
+
queue.append(entry)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _current_toast(position: Literal["left", "right"] = "left") -> _ToastEntry | None:
|
|
461
|
+
queue = _toast_queues[position]
|
|
462
|
+
if not queue:
|
|
463
|
+
return None
|
|
464
|
+
return queue[0]
|
|
390
465
|
|
|
391
466
|
|
|
392
467
|
_ATTACHMENT_PLACEHOLDER_RE = re.compile(
|
|
@@ -394,15 +469,36 @@ _ATTACHMENT_PLACEHOLDER_RE = re.compile(
|
|
|
394
469
|
)
|
|
395
470
|
|
|
396
471
|
|
|
472
|
+
def _sanitize_surrogates(text: str) -> str:
|
|
473
|
+
"""Sanitize UTF-16 surrogate characters that cannot be encoded to UTF-8.
|
|
474
|
+
|
|
475
|
+
This is particularly common on Windows when copying text from applications
|
|
476
|
+
that use UTF-16 internally and don't properly convert surrogate pairs.
|
|
477
|
+
"""
|
|
478
|
+
return text.encode("utf-8", errors="surrogatepass").decode("utf-8", errors="replace")
|
|
479
|
+
|
|
480
|
+
|
|
397
481
|
class CustomPromptSession:
|
|
398
|
-
def __init__(
|
|
482
|
+
def __init__(
|
|
483
|
+
self,
|
|
484
|
+
*,
|
|
485
|
+
status_provider: Callable[[], StatusSnapshot],
|
|
486
|
+
model_capabilities: set[ModelCapability],
|
|
487
|
+
model_name: str | None,
|
|
488
|
+
thinking: bool,
|
|
489
|
+
agent_mode_slash_commands: Sequence[SlashCommand[Any]],
|
|
490
|
+
shell_mode_slash_commands: Sequence[SlashCommand[Any]],
|
|
491
|
+
) -> None:
|
|
399
492
|
history_dir = get_share_dir() / "user-history"
|
|
400
493
|
history_dir.mkdir(parents=True, exist_ok=True)
|
|
401
|
-
work_dir_id = md5(str(
|
|
494
|
+
work_dir_id = md5(str(KaosPath.cwd()).encode(encoding="utf-8")).hexdigest()
|
|
402
495
|
self._history_file = (history_dir / work_dir_id).with_suffix(".jsonl")
|
|
403
496
|
self._status_provider = status_provider
|
|
497
|
+
self._model_capabilities = model_capabilities
|
|
498
|
+
self._model_name = model_name
|
|
404
499
|
self._last_history_content: str | None = None
|
|
405
500
|
self._mode: PromptMode = PromptMode.AGENT
|
|
501
|
+
self._thinking = thinking
|
|
406
502
|
self._attachment_parts: dict[str, ContentPart] = {}
|
|
407
503
|
"""Mapping from attachment id to ContentPart."""
|
|
408
504
|
|
|
@@ -418,17 +514,19 @@ class CustomPromptSession:
|
|
|
418
514
|
# Build completers
|
|
419
515
|
self._agent_mode_completer = merge_completers(
|
|
420
516
|
[
|
|
421
|
-
|
|
422
|
-
|
|
517
|
+
SlashCommandCompleter(agent_mode_slash_commands),
|
|
518
|
+
# TODO(kaos): we need an async KaosFileMentionCompleter
|
|
519
|
+
LocalFileMentionCompleter(KaosPath.cwd().unsafe_to_local_path()),
|
|
423
520
|
],
|
|
424
521
|
deduplicate=True,
|
|
425
522
|
)
|
|
523
|
+
self._shell_mode_completer = SlashCommandCompleter(shell_mode_slash_commands)
|
|
426
524
|
|
|
427
525
|
# Build key bindings
|
|
428
526
|
_kb = KeyBindings()
|
|
429
527
|
|
|
430
528
|
@_kb.add("enter", filter=has_completions)
|
|
431
|
-
def
|
|
529
|
+
def _(event: KeyPressEvent) -> None:
|
|
432
530
|
"""Accept the first completion when Enter is pressed and completions are shown."""
|
|
433
531
|
buff = event.current_buffer
|
|
434
532
|
if buff.complete_state and buff.complete_state.completions:
|
|
@@ -438,45 +536,66 @@ class CustomPromptSession:
|
|
|
438
536
|
completion = buff.complete_state.completions[0]
|
|
439
537
|
buff.apply_completion(completion)
|
|
440
538
|
|
|
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
539
|
@_kb.add("c-x", eager=True)
|
|
448
|
-
def
|
|
540
|
+
def _(event: KeyPressEvent) -> None:
|
|
449
541
|
self._mode = self._mode.toggle()
|
|
450
542
|
# Apply mode-specific settings
|
|
451
543
|
self._apply_mode(event)
|
|
452
544
|
# Redraw UI
|
|
453
545
|
event.app.invalidate()
|
|
454
546
|
|
|
455
|
-
@_kb.add("
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
547
|
+
@_kb.add("escape", "enter", eager=True)
|
|
548
|
+
@_kb.add("c-j", eager=True)
|
|
549
|
+
def _(event: KeyPressEvent) -> None:
|
|
550
|
+
"""Insert a newline when Alt-Enter or Ctrl-J is pressed."""
|
|
551
|
+
event.current_buffer.insert_text("\n")
|
|
552
|
+
|
|
553
|
+
if is_clipboard_available():
|
|
554
|
+
|
|
555
|
+
@_kb.add("c-v", eager=True)
|
|
556
|
+
def _(event: KeyPressEvent) -> None:
|
|
557
|
+
if self._try_paste_image(event):
|
|
558
|
+
return
|
|
559
|
+
clipboard_data = event.app.clipboard.get_data()
|
|
560
|
+
event.current_buffer.paste_clipboard_data(clipboard_data)
|
|
561
|
+
|
|
562
|
+
clipboard = PyperclipClipboard()
|
|
563
|
+
else:
|
|
564
|
+
clipboard = None
|
|
565
|
+
|
|
566
|
+
@_kb.add("c-_", eager=True) # Ctrl-/ sends Ctrl-_ in most terminals
|
|
567
|
+
def _(event: KeyPressEvent) -> None:
|
|
568
|
+
"""Show help by submitting /help command."""
|
|
569
|
+
buff = event.current_buffer
|
|
570
|
+
buff.text = "/help"
|
|
571
|
+
buff.validate_and_handle()
|
|
461
572
|
|
|
462
|
-
self._session = PromptSession(
|
|
573
|
+
self._session = PromptSession[str](
|
|
463
574
|
message=self._render_message,
|
|
464
575
|
# prompt_continuation=FormattedText([("fg:#4d4d4d", "... ")]),
|
|
465
576
|
completer=self._agent_mode_completer,
|
|
466
577
|
complete_while_typing=True,
|
|
467
578
|
key_bindings=_kb,
|
|
468
|
-
clipboard=
|
|
579
|
+
clipboard=clipboard,
|
|
469
580
|
history=history,
|
|
470
581
|
bottom_toolbar=self._render_bottom_toolbar,
|
|
582
|
+
style=Style.from_dict({"bottom-toolbar": "noreverse"}),
|
|
471
583
|
)
|
|
472
584
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
self.
|
|
585
|
+
# Allow completion to be triggered when the text is changed,
|
|
586
|
+
# such as when backspace is used to delete text.
|
|
587
|
+
@self._session.default_buffer.on_text_changed.add_handler
|
|
588
|
+
def _(buffer: Buffer) -> None:
|
|
589
|
+
if buffer.complete_while_typing():
|
|
590
|
+
buffer.start_completion()
|
|
591
|
+
|
|
592
|
+
self._status_refresh_task: asyncio.Task[None] | None = None
|
|
476
593
|
|
|
477
594
|
def _render_message(self) -> FormattedText:
|
|
478
|
-
symbol = PROMPT_SYMBOL if self._mode == PromptMode.AGENT else
|
|
479
|
-
|
|
595
|
+
symbol = PROMPT_SYMBOL if self._mode == PromptMode.AGENT else PROMPT_SYMBOL_SHELL
|
|
596
|
+
if self._mode == PromptMode.AGENT and self._thinking:
|
|
597
|
+
symbol = PROMPT_SYMBOL_THINKING
|
|
598
|
+
return FormattedText([("bold", f"{getpass.getuser()}@{KaosPath.cwd().name}{symbol} ")])
|
|
480
599
|
|
|
481
600
|
def _apply_mode(self, event: KeyPressEvent | None = None) -> None:
|
|
482
601
|
# Apply mode to the active buffer (not the PromptSession itself)
|
|
@@ -486,19 +605,13 @@ class CustomPromptSession:
|
|
|
486
605
|
buff = None
|
|
487
606
|
|
|
488
607
|
if self._mode == PromptMode.SHELL:
|
|
489
|
-
# Cancel any active completion menu
|
|
490
|
-
with contextlib.suppress(Exception):
|
|
491
|
-
if buff is not None:
|
|
492
|
-
buff.cancel_completion()
|
|
493
608
|
if buff is not None:
|
|
494
|
-
buff.completer =
|
|
495
|
-
buff.complete_while_typing = Never()
|
|
609
|
+
buff.completer = self._shell_mode_completer
|
|
496
610
|
else:
|
|
497
611
|
if buff is not None:
|
|
498
612
|
buff.completer = self._agent_mode_completer
|
|
499
|
-
buff.complete_while_typing = Always()
|
|
500
613
|
|
|
501
|
-
def __enter__(self) ->
|
|
614
|
+
def __enter__(self) -> CustomPromptSession:
|
|
502
615
|
if self._status_refresh_task is not None and not self._status_refresh_task.done():
|
|
503
616
|
return self
|
|
504
617
|
|
|
@@ -524,7 +637,7 @@ class CustomPromptSession:
|
|
|
524
637
|
self._status_refresh_task = asyncio.create_task(_refresh(_REFRESH_INTERVAL))
|
|
525
638
|
return self
|
|
526
639
|
|
|
527
|
-
def __exit__(self,
|
|
640
|
+
def __exit__(self, *_) -> None:
|
|
528
641
|
if self._status_refresh_task is not None and not self._status_refresh_task.done():
|
|
529
642
|
self._status_refresh_task.cancel()
|
|
530
643
|
self._status_refresh_task = None
|
|
@@ -534,7 +647,10 @@ class CustomPromptSession:
|
|
|
534
647
|
"""Try to paste an image from the clipboard. Return True if successful."""
|
|
535
648
|
# Try get image from clipboard
|
|
536
649
|
image = ImageGrab.grabclipboard()
|
|
537
|
-
if
|
|
650
|
+
if image is None:
|
|
651
|
+
return False
|
|
652
|
+
|
|
653
|
+
if not isinstance(image, Image.Image):
|
|
538
654
|
for item in image:
|
|
539
655
|
try:
|
|
540
656
|
with Image.open(item) as img:
|
|
@@ -543,9 +659,10 @@ class CustomPromptSession:
|
|
|
543
659
|
except Exception:
|
|
544
660
|
continue
|
|
545
661
|
else:
|
|
546
|
-
|
|
662
|
+
return False
|
|
547
663
|
|
|
548
|
-
if
|
|
664
|
+
if "image_in" not in self._model_capabilities:
|
|
665
|
+
console.print("[yellow]Image input is not supported by the selected LLM model[/yellow]")
|
|
549
666
|
return False
|
|
550
667
|
|
|
551
668
|
attachment_id = f"{random_string(8)}.png"
|
|
@@ -570,9 +687,11 @@ class CustomPromptSession:
|
|
|
570
687
|
return True
|
|
571
688
|
|
|
572
689
|
async def prompt(self) -> UserInput:
|
|
573
|
-
with patch_stdout():
|
|
690
|
+
with patch_stdout(raw=True):
|
|
574
691
|
command = str(await self._session.prompt_async()).strip()
|
|
575
692
|
command = command.replace("\x00", "") # just in case null bytes are somehow inserted
|
|
693
|
+
# Sanitize UTF-16 surrogates that may come from Windows clipboard
|
|
694
|
+
command = _sanitize_surrogates(command)
|
|
576
695
|
self._append_history_entry(command)
|
|
577
696
|
|
|
578
697
|
# Parse rich content parts
|
|
@@ -597,7 +716,11 @@ class CustomPromptSession:
|
|
|
597
716
|
if remaining_command.strip():
|
|
598
717
|
content.append(TextPart(text=remaining_command.strip()))
|
|
599
718
|
|
|
600
|
-
return UserInput(
|
|
719
|
+
return UserInput(
|
|
720
|
+
mode=self._mode,
|
|
721
|
+
content=content,
|
|
722
|
+
command=command,
|
|
723
|
+
)
|
|
601
724
|
|
|
602
725
|
def _append_history_entry(self, text: str) -> None:
|
|
603
726
|
entry = _HistoryEntry(content=text.strip())
|
|
@@ -632,146 +755,49 @@ class CustomPromptSession:
|
|
|
632
755
|
columns -= len(now_text) + 2
|
|
633
756
|
|
|
634
757
|
mode = str(self._mode).lower()
|
|
758
|
+
if self._mode == PromptMode.AGENT:
|
|
759
|
+
mode_details: list[str] = []
|
|
760
|
+
if self._model_name:
|
|
761
|
+
mode_details.append(self._model_name)
|
|
762
|
+
if self._thinking:
|
|
763
|
+
mode_details.append("thinking")
|
|
764
|
+
if mode_details:
|
|
765
|
+
mode += f" ({', '.join(mode_details)})"
|
|
766
|
+
status = self._status_provider()
|
|
767
|
+
if status.yolo_enabled:
|
|
768
|
+
fragments.extend([("bold fg:#ffff00", "yolo"), ("", " " * 2)])
|
|
769
|
+
columns -= len("yolo") + 2
|
|
635
770
|
fragments.extend([("", f"{mode}"), ("", " " * 2)])
|
|
636
771
|
columns -= len(mode) + 2
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
self._current_toast = None
|
|
772
|
+
right_text = self._render_right_span(status)
|
|
773
|
+
|
|
774
|
+
current_toast_left = _current_toast("left")
|
|
775
|
+
if current_toast_left is not None:
|
|
776
|
+
fragments.extend([("", current_toast_left.message), ("", " " * 2)])
|
|
777
|
+
columns -= len(current_toast_left.message) + 2
|
|
778
|
+
current_toast_left.duration -= _REFRESH_INTERVAL
|
|
779
|
+
if current_toast_left.duration <= 0.0:
|
|
780
|
+
_toast_queues["left"].popleft()
|
|
647
781
|
else:
|
|
648
|
-
shortcuts =
|
|
649
|
-
|
|
650
|
-
"
|
|
651
|
-
|
|
652
|
-
"ctrl-d: exit",
|
|
653
|
-
]
|
|
654
|
-
for shortcut in shortcuts:
|
|
655
|
-
if columns - len(status_text) > len(shortcut) + 2:
|
|
656
|
-
fragments.extend([("", shortcut), ("", " " * 2)])
|
|
657
|
-
columns -= len(shortcut) + 2
|
|
658
|
-
else:
|
|
659
|
-
break
|
|
782
|
+
shortcuts = "ctrl-x: toggle mode ctrl-/: help"
|
|
783
|
+
if columns - len(right_text) > len(shortcuts) + 2:
|
|
784
|
+
fragments.extend([("", shortcuts), ("", " " * 2)])
|
|
785
|
+
columns -= len(shortcuts) + 2
|
|
660
786
|
|
|
661
|
-
|
|
662
|
-
self._current_toast, self._current_toast_duration = _toast_queue.get_nowait()
|
|
663
|
-
|
|
664
|
-
padding = max(1, columns - len(status_text))
|
|
787
|
+
padding = max(1, columns - len(right_text))
|
|
665
788
|
fragments.append(("", " " * padding))
|
|
666
|
-
fragments.append(("",
|
|
789
|
+
fragments.append(("", right_text))
|
|
667
790
|
|
|
668
791
|
return FormattedText(fragments)
|
|
669
792
|
|
|
670
793
|
@staticmethod
|
|
671
|
-
def
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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()
|
|
794
|
+
def _render_right_span(status: StatusSnapshot) -> str:
|
|
795
|
+
current_toast = _current_toast("right")
|
|
796
|
+
if current_toast is None:
|
|
797
|
+
bounded = max(0.0, min(status.context_usage, 1.0))
|
|
798
|
+
return f"context: {bounded:.1%}"
|
|
799
|
+
|
|
800
|
+
current_toast.duration -= _REFRESH_INTERVAL
|
|
801
|
+
if current_toast.duration <= 0.0:
|
|
802
|
+
_toast_queues["right"].popleft()
|
|
803
|
+
return current_toast.message
|