kimi-cli 0.42__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 +13 -0
- kimi_cli/__init__.py +48 -11
- kimi_cli/config.py +7 -2
- kimi_cli/llm.py +31 -4
- kimi_cli/soul/__init__.py +22 -4
- kimi_cli/soul/kimisoul.py +21 -4
- kimi_cli/soul/message.py +1 -1
- kimi_cli/soul/runtime.py +1 -1
- 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 +24 -29
- kimi_cli/ui/shell/__init__.py +55 -36
- kimi_cli/ui/shell/keyboard.py +82 -14
- kimi_cli/ui/shell/prompt.py +198 -3
- 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 +1 -0
- {kimi_cli-0.42.dist-info → kimi_cli-0.43.dist-info}/METADATA +7 -6
- {kimi_cli-0.42.dist-info → kimi_cli-0.43.dist-info}/RECORD +25 -23
- {kimi_cli-0.42.dist-info → kimi_cli-0.43.dist-info}/WHEEL +0 -0
- {kimi_cli-0.42.dist-info → kimi_cli-0.43.dist-info}/entry_points.txt +0 -0
kimi_cli/ui/shell/__init__.py
CHANGED
|
@@ -1,28 +1,32 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
-
import signal
|
|
3
2
|
from collections.abc import Awaitable, Coroutine
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum
|
|
4
5
|
from typing import Any
|
|
5
6
|
|
|
7
|
+
from kosong.base.message import ContentPart
|
|
6
8
|
from kosong.chat_provider import APIStatusError, ChatProviderError
|
|
7
9
|
from rich.console import Group, RenderableType
|
|
8
10
|
from rich.panel import Panel
|
|
9
11
|
from rich.table import Table
|
|
10
12
|
from rich.text import Text
|
|
11
13
|
|
|
12
|
-
from kimi_cli.soul import LLMNotSet, MaxStepsReached, RunCancelled, Soul, run_soul
|
|
14
|
+
from kimi_cli.soul import LLMNotSet, LLMNotSupported, MaxStepsReached, RunCancelled, Soul, run_soul
|
|
13
15
|
from kimi_cli.soul.kimisoul import KimiSoul
|
|
14
16
|
from kimi_cli.ui.shell.console import console
|
|
15
17
|
from kimi_cli.ui.shell.metacmd import get_meta_command
|
|
16
|
-
from kimi_cli.ui.shell.prompt import CustomPromptSession, PromptMode, toast
|
|
18
|
+
from kimi_cli.ui.shell.prompt import CustomPromptSession, PromptMode, ensure_new_line, toast
|
|
19
|
+
from kimi_cli.ui.shell.replay import replay_recent_history
|
|
17
20
|
from kimi_cli.ui.shell.update import LATEST_VERSION_FILE, UpdateResult, do_update, semver_tuple
|
|
18
21
|
from kimi_cli.ui.shell.visualize import visualize
|
|
19
22
|
from kimi_cli.utils.logging import logger
|
|
23
|
+
from kimi_cli.utils.signals import install_sigint_handler
|
|
20
24
|
|
|
21
25
|
|
|
22
26
|
class ShellApp:
|
|
23
|
-
def __init__(self, soul: Soul, welcome_info:
|
|
27
|
+
def __init__(self, soul: Soul, welcome_info: list["WelcomeInfoItem"] | None = None):
|
|
24
28
|
self.soul = soul
|
|
25
|
-
self.
|
|
29
|
+
self._welcome_info = list(welcome_info or [])
|
|
26
30
|
self._background_tasks: set[asyncio.Task[Any]] = set()
|
|
27
31
|
|
|
28
32
|
async def run(self, command: str | None = None) -> bool:
|
|
@@ -33,11 +37,15 @@ class ShellApp:
|
|
|
33
37
|
|
|
34
38
|
self._start_background_task(self._auto_update())
|
|
35
39
|
|
|
36
|
-
_print_welcome_info(self.soul.name or "Kimi CLI", self.
|
|
40
|
+
_print_welcome_info(self.soul.name or "Kimi CLI", self._welcome_info)
|
|
41
|
+
|
|
42
|
+
if isinstance(self.soul, KimiSoul):
|
|
43
|
+
await replay_recent_history(self.soul.context.history)
|
|
37
44
|
|
|
38
45
|
with CustomPromptSession(lambda: self.soul.status) as prompt_session:
|
|
39
46
|
while True:
|
|
40
47
|
try:
|
|
48
|
+
ensure_new_line()
|
|
41
49
|
user_input = await prompt_session.prompt()
|
|
42
50
|
except KeyboardInterrupt:
|
|
43
51
|
logger.debug("Exiting by KeyboardInterrupt")
|
|
@@ -62,14 +70,13 @@ class ShellApp:
|
|
|
62
70
|
await self._run_shell_command(user_input.command)
|
|
63
71
|
continue
|
|
64
72
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
await self._run_meta_command(command[1:])
|
|
73
|
+
if user_input.command.startswith("/"):
|
|
74
|
+
logger.debug("Running meta command: {command}", command=user_input.command)
|
|
75
|
+
await self._run_meta_command(user_input.command[1:])
|
|
69
76
|
continue
|
|
70
77
|
|
|
71
|
-
logger.info("Running agent command: {command}", command=
|
|
72
|
-
await self._run_soul_command(
|
|
78
|
+
logger.info("Running agent command: {command}", command=user_input.content)
|
|
79
|
+
await self._run_soul_command(user_input.content)
|
|
73
80
|
|
|
74
81
|
return True
|
|
75
82
|
|
|
@@ -79,24 +86,26 @@ class ShellApp:
|
|
|
79
86
|
return
|
|
80
87
|
|
|
81
88
|
logger.info("Running shell command: {cmd}", cmd=command)
|
|
89
|
+
|
|
90
|
+
proc: asyncio.subprocess.Process | None = None
|
|
91
|
+
|
|
92
|
+
def _handler():
|
|
93
|
+
logger.debug("SIGINT received.")
|
|
94
|
+
if proc:
|
|
95
|
+
proc.terminate()
|
|
96
|
+
|
|
82
97
|
loop = asyncio.get_running_loop()
|
|
98
|
+
remove_sigint = install_sigint_handler(loop, _handler)
|
|
83
99
|
try:
|
|
84
100
|
# TODO: For the sake of simplicity, we now use `create_subprocess_shell`.
|
|
85
101
|
# Later we should consider making this behave like a real shell.
|
|
86
102
|
proc = await asyncio.create_subprocess_shell(command)
|
|
87
|
-
|
|
88
|
-
def _handler():
|
|
89
|
-
logger.debug("SIGINT received.")
|
|
90
|
-
proc.terminate()
|
|
91
|
-
|
|
92
|
-
loop.add_signal_handler(signal.SIGINT, _handler)
|
|
93
|
-
|
|
94
103
|
await proc.wait()
|
|
95
104
|
except Exception as e:
|
|
96
105
|
logger.exception("Failed to run shell command:")
|
|
97
106
|
console.print(f"[red]Failed to run shell command: {e}[/red]")
|
|
98
107
|
finally:
|
|
99
|
-
|
|
108
|
+
remove_sigint()
|
|
100
109
|
|
|
101
110
|
async def _run_meta_command(self, command_str: str):
|
|
102
111
|
from kimi_cli.cli import Reload
|
|
@@ -137,7 +146,7 @@ class ShellApp:
|
|
|
137
146
|
console.print(f"[red]Unknown error: {e}[/red]")
|
|
138
147
|
raise # re-raise unknown error
|
|
139
148
|
|
|
140
|
-
async def _run_soul_command(self,
|
|
149
|
+
async def _run_soul_command(self, user_input: str | list[ContentPart]) -> bool:
|
|
141
150
|
"""
|
|
142
151
|
Run the soul and handle any known exceptions.
|
|
143
152
|
|
|
@@ -151,13 +160,13 @@ class ShellApp:
|
|
|
151
160
|
cancel_event.set()
|
|
152
161
|
|
|
153
162
|
loop = asyncio.get_running_loop()
|
|
154
|
-
loop
|
|
163
|
+
remove_sigint = install_sigint_handler(loop, _handler)
|
|
155
164
|
|
|
156
165
|
try:
|
|
157
166
|
# Use lambda to pass cancel_event via closure
|
|
158
167
|
await run_soul(
|
|
159
168
|
self.soul,
|
|
160
|
-
|
|
169
|
+
user_input,
|
|
161
170
|
lambda wire: visualize(
|
|
162
171
|
wire, initial_status=self.soul.status, cancel_event=cancel_event
|
|
163
172
|
),
|
|
@@ -167,6 +176,13 @@ class ShellApp:
|
|
|
167
176
|
except LLMNotSet:
|
|
168
177
|
logger.error("LLM not set")
|
|
169
178
|
console.print("[red]LLM not set, send /setup to configure[/red]")
|
|
179
|
+
except LLMNotSupported as e:
|
|
180
|
+
logger.error(
|
|
181
|
+
"LLM model '{model_name}' does not support required capabilities: {capabilities}",
|
|
182
|
+
model_name=e.llm.model_name,
|
|
183
|
+
capabilities=", ".join(e.capabilities),
|
|
184
|
+
)
|
|
185
|
+
console.print(f"[red]{e}[/red]")
|
|
170
186
|
except ChatProviderError as e:
|
|
171
187
|
logger.exception("LLM provider error:")
|
|
172
188
|
if isinstance(e, APIStatusError) and e.status_code == 401:
|
|
@@ -188,7 +204,7 @@ class ShellApp:
|
|
|
188
204
|
console.print(f"[red]Unknown error: {e}[/red]")
|
|
189
205
|
raise # re-raise unknown error
|
|
190
206
|
finally:
|
|
191
|
-
|
|
207
|
+
remove_sigint()
|
|
192
208
|
return False
|
|
193
209
|
|
|
194
210
|
async def _auto_update(self) -> None:
|
|
@@ -227,7 +243,19 @@ _LOGO = f"""\
|
|
|
227
243
|
"""
|
|
228
244
|
|
|
229
245
|
|
|
230
|
-
|
|
246
|
+
@dataclass(slots=True)
|
|
247
|
+
class WelcomeInfoItem:
|
|
248
|
+
class Level(Enum):
|
|
249
|
+
INFO = "grey50"
|
|
250
|
+
WARN = "yellow"
|
|
251
|
+
ERROR = "red"
|
|
252
|
+
|
|
253
|
+
name: str
|
|
254
|
+
value: str
|
|
255
|
+
level: Level = Level.INFO
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _print_welcome_info(name: str, info_items: list[WelcomeInfoItem]) -> None:
|
|
231
259
|
head = Text.from_markup(f"[bold]Welcome to {name}![/bold]")
|
|
232
260
|
help_text = Text.from_markup("[grey50]Send /help for help information.[/grey50]")
|
|
233
261
|
|
|
@@ -241,17 +269,8 @@ def _print_welcome_info(name: str, model: str, info_items: dict[str, str]) -> No
|
|
|
241
269
|
rows: list[RenderableType] = [table]
|
|
242
270
|
|
|
243
271
|
rows.append(Text("")) # Empty line
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
)
|
|
247
|
-
if model:
|
|
248
|
-
rows.append(Text.from_markup(f"[grey50]Model: {model}[/grey50]"))
|
|
249
|
-
else:
|
|
250
|
-
rows.append(
|
|
251
|
-
Text.from_markup(
|
|
252
|
-
"[grey50]Model:[/grey50] [yellow]not set, send /setup to configure[/yellow]"
|
|
253
|
-
)
|
|
254
|
-
)
|
|
272
|
+
for item in info_items:
|
|
273
|
+
rows.append(Text(f"{item.name}: {item.value}", style=item.level.value))
|
|
255
274
|
|
|
256
275
|
if LATEST_VERSION_FILE.exists():
|
|
257
276
|
from kimi_cli.constant import VERSION as current_version
|
kimi_cli/ui/shell/keyboard.py
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import sys
|
|
3
|
-
import termios
|
|
4
3
|
import threading
|
|
5
4
|
import time
|
|
6
5
|
from collections.abc import AsyncGenerator, Callable
|
|
@@ -47,6 +46,21 @@ def _listen_for_keyboard_thread(
|
|
|
47
46
|
cancel: threading.Event,
|
|
48
47
|
emit: Callable[[KeyEvent], None],
|
|
49
48
|
) -> None:
|
|
49
|
+
if sys.platform == "win32":
|
|
50
|
+
_listen_for_keyboard_windows(cancel, emit)
|
|
51
|
+
else:
|
|
52
|
+
_listen_for_keyboard_unix(cancel, emit)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _listen_for_keyboard_unix(
|
|
56
|
+
cancel: threading.Event,
|
|
57
|
+
emit: Callable[[KeyEvent], None],
|
|
58
|
+
) -> None:
|
|
59
|
+
if sys.platform == "win32":
|
|
60
|
+
raise RuntimeError("Unix keyboard listener requires a non-Windows platform")
|
|
61
|
+
|
|
62
|
+
import termios
|
|
63
|
+
|
|
50
64
|
# make stdin raw and non-blocking
|
|
51
65
|
fd = sys.stdin.fileno()
|
|
52
66
|
oldterm = termios.tcgetattr(fd)
|
|
@@ -59,9 +73,9 @@ def _listen_for_keyboard_thread(
|
|
|
59
73
|
try:
|
|
60
74
|
while not cancel.is_set():
|
|
61
75
|
try:
|
|
62
|
-
c = sys.stdin.read(1)
|
|
76
|
+
c = sys.stdin.buffer.read(1)
|
|
63
77
|
except (OSError, ValueError):
|
|
64
|
-
c = ""
|
|
78
|
+
c = b""
|
|
65
79
|
|
|
66
80
|
if not c:
|
|
67
81
|
if cancel.is_set():
|
|
@@ -69,15 +83,15 @@ def _listen_for_keyboard_thread(
|
|
|
69
83
|
time.sleep(0.01)
|
|
70
84
|
continue
|
|
71
85
|
|
|
72
|
-
if c == "\x1b":
|
|
86
|
+
if c == b"\x1b":
|
|
73
87
|
sequence = c
|
|
74
88
|
for _ in range(2):
|
|
75
89
|
if cancel.is_set():
|
|
76
90
|
break
|
|
77
91
|
try:
|
|
78
|
-
fragment = sys.stdin.read(1)
|
|
92
|
+
fragment = sys.stdin.buffer.read(1)
|
|
79
93
|
except (OSError, ValueError):
|
|
80
|
-
fragment = ""
|
|
94
|
+
fragment = b""
|
|
81
95
|
if not fragment:
|
|
82
96
|
break
|
|
83
97
|
sequence += fragment
|
|
@@ -87,22 +101,76 @@ def _listen_for_keyboard_thread(
|
|
|
87
101
|
event = _ARROW_KEY_MAP.get(sequence)
|
|
88
102
|
if event is not None:
|
|
89
103
|
emit(event)
|
|
90
|
-
elif sequence == "\x1b":
|
|
104
|
+
elif sequence == b"\x1b":
|
|
91
105
|
emit(KeyEvent.ESCAPE)
|
|
92
|
-
elif c in ("\r", "\n"):
|
|
106
|
+
elif c in (b"\r", b"\n"):
|
|
93
107
|
emit(KeyEvent.ENTER)
|
|
94
|
-
elif c == "\t":
|
|
108
|
+
elif c == b"\t":
|
|
95
109
|
emit(KeyEvent.TAB)
|
|
96
110
|
finally:
|
|
97
111
|
# restore the terminal settings
|
|
98
112
|
termios.tcsetattr(fd, termios.TCSAFLUSH, oldterm)
|
|
99
113
|
|
|
100
114
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
"
|
|
115
|
+
def _listen_for_keyboard_windows(
|
|
116
|
+
cancel: threading.Event,
|
|
117
|
+
emit: Callable[[KeyEvent], None],
|
|
118
|
+
) -> None:
|
|
119
|
+
if sys.platform != "win32":
|
|
120
|
+
raise RuntimeError("Windows keyboard listener requires a Windows platform")
|
|
121
|
+
|
|
122
|
+
import msvcrt
|
|
123
|
+
|
|
124
|
+
while not cancel.is_set():
|
|
125
|
+
if msvcrt.kbhit():
|
|
126
|
+
c = msvcrt.getch()
|
|
127
|
+
|
|
128
|
+
# Handle special keys (arrow keys, etc.)
|
|
129
|
+
if c in (b"\x00", b"\xe0"):
|
|
130
|
+
# Extended key, read the next byte
|
|
131
|
+
extended = msvcrt.getch()
|
|
132
|
+
event = _WINDOWS_KEY_MAP.get(extended)
|
|
133
|
+
if event is not None:
|
|
134
|
+
emit(event)
|
|
135
|
+
elif c == b"\x1b":
|
|
136
|
+
sequence = c
|
|
137
|
+
for _ in range(2):
|
|
138
|
+
if cancel.is_set():
|
|
139
|
+
break
|
|
140
|
+
fragment = msvcrt.getch() if msvcrt.kbhit() else b""
|
|
141
|
+
if not fragment:
|
|
142
|
+
break
|
|
143
|
+
sequence += fragment
|
|
144
|
+
if sequence in _ARROW_KEY_MAP:
|
|
145
|
+
break
|
|
146
|
+
|
|
147
|
+
event = _ARROW_KEY_MAP.get(sequence)
|
|
148
|
+
if event is not None:
|
|
149
|
+
emit(event)
|
|
150
|
+
elif sequence == b"\x1b":
|
|
151
|
+
emit(KeyEvent.ESCAPE)
|
|
152
|
+
elif c in (b"\r", b"\n"):
|
|
153
|
+
emit(KeyEvent.ENTER)
|
|
154
|
+
elif c == b"\t":
|
|
155
|
+
emit(KeyEvent.TAB)
|
|
156
|
+
else:
|
|
157
|
+
if cancel.is_set():
|
|
158
|
+
break
|
|
159
|
+
time.sleep(0.01)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
_ARROW_KEY_MAP: dict[bytes, KeyEvent] = {
|
|
163
|
+
b"\x1b[A": KeyEvent.UP,
|
|
164
|
+
b"\x1b[B": KeyEvent.DOWN,
|
|
165
|
+
b"\x1b[C": KeyEvent.RIGHT,
|
|
166
|
+
b"\x1b[D": KeyEvent.LEFT,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
_WINDOWS_KEY_MAP: dict[bytes, KeyEvent] = {
|
|
170
|
+
b"H": KeyEvent.UP, # Up arrow
|
|
171
|
+
b"P": KeyEvent.DOWN, # Down arrow
|
|
172
|
+
b"M": KeyEvent.RIGHT, # Right arrow
|
|
173
|
+
b"K": KeyEvent.LEFT, # Left arrow
|
|
106
174
|
}
|
|
107
175
|
|
|
108
176
|
|
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,75 @@ 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()
|
|
508
575
|
self._append_history_entry(command)
|
|
509
|
-
|
|
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)
|
|
510
600
|
|
|
511
601
|
def _append_history_entry(self, text: str) -> None:
|
|
512
602
|
entry = _HistoryEntry(content=text.strip())
|
|
@@ -557,6 +647,7 @@ class CustomPromptSession:
|
|
|
557
647
|
shortcuts = [
|
|
558
648
|
"ctrl-x: switch mode",
|
|
559
649
|
"ctrl-j: newline",
|
|
650
|
+
"ctrl-v: paste",
|
|
560
651
|
"ctrl-d: exit",
|
|
561
652
|
]
|
|
562
653
|
for shortcut in shortcuts:
|
|
@@ -579,3 +670,107 @@ class CustomPromptSession:
|
|
|
579
670
|
def _format_status(status: StatusSnapshot) -> str:
|
|
580
671
|
bounded = max(0.0, min(status.context_usage, 1.0))
|
|
581
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()
|