kimi-cli 0.35__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 +304 -0
- kimi_cli/__init__.py +374 -0
- kimi_cli/agent.py +261 -0
- kimi_cli/agents/koder/README.md +3 -0
- kimi_cli/agents/koder/agent.yaml +24 -0
- kimi_cli/agents/koder/sub.yaml +11 -0
- kimi_cli/agents/koder/system.md +72 -0
- kimi_cli/config.py +138 -0
- kimi_cli/llm.py +8 -0
- kimi_cli/metadata.py +117 -0
- kimi_cli/prompts/metacmds/__init__.py +4 -0
- kimi_cli/prompts/metacmds/compact.md +74 -0
- kimi_cli/prompts/metacmds/init.md +21 -0
- kimi_cli/py.typed +0 -0
- kimi_cli/share.py +8 -0
- kimi_cli/soul/__init__.py +59 -0
- kimi_cli/soul/approval.py +69 -0
- kimi_cli/soul/context.py +142 -0
- kimi_cli/soul/denwarenji.py +37 -0
- kimi_cli/soul/kimisoul.py +248 -0
- kimi_cli/soul/message.py +76 -0
- kimi_cli/soul/toolset.py +25 -0
- kimi_cli/soul/wire.py +101 -0
- kimi_cli/tools/__init__.py +85 -0
- kimi_cli/tools/bash/__init__.py +97 -0
- kimi_cli/tools/bash/bash.md +31 -0
- kimi_cli/tools/dmail/__init__.py +38 -0
- kimi_cli/tools/dmail/dmail.md +15 -0
- kimi_cli/tools/file/__init__.py +21 -0
- kimi_cli/tools/file/glob.md +17 -0
- kimi_cli/tools/file/glob.py +149 -0
- kimi_cli/tools/file/grep.md +5 -0
- kimi_cli/tools/file/grep.py +285 -0
- kimi_cli/tools/file/patch.md +8 -0
- kimi_cli/tools/file/patch.py +131 -0
- kimi_cli/tools/file/read.md +14 -0
- kimi_cli/tools/file/read.py +139 -0
- kimi_cli/tools/file/replace.md +7 -0
- kimi_cli/tools/file/replace.py +132 -0
- kimi_cli/tools/file/write.md +5 -0
- kimi_cli/tools/file/write.py +107 -0
- kimi_cli/tools/mcp.py +85 -0
- kimi_cli/tools/task/__init__.py +156 -0
- kimi_cli/tools/task/task.md +26 -0
- kimi_cli/tools/test.py +55 -0
- kimi_cli/tools/think/__init__.py +21 -0
- kimi_cli/tools/think/think.md +1 -0
- kimi_cli/tools/todo/__init__.py +27 -0
- kimi_cli/tools/todo/set_todo_list.md +15 -0
- kimi_cli/tools/utils.py +150 -0
- kimi_cli/tools/web/__init__.py +4 -0
- kimi_cli/tools/web/fetch.md +1 -0
- kimi_cli/tools/web/fetch.py +94 -0
- kimi_cli/tools/web/search.md +1 -0
- kimi_cli/tools/web/search.py +126 -0
- kimi_cli/ui/__init__.py +68 -0
- kimi_cli/ui/acp/__init__.py +441 -0
- kimi_cli/ui/print/__init__.py +176 -0
- kimi_cli/ui/shell/__init__.py +326 -0
- kimi_cli/ui/shell/console.py +3 -0
- kimi_cli/ui/shell/liveview.py +158 -0
- kimi_cli/ui/shell/metacmd.py +309 -0
- kimi_cli/ui/shell/prompt.py +574 -0
- kimi_cli/ui/shell/setup.py +192 -0
- kimi_cli/ui/shell/update.py +204 -0
- kimi_cli/utils/changelog.py +101 -0
- kimi_cli/utils/logging.py +18 -0
- kimi_cli/utils/message.py +8 -0
- kimi_cli/utils/path.py +23 -0
- kimi_cli/utils/provider.py +64 -0
- kimi_cli/utils/pyinstaller.py +24 -0
- kimi_cli/utils/string.py +12 -0
- kimi_cli-0.35.dist-info/METADATA +24 -0
- kimi_cli-0.35.dist-info/RECORD +76 -0
- kimi_cli-0.35.dist-info/WHEEL +4 -0
- kimi_cli-0.35.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextlib
|
|
3
|
+
import getpass
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from hashlib import md5
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import override
|
|
14
|
+
|
|
15
|
+
from prompt_toolkit import PromptSession
|
|
16
|
+
from prompt_toolkit.application.current import get_app_or_none
|
|
17
|
+
from prompt_toolkit.completion import (
|
|
18
|
+
Completer,
|
|
19
|
+
Completion,
|
|
20
|
+
DummyCompleter,
|
|
21
|
+
FuzzyCompleter,
|
|
22
|
+
WordCompleter,
|
|
23
|
+
merge_completers,
|
|
24
|
+
)
|
|
25
|
+
from prompt_toolkit.document import Document
|
|
26
|
+
from prompt_toolkit.filters import Always, Never, has_completions
|
|
27
|
+
from prompt_toolkit.formatted_text import FormattedText
|
|
28
|
+
from prompt_toolkit.history import InMemoryHistory
|
|
29
|
+
from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
|
|
30
|
+
from prompt_toolkit.patch_stdout import patch_stdout
|
|
31
|
+
from pydantic import BaseModel, ValidationError
|
|
32
|
+
|
|
33
|
+
from kimi_cli.share import get_share_dir
|
|
34
|
+
from kimi_cli.soul import StatusSnapshot
|
|
35
|
+
from kimi_cli.ui.shell.metacmd import get_meta_commands
|
|
36
|
+
from kimi_cli.utils.logging import logger
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class MetaCommandCompleter(Completer):
|
|
40
|
+
"""A completer that:
|
|
41
|
+
- Shows one line per meta command in the form: "/name (alias1, alias2)"
|
|
42
|
+
- Matches by primary name or any alias while inserting the canonical "/name"
|
|
43
|
+
- Only activates when the current token starts with '/'
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
@override
|
|
47
|
+
def get_completions(self, document, complete_event):
|
|
48
|
+
text = document.text_before_cursor
|
|
49
|
+
|
|
50
|
+
# Only autocomplete when the input buffer has no other content.
|
|
51
|
+
if document.text_after_cursor.strip():
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
# Only consider the last token (allowing future arguments after a space)
|
|
55
|
+
last_space = text.rfind(" ")
|
|
56
|
+
token = text[last_space + 1 :]
|
|
57
|
+
prefix = text[: last_space + 1] if last_space != -1 else ""
|
|
58
|
+
|
|
59
|
+
if prefix.strip():
|
|
60
|
+
return
|
|
61
|
+
if not token.startswith("/"):
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
typed = token[1:]
|
|
65
|
+
typed_lower = typed.lower()
|
|
66
|
+
|
|
67
|
+
for cmd in sorted(get_meta_commands(), key=lambda c: c.name):
|
|
68
|
+
names = [cmd.name] + list(cmd.aliases)
|
|
69
|
+
if typed == "" or any(n.lower().startswith(typed_lower) for n in names):
|
|
70
|
+
yield Completion(
|
|
71
|
+
text=f"/{cmd.name}",
|
|
72
|
+
start_position=-len(token),
|
|
73
|
+
display=cmd.slash_name(),
|
|
74
|
+
display_meta=cmd.description,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class FileMentionCompleter(Completer):
|
|
79
|
+
"""Offer fuzzy `@` path completion by indexing workspace files."""
|
|
80
|
+
|
|
81
|
+
_FRAGMENT_PATTERN = re.compile(r"[^\s@]+")
|
|
82
|
+
_TRIGGER_GUARDS = frozenset((".", "-", "_", "`", "'", '"', ":", "@", "#", "~"))
|
|
83
|
+
_IGNORED_NAME_GROUPS: dict[str, tuple[str, ...]] = {
|
|
84
|
+
"vcs_metadata": (".DS_Store", ".bzr", ".git", ".hg", ".svn"),
|
|
85
|
+
"tooling_caches": (
|
|
86
|
+
".build",
|
|
87
|
+
".cache",
|
|
88
|
+
".coverage",
|
|
89
|
+
".fleet",
|
|
90
|
+
".gradle",
|
|
91
|
+
".idea",
|
|
92
|
+
".ipynb_checkpoints",
|
|
93
|
+
".pnpm-store",
|
|
94
|
+
".pytest_cache",
|
|
95
|
+
".pub-cache",
|
|
96
|
+
".ruff_cache",
|
|
97
|
+
".swiftpm",
|
|
98
|
+
".tox",
|
|
99
|
+
".venv",
|
|
100
|
+
".vs",
|
|
101
|
+
".vscode",
|
|
102
|
+
".yarn",
|
|
103
|
+
".yarn-cache",
|
|
104
|
+
),
|
|
105
|
+
"js_frontend": (
|
|
106
|
+
".next",
|
|
107
|
+
".nuxt",
|
|
108
|
+
".parcel-cache",
|
|
109
|
+
".svelte-kit",
|
|
110
|
+
".turbo",
|
|
111
|
+
".vercel",
|
|
112
|
+
"node_modules",
|
|
113
|
+
),
|
|
114
|
+
"python_packaging": (
|
|
115
|
+
"__pycache__",
|
|
116
|
+
"build",
|
|
117
|
+
"coverage",
|
|
118
|
+
"dist",
|
|
119
|
+
"htmlcov",
|
|
120
|
+
"pip-wheel-metadata",
|
|
121
|
+
"venv",
|
|
122
|
+
),
|
|
123
|
+
"java_jvm": (".mvn", "out", "target"),
|
|
124
|
+
"dotnet_native": ("bin", "cmake-build-debug", "cmake-build-release", "obj"),
|
|
125
|
+
"bazel_buck": ("bazel-bin", "bazel-out", "bazel-testlogs", "buck-out"),
|
|
126
|
+
"misc_artifacts": (
|
|
127
|
+
".dart_tool",
|
|
128
|
+
".serverless",
|
|
129
|
+
".stack-work",
|
|
130
|
+
".terraform",
|
|
131
|
+
".terragrunt-cache",
|
|
132
|
+
"DerivedData",
|
|
133
|
+
"Pods",
|
|
134
|
+
"deps",
|
|
135
|
+
"tmp",
|
|
136
|
+
"vendor",
|
|
137
|
+
),
|
|
138
|
+
}
|
|
139
|
+
_IGNORED_NAMES = frozenset(name for group in _IGNORED_NAME_GROUPS.values() for name in group)
|
|
140
|
+
_IGNORED_PATTERN_PARTS: tuple[str, ...] = (
|
|
141
|
+
r".*_cache$",
|
|
142
|
+
r".*-cache$",
|
|
143
|
+
r".*\.egg-info$",
|
|
144
|
+
r".*\.dist-info$",
|
|
145
|
+
r".*\.py[co]$",
|
|
146
|
+
r".*\.class$",
|
|
147
|
+
r".*\.sw[po]$",
|
|
148
|
+
r".*~$",
|
|
149
|
+
r".*\.(?:tmp|bak)$",
|
|
150
|
+
)
|
|
151
|
+
_IGNORED_PATTERNS = re.compile(
|
|
152
|
+
"|".join(f"(?:{part})" for part in _IGNORED_PATTERN_PARTS),
|
|
153
|
+
re.IGNORECASE,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def __init__(
|
|
157
|
+
self,
|
|
158
|
+
root: Path,
|
|
159
|
+
*,
|
|
160
|
+
refresh_interval: float = 2.0,
|
|
161
|
+
limit: int = 1000,
|
|
162
|
+
) -> None:
|
|
163
|
+
self._root = root
|
|
164
|
+
self._refresh_interval = refresh_interval
|
|
165
|
+
self._limit = limit
|
|
166
|
+
self._cache_time: float = 0.0
|
|
167
|
+
self._cached_paths: list[str] = []
|
|
168
|
+
self._top_cache_time: float = 0.0
|
|
169
|
+
self._top_cached_paths: list[str] = []
|
|
170
|
+
self._fragment_hint: str | None = None
|
|
171
|
+
|
|
172
|
+
self._word_completer = WordCompleter(
|
|
173
|
+
self._get_paths,
|
|
174
|
+
WORD=False,
|
|
175
|
+
pattern=self._FRAGMENT_PATTERN,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
self._fuzzy = FuzzyCompleter(
|
|
179
|
+
self._word_completer,
|
|
180
|
+
WORD=False,
|
|
181
|
+
pattern=r"^[^\s@]*",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
@classmethod
|
|
185
|
+
def _is_ignored(cls, name: str) -> bool:
|
|
186
|
+
if not name:
|
|
187
|
+
return True
|
|
188
|
+
if name in cls._IGNORED_NAMES:
|
|
189
|
+
return True
|
|
190
|
+
return bool(cls._IGNORED_PATTERNS.fullmatch(name))
|
|
191
|
+
|
|
192
|
+
def _get_paths(self) -> list[str]:
|
|
193
|
+
fragment = self._fragment_hint or ""
|
|
194
|
+
if "/" not in fragment and len(fragment) < 3:
|
|
195
|
+
return self._get_top_level_paths()
|
|
196
|
+
return self._get_deep_paths()
|
|
197
|
+
|
|
198
|
+
def _get_top_level_paths(self) -> list[str]:
|
|
199
|
+
now = time.monotonic()
|
|
200
|
+
if now - self._top_cache_time <= self._refresh_interval:
|
|
201
|
+
return self._top_cached_paths
|
|
202
|
+
|
|
203
|
+
entries: list[str] = []
|
|
204
|
+
try:
|
|
205
|
+
for entry in sorted(self._root.iterdir(), key=lambda p: p.name):
|
|
206
|
+
name = entry.name
|
|
207
|
+
if self._is_ignored(name):
|
|
208
|
+
continue
|
|
209
|
+
entries.append(f"{name}/" if entry.is_dir() else name)
|
|
210
|
+
if len(entries) >= self._limit:
|
|
211
|
+
break
|
|
212
|
+
except OSError:
|
|
213
|
+
return self._top_cached_paths
|
|
214
|
+
|
|
215
|
+
self._top_cached_paths = entries
|
|
216
|
+
self._top_cache_time = now
|
|
217
|
+
return self._top_cached_paths
|
|
218
|
+
|
|
219
|
+
def _get_deep_paths(self) -> list[str]:
|
|
220
|
+
now = time.monotonic()
|
|
221
|
+
if now - self._cache_time <= self._refresh_interval:
|
|
222
|
+
return self._cached_paths
|
|
223
|
+
|
|
224
|
+
paths: list[str] = []
|
|
225
|
+
try:
|
|
226
|
+
for current_root, dirs, files in os.walk(self._root):
|
|
227
|
+
relative_root = Path(current_root).relative_to(self._root)
|
|
228
|
+
|
|
229
|
+
# Prevent descending into ignored directories.
|
|
230
|
+
dirs[:] = sorted(d for d in dirs if not self._is_ignored(d))
|
|
231
|
+
|
|
232
|
+
if relative_root.parts and any(
|
|
233
|
+
self._is_ignored(part) for part in relative_root.parts
|
|
234
|
+
):
|
|
235
|
+
dirs[:] = []
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
if relative_root.parts:
|
|
239
|
+
paths.append(relative_root.as_posix() + "/")
|
|
240
|
+
if len(paths) >= self._limit:
|
|
241
|
+
break
|
|
242
|
+
|
|
243
|
+
for file_name in sorted(files):
|
|
244
|
+
if self._is_ignored(file_name):
|
|
245
|
+
continue
|
|
246
|
+
relative = (relative_root / file_name).as_posix()
|
|
247
|
+
if not relative:
|
|
248
|
+
continue
|
|
249
|
+
paths.append(relative)
|
|
250
|
+
if len(paths) >= self._limit:
|
|
251
|
+
break
|
|
252
|
+
|
|
253
|
+
if len(paths) >= self._limit:
|
|
254
|
+
break
|
|
255
|
+
except OSError:
|
|
256
|
+
return self._cached_paths
|
|
257
|
+
|
|
258
|
+
self._cached_paths = paths
|
|
259
|
+
self._cache_time = now
|
|
260
|
+
return self._cached_paths
|
|
261
|
+
|
|
262
|
+
@staticmethod
|
|
263
|
+
def _extract_fragment(text: str) -> str | None:
|
|
264
|
+
index = text.rfind("@")
|
|
265
|
+
if index == -1:
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
if index > 0:
|
|
269
|
+
prev = text[index - 1]
|
|
270
|
+
if prev.isalnum() or prev in FileMentionCompleter._TRIGGER_GUARDS:
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
fragment = text[index + 1 :]
|
|
274
|
+
if not fragment:
|
|
275
|
+
return ""
|
|
276
|
+
|
|
277
|
+
if any(ch.isspace() for ch in fragment):
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
return fragment
|
|
281
|
+
|
|
282
|
+
def _is_completed_file(self, fragment: str) -> bool:
|
|
283
|
+
candidate = fragment.rstrip("/")
|
|
284
|
+
if not candidate:
|
|
285
|
+
return False
|
|
286
|
+
try:
|
|
287
|
+
return (self._root / candidate).is_file()
|
|
288
|
+
except OSError:
|
|
289
|
+
return False
|
|
290
|
+
|
|
291
|
+
@override
|
|
292
|
+
def get_completions(self, document, complete_event):
|
|
293
|
+
fragment = self._extract_fragment(document.text_before_cursor)
|
|
294
|
+
if fragment is None:
|
|
295
|
+
return
|
|
296
|
+
if self._is_completed_file(fragment):
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
mention_doc = Document(text=fragment, cursor_position=len(fragment))
|
|
300
|
+
self._fragment_hint = fragment
|
|
301
|
+
try:
|
|
302
|
+
yield from self._fuzzy.get_completions(mention_doc, complete_event)
|
|
303
|
+
finally:
|
|
304
|
+
self._fragment_hint = None
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class _HistoryEntry(BaseModel):
|
|
308
|
+
content: str
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _load_history_entries(history_file: Path) -> list[_HistoryEntry]:
|
|
312
|
+
entries: list[_HistoryEntry] = []
|
|
313
|
+
if not history_file.exists():
|
|
314
|
+
return entries
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
with history_file.open(encoding="utf-8") as f:
|
|
318
|
+
for raw_line in f:
|
|
319
|
+
line = raw_line.strip()
|
|
320
|
+
if not line:
|
|
321
|
+
continue
|
|
322
|
+
try:
|
|
323
|
+
record = json.loads(line)
|
|
324
|
+
except json.JSONDecodeError:
|
|
325
|
+
logger.warning(
|
|
326
|
+
"Failed to parse user history line; skipping: {line}",
|
|
327
|
+
line=line,
|
|
328
|
+
)
|
|
329
|
+
continue
|
|
330
|
+
try:
|
|
331
|
+
entry = _HistoryEntry.model_validate(record)
|
|
332
|
+
entries.append(entry)
|
|
333
|
+
except ValidationError:
|
|
334
|
+
logger.warning(
|
|
335
|
+
"Failed to validate user history entry; skipping: {line}",
|
|
336
|
+
line=line,
|
|
337
|
+
)
|
|
338
|
+
continue
|
|
339
|
+
except OSError as exc:
|
|
340
|
+
logger.warning(
|
|
341
|
+
"Failed to load user history file: {file} ({error})",
|
|
342
|
+
file=history_file,
|
|
343
|
+
error=exc,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
return entries
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class PromptMode(Enum):
|
|
350
|
+
AGENT = "agent"
|
|
351
|
+
SHELL = "shell"
|
|
352
|
+
|
|
353
|
+
def toggle(self) -> "PromptMode":
|
|
354
|
+
return PromptMode.SHELL if self == PromptMode.AGENT else PromptMode.AGENT
|
|
355
|
+
|
|
356
|
+
def __str__(self) -> str:
|
|
357
|
+
return self.value
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class UserInput(BaseModel):
|
|
361
|
+
mode: PromptMode
|
|
362
|
+
command: str
|
|
363
|
+
|
|
364
|
+
def __str__(self) -> str:
|
|
365
|
+
return self.command
|
|
366
|
+
|
|
367
|
+
def __bool__(self) -> bool:
|
|
368
|
+
return bool(self.command)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
_REFRESH_INTERVAL = 1.0
|
|
372
|
+
_toast_queue: asyncio.Queue[tuple[str, float]] = asyncio.Queue()
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def toast(message: str, duration: float = 5.0) -> None:
|
|
376
|
+
duration = max(duration, _REFRESH_INTERVAL)
|
|
377
|
+
_toast_queue.put_nowait((message, duration))
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class CustomPromptSession:
|
|
381
|
+
def __init__(self, status_provider: Callable[[], StatusSnapshot]):
|
|
382
|
+
history_dir = get_share_dir() / "user-history"
|
|
383
|
+
history_dir.mkdir(parents=True, exist_ok=True)
|
|
384
|
+
work_dir_id = md5(str(Path.cwd()).encode()).hexdigest()
|
|
385
|
+
self._history_file = (history_dir / work_dir_id).with_suffix(".jsonl")
|
|
386
|
+
self._status_provider = status_provider
|
|
387
|
+
self._last_history_content: str | None = None
|
|
388
|
+
self._mode: PromptMode = PromptMode.AGENT
|
|
389
|
+
|
|
390
|
+
history_entries = _load_history_entries(self._history_file)
|
|
391
|
+
history = InMemoryHistory()
|
|
392
|
+
for entry in history_entries:
|
|
393
|
+
history.append_string(entry.content)
|
|
394
|
+
|
|
395
|
+
if history_entries:
|
|
396
|
+
# for consecutive deduplication
|
|
397
|
+
self._last_history_content = history_entries[-1].content
|
|
398
|
+
|
|
399
|
+
# Build completers
|
|
400
|
+
self._agent_mode_completer = merge_completers(
|
|
401
|
+
[
|
|
402
|
+
MetaCommandCompleter(),
|
|
403
|
+
FileMentionCompleter(Path.cwd()),
|
|
404
|
+
],
|
|
405
|
+
deduplicate=True,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
# Build key bindings
|
|
409
|
+
_kb = KeyBindings()
|
|
410
|
+
|
|
411
|
+
@_kb.add("enter", filter=has_completions)
|
|
412
|
+
def _accept_completion(event: KeyPressEvent) -> None:
|
|
413
|
+
"""Accept the first completion when Enter is pressed and completions are shown."""
|
|
414
|
+
buff = event.current_buffer
|
|
415
|
+
if buff.complete_state and buff.complete_state.completions:
|
|
416
|
+
# Get the current completion, or use the first one if none is selected
|
|
417
|
+
completion = buff.complete_state.current_completion
|
|
418
|
+
if not completion:
|
|
419
|
+
completion = buff.complete_state.completions[0]
|
|
420
|
+
buff.apply_completion(completion)
|
|
421
|
+
|
|
422
|
+
@_kb.add("c-k", eager=True)
|
|
423
|
+
def _toggle_mode(event: KeyPressEvent) -> None:
|
|
424
|
+
self._mode = self._mode.toggle()
|
|
425
|
+
# Apply mode-specific settings
|
|
426
|
+
self._apply_mode(event)
|
|
427
|
+
# Redraw UI
|
|
428
|
+
event.app.invalidate()
|
|
429
|
+
|
|
430
|
+
self._session = PromptSession(
|
|
431
|
+
message=self._render_message,
|
|
432
|
+
prompt_continuation=FormattedText([("fg:#4d4d4d", "... ")]),
|
|
433
|
+
completer=self._agent_mode_completer,
|
|
434
|
+
complete_while_typing=True,
|
|
435
|
+
key_bindings=_kb,
|
|
436
|
+
history=history,
|
|
437
|
+
bottom_toolbar=self._render_bottom_toolbar,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
self._status_refresh_task: asyncio.Task | None = None
|
|
441
|
+
self._current_toast: str | None = None
|
|
442
|
+
self._current_toast_duration: float = 0.0
|
|
443
|
+
|
|
444
|
+
def _render_message(self) -> FormattedText:
|
|
445
|
+
symbol = "✨" if self._mode == PromptMode.AGENT else "$"
|
|
446
|
+
return FormattedText([("bold", f"{getpass.getuser()}{symbol} ")])
|
|
447
|
+
|
|
448
|
+
def _apply_mode(self, event: KeyPressEvent | None = None) -> None:
|
|
449
|
+
# Apply mode to the active buffer (not the PromptSession itself)
|
|
450
|
+
try:
|
|
451
|
+
buff = event.current_buffer if event is not None else self._session.default_buffer
|
|
452
|
+
except Exception:
|
|
453
|
+
buff = None
|
|
454
|
+
|
|
455
|
+
if self._mode == PromptMode.SHELL:
|
|
456
|
+
# Cancel any active completion menu
|
|
457
|
+
with contextlib.suppress(Exception):
|
|
458
|
+
if buff is not None:
|
|
459
|
+
buff.cancel_completion()
|
|
460
|
+
if buff is not None:
|
|
461
|
+
buff.completer = DummyCompleter()
|
|
462
|
+
buff.complete_while_typing = Never()
|
|
463
|
+
else:
|
|
464
|
+
if buff is not None:
|
|
465
|
+
buff.completer = self._agent_mode_completer
|
|
466
|
+
buff.complete_while_typing = Always()
|
|
467
|
+
|
|
468
|
+
def __enter__(self) -> "CustomPromptSession":
|
|
469
|
+
if self._status_refresh_task is not None and not self._status_refresh_task.done():
|
|
470
|
+
return self
|
|
471
|
+
|
|
472
|
+
async def _refresh(interval: float) -> None:
|
|
473
|
+
try:
|
|
474
|
+
while True:
|
|
475
|
+
app = get_app_or_none()
|
|
476
|
+
if app is not None:
|
|
477
|
+
app.invalidate()
|
|
478
|
+
|
|
479
|
+
try:
|
|
480
|
+
asyncio.get_running_loop()
|
|
481
|
+
except RuntimeError:
|
|
482
|
+
logger.warning("No running loop found, exiting status refresh task")
|
|
483
|
+
self._status_refresh_task = None
|
|
484
|
+
break
|
|
485
|
+
|
|
486
|
+
await asyncio.sleep(interval)
|
|
487
|
+
except asyncio.CancelledError:
|
|
488
|
+
# graceful exit
|
|
489
|
+
pass
|
|
490
|
+
|
|
491
|
+
self._status_refresh_task = asyncio.create_task(_refresh(_REFRESH_INTERVAL))
|
|
492
|
+
return self
|
|
493
|
+
|
|
494
|
+
def __exit__(self, exc_type, exc_value, traceback) -> None:
|
|
495
|
+
if self._status_refresh_task is not None and not self._status_refresh_task.done():
|
|
496
|
+
self._status_refresh_task.cancel()
|
|
497
|
+
self._status_refresh_task = None
|
|
498
|
+
|
|
499
|
+
async def prompt(self) -> UserInput:
|
|
500
|
+
with patch_stdout():
|
|
501
|
+
command = str(await self._session.prompt_async()).strip()
|
|
502
|
+
self._append_history_entry(command)
|
|
503
|
+
return UserInput(mode=self._mode, command=command)
|
|
504
|
+
|
|
505
|
+
def _append_history_entry(self, text: str) -> None:
|
|
506
|
+
entry = _HistoryEntry(content=text.strip())
|
|
507
|
+
if not entry.content:
|
|
508
|
+
return
|
|
509
|
+
|
|
510
|
+
# skip if same as last entry
|
|
511
|
+
if entry.content == self._last_history_content:
|
|
512
|
+
return
|
|
513
|
+
|
|
514
|
+
try:
|
|
515
|
+
self._history_file.parent.mkdir(parents=True, exist_ok=True)
|
|
516
|
+
with self._history_file.open("a", encoding="utf-8") as f:
|
|
517
|
+
f.write(entry.model_dump_json(ensure_ascii=False) + "\n")
|
|
518
|
+
self._last_history_content = entry.content
|
|
519
|
+
except OSError as exc:
|
|
520
|
+
logger.warning(
|
|
521
|
+
"Failed to append user history entry: {file} ({error})",
|
|
522
|
+
file=self._history_file,
|
|
523
|
+
error=exc,
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
def _render_bottom_toolbar(self) -> FormattedText:
|
|
527
|
+
app = get_app_or_none()
|
|
528
|
+
assert app is not None
|
|
529
|
+
columns = app.output.get_size().columns
|
|
530
|
+
|
|
531
|
+
fragments: list[tuple[str, str]] = []
|
|
532
|
+
|
|
533
|
+
now_text = datetime.now().strftime("%H:%M")
|
|
534
|
+
fragments.extend([("", now_text), ("", " " * 2)])
|
|
535
|
+
columns -= len(now_text) + 2
|
|
536
|
+
|
|
537
|
+
mode = str(self._mode).lower()
|
|
538
|
+
fragments.extend([("", f"{mode}"), ("", " " * 2)])
|
|
539
|
+
columns -= len(mode) + 2
|
|
540
|
+
|
|
541
|
+
status = self._status_provider()
|
|
542
|
+
status_text = self._format_status(status)
|
|
543
|
+
|
|
544
|
+
if self._current_toast is not None:
|
|
545
|
+
fragments.extend([("", self._current_toast), ("", " " * 2)])
|
|
546
|
+
columns -= len(self._current_toast) + 2
|
|
547
|
+
self._current_toast_duration -= _REFRESH_INTERVAL
|
|
548
|
+
if self._current_toast_duration <= 0.0:
|
|
549
|
+
self._current_toast = None
|
|
550
|
+
else:
|
|
551
|
+
shortcuts = [
|
|
552
|
+
"ctrl-k: toggle mode",
|
|
553
|
+
"ctrl-d: exit",
|
|
554
|
+
]
|
|
555
|
+
for shortcut in shortcuts:
|
|
556
|
+
if columns - len(status_text) > len(shortcut) + 2:
|
|
557
|
+
fragments.extend([("", shortcut), ("", " " * 2)])
|
|
558
|
+
columns -= len(shortcut) + 2
|
|
559
|
+
else:
|
|
560
|
+
break
|
|
561
|
+
|
|
562
|
+
if self._current_toast is None and not _toast_queue.empty():
|
|
563
|
+
self._current_toast, self._current_toast_duration = _toast_queue.get_nowait()
|
|
564
|
+
|
|
565
|
+
padding = max(1, columns - len(status_text))
|
|
566
|
+
fragments.append(("", " " * padding))
|
|
567
|
+
fragments.append(("", status_text))
|
|
568
|
+
|
|
569
|
+
return FormattedText(fragments)
|
|
570
|
+
|
|
571
|
+
@staticmethod
|
|
572
|
+
def _format_status(status: StatusSnapshot) -> str:
|
|
573
|
+
bounded = max(0.0, min(status.context_usage, 1.0))
|
|
574
|
+
return f"context: {bounded:.1%}"
|