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.

Files changed (137) hide show
  1. kimi_cli/CHANGELOG.md +349 -40
  2. kimi_cli/__init__.py +6 -0
  3. kimi_cli/acp/AGENTS.md +91 -0
  4. kimi_cli/acp/__init__.py +13 -0
  5. kimi_cli/acp/convert.py +111 -0
  6. kimi_cli/acp/kaos.py +270 -0
  7. kimi_cli/acp/mcp.py +46 -0
  8. kimi_cli/acp/server.py +335 -0
  9. kimi_cli/acp/session.py +445 -0
  10. kimi_cli/acp/tools.py +158 -0
  11. kimi_cli/acp/types.py +13 -0
  12. kimi_cli/agents/default/agent.yaml +4 -4
  13. kimi_cli/agents/default/sub.yaml +2 -1
  14. kimi_cli/agents/default/system.md +79 -21
  15. kimi_cli/agents/okabe/agent.yaml +17 -0
  16. kimi_cli/agentspec.py +53 -25
  17. kimi_cli/app.py +180 -52
  18. kimi_cli/cli/__init__.py +595 -0
  19. kimi_cli/cli/__main__.py +8 -0
  20. kimi_cli/cli/info.py +63 -0
  21. kimi_cli/cli/mcp.py +349 -0
  22. kimi_cli/config.py +153 -17
  23. kimi_cli/constant.py +3 -0
  24. kimi_cli/exception.py +23 -2
  25. kimi_cli/flow/__init__.py +117 -0
  26. kimi_cli/flow/d2.py +376 -0
  27. kimi_cli/flow/mermaid.py +218 -0
  28. kimi_cli/llm.py +129 -23
  29. kimi_cli/metadata.py +32 -7
  30. kimi_cli/platforms.py +262 -0
  31. kimi_cli/prompts/__init__.py +2 -0
  32. kimi_cli/prompts/compact.md +4 -5
  33. kimi_cli/session.py +223 -31
  34. kimi_cli/share.py +2 -0
  35. kimi_cli/skill.py +145 -0
  36. kimi_cli/skills/kimi-cli-help/SKILL.md +55 -0
  37. kimi_cli/skills/skill-creator/SKILL.md +351 -0
  38. kimi_cli/soul/__init__.py +51 -20
  39. kimi_cli/soul/agent.py +213 -85
  40. kimi_cli/soul/approval.py +86 -17
  41. kimi_cli/soul/compaction.py +64 -53
  42. kimi_cli/soul/context.py +38 -5
  43. kimi_cli/soul/denwarenji.py +2 -0
  44. kimi_cli/soul/kimisoul.py +442 -60
  45. kimi_cli/soul/message.py +54 -54
  46. kimi_cli/soul/slash.py +72 -0
  47. kimi_cli/soul/toolset.py +387 -6
  48. kimi_cli/toad.py +74 -0
  49. kimi_cli/tools/AGENTS.md +5 -0
  50. kimi_cli/tools/__init__.py +42 -34
  51. kimi_cli/tools/display.py +25 -0
  52. kimi_cli/tools/dmail/__init__.py +10 -10
  53. kimi_cli/tools/dmail/dmail.md +11 -9
  54. kimi_cli/tools/file/__init__.py +1 -3
  55. kimi_cli/tools/file/glob.py +20 -23
  56. kimi_cli/tools/file/grep.md +1 -1
  57. kimi_cli/tools/file/{grep.py → grep_local.py} +51 -23
  58. kimi_cli/tools/file/read.md +24 -6
  59. kimi_cli/tools/file/read.py +134 -50
  60. kimi_cli/tools/file/replace.md +1 -1
  61. kimi_cli/tools/file/replace.py +36 -29
  62. kimi_cli/tools/file/utils.py +282 -0
  63. kimi_cli/tools/file/write.py +43 -22
  64. kimi_cli/tools/multiagent/__init__.py +7 -0
  65. kimi_cli/tools/multiagent/create.md +11 -0
  66. kimi_cli/tools/multiagent/create.py +50 -0
  67. kimi_cli/tools/{task/__init__.py → multiagent/task.py} +48 -53
  68. kimi_cli/tools/shell/__init__.py +120 -0
  69. kimi_cli/tools/{bash → shell}/bash.md +1 -2
  70. kimi_cli/tools/shell/powershell.md +25 -0
  71. kimi_cli/tools/test.py +4 -4
  72. kimi_cli/tools/think/__init__.py +2 -2
  73. kimi_cli/tools/todo/__init__.py +14 -8
  74. kimi_cli/tools/utils.py +64 -24
  75. kimi_cli/tools/web/fetch.py +68 -13
  76. kimi_cli/tools/web/search.py +10 -12
  77. kimi_cli/ui/acp/__init__.py +65 -412
  78. kimi_cli/ui/print/__init__.py +37 -49
  79. kimi_cli/ui/print/visualize.py +179 -0
  80. kimi_cli/ui/shell/__init__.py +141 -84
  81. kimi_cli/ui/shell/console.py +2 -0
  82. kimi_cli/ui/shell/debug.py +28 -23
  83. kimi_cli/ui/shell/keyboard.py +5 -1
  84. kimi_cli/ui/shell/prompt.py +220 -194
  85. kimi_cli/ui/shell/replay.py +111 -46
  86. kimi_cli/ui/shell/setup.py +89 -82
  87. kimi_cli/ui/shell/slash.py +422 -0
  88. kimi_cli/ui/shell/update.py +4 -2
  89. kimi_cli/ui/shell/usage.py +271 -0
  90. kimi_cli/ui/shell/visualize.py +574 -72
  91. kimi_cli/ui/wire/__init__.py +267 -0
  92. kimi_cli/ui/wire/jsonrpc.py +142 -0
  93. kimi_cli/ui/wire/protocol.py +1 -0
  94. kimi_cli/utils/__init__.py +0 -0
  95. kimi_cli/utils/aiohttp.py +2 -0
  96. kimi_cli/utils/aioqueue.py +72 -0
  97. kimi_cli/utils/broadcast.py +37 -0
  98. kimi_cli/utils/changelog.py +12 -7
  99. kimi_cli/utils/clipboard.py +12 -0
  100. kimi_cli/utils/datetime.py +37 -0
  101. kimi_cli/utils/environment.py +58 -0
  102. kimi_cli/utils/envvar.py +12 -0
  103. kimi_cli/utils/frontmatter.py +44 -0
  104. kimi_cli/utils/logging.py +7 -6
  105. kimi_cli/utils/message.py +9 -14
  106. kimi_cli/utils/path.py +99 -9
  107. kimi_cli/utils/pyinstaller.py +6 -0
  108. kimi_cli/utils/rich/__init__.py +33 -0
  109. kimi_cli/utils/rich/columns.py +99 -0
  110. kimi_cli/utils/rich/markdown.py +961 -0
  111. kimi_cli/utils/rich/markdown_sample.md +108 -0
  112. kimi_cli/utils/rich/markdown_sample_short.md +2 -0
  113. kimi_cli/utils/signals.py +2 -0
  114. kimi_cli/utils/slashcmd.py +124 -0
  115. kimi_cli/utils/string.py +2 -0
  116. kimi_cli/utils/term.py +168 -0
  117. kimi_cli/utils/typing.py +20 -0
  118. kimi_cli/wire/__init__.py +98 -29
  119. kimi_cli/wire/serde.py +45 -0
  120. kimi_cli/wire/types.py +299 -0
  121. kimi_cli-0.78.dist-info/METADATA +200 -0
  122. kimi_cli-0.78.dist-info/RECORD +135 -0
  123. kimi_cli-0.78.dist-info/entry_points.txt +4 -0
  124. kimi_cli/cli.py +0 -250
  125. kimi_cli/soul/runtime.py +0 -96
  126. kimi_cli/tools/bash/__init__.py +0 -99
  127. kimi_cli/tools/file/patch.md +0 -8
  128. kimi_cli/tools/file/patch.py +0 -143
  129. kimi_cli/tools/mcp.py +0 -85
  130. kimi_cli/ui/shell/liveview.py +0 -386
  131. kimi_cli/ui/shell/metacmd.py +0 -262
  132. kimi_cli/wire/message.py +0 -91
  133. kimi_cli-0.44.dist-info/METADATA +0 -188
  134. kimi_cli-0.44.dist-info/RECORD +0 -89
  135. kimi_cli-0.44.dist-info/entry_points.txt +0 -3
  136. /kimi_cli/tools/{task → multiagent}/task.md +0 -0
  137. {kimi_cli-0.44.dist-info → kimi_cli-0.78.dist-info}/WHEEL +0 -0
@@ -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.abc import Callable
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 kosong.base.message import ContentPart, ImageURLPart, TextPart
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 Always, Never, has_completions
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.metacmd import get_meta_commands
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 MetaCommandCompleter(Completer):
49
- """A completer that:
50
- - Shows one line per meta command in the form: "/name (alias1, alias2)"
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(self, document, complete_event):
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(get_meta_commands(), key=lambda c: c.name):
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 FileMentionCompleter(Completer):
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 FileMentionCompleter._TRIGGER_GUARDS:
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(self, document, complete_event):
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
- yield from self._fuzzy.get_completions(mention_doc, complete_event)
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) -> "PromptMode":
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
- def toast(message: str, duration: float = 5.0) -> None:
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
- _toast_queue.put_nowait((message, duration))
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__(self, status_provider: Callable[[], StatusSnapshot]):
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(Path.cwd()).encode()).hexdigest()
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
- MetaCommandCompleter(),
422
- FileMentionCompleter(Path.cwd()),
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 _accept_completion(event: KeyPressEvent) -> None:
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 _switch_mode(event: KeyPressEvent) -> None:
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("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)
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=PyperclipClipboard(),
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
- self._status_refresh_task: asyncio.Task | None = None
474
- self._current_toast: str | None = None
475
- self._current_toast_duration: float = 0.0
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
- return FormattedText([("bold", f"{getpass.getuser()}{symbol} ")])
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 = DummyCompleter()
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) -> "CustomPromptSession":
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, exc_type, exc_value, traceback) -> None:
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 isinstance(image, list):
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
- image = None
662
+ return False
547
663
 
548
- if image is None:
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(mode=self._mode, content=content, command=command)
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
- status = self._status_provider()
639
- status_text = self._format_status(status)
640
-
641
- if self._current_toast is not None:
642
- fragments.extend([("", self._current_toast), ("", " " * 2)])
643
- columns -= len(self._current_toast) + 2
644
- self._current_toast_duration -= _REFRESH_INTERVAL
645
- if self._current_toast_duration <= 0.0:
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
- "ctrl-x: switch mode",
650
- "ctrl-j: newline",
651
- "ctrl-v: paste",
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
- if self._current_toast is None and not _toast_queue.empty():
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(("", status_text))
789
+ fragments.append(("", right_text))
667
790
 
668
791
  return FormattedText(fragments)
669
792
 
670
793
  @staticmethod
671
- def _format_status(status: StatusSnapshot) -> str:
672
- bounded = max(0.0, min(status.context_usage, 1.0))
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()
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