w1x-cli 3.5.0__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.
- w1x/__init__.py +5 -0
- w1x/app.py +754 -0
- w1x/config.py +189 -0
- w1x/engine.py +565 -0
- w1x/memory.py +272 -0
- w1x/models.py +458 -0
- w1x/puller.py +955 -0
- w1x/terminal.py +661 -0
- w1x/testpy.py +325 -0
- w1x/tools.py +897 -0
- w1x_cli-3.5.0.dist-info/METADATA +178 -0
- w1x_cli-3.5.0.dist-info/RECORD +16 -0
- w1x_cli-3.5.0.dist-info/WHEEL +5 -0
- w1x_cli-3.5.0.dist-info/entry_points.txt +2 -0
- w1x_cli-3.5.0.dist-info/licenses/LICENSE +21 -0
- w1x_cli-3.5.0.dist-info/top_level.txt +1 -0
w1x/terminal.py
ADDED
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Интерфейс терминала, темы Rich, буфер обмена Windows и горячие клавиши prompt_toolkit."""
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
import shutil
|
|
7
|
+
import threading
|
|
8
|
+
import subprocess
|
|
9
|
+
|
|
10
|
+
from prompt_toolkit import PromptSession
|
|
11
|
+
from prompt_toolkit.auto_suggest import AutoSuggest, Suggestion
|
|
12
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
13
|
+
from prompt_toolkit.formatted_text import HTML
|
|
14
|
+
from prompt_toolkit.history import InMemoryHistory
|
|
15
|
+
from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES
|
|
16
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
17
|
+
from prompt_toolkit.key_binding.key_processor import KeyPress
|
|
18
|
+
from prompt_toolkit.keys import Keys
|
|
19
|
+
from prompt_toolkit.selection import SelectionType
|
|
20
|
+
|
|
21
|
+
from rich.console import Console, ConsoleOptions, RenderResult
|
|
22
|
+
from rich.markdown import CodeBlock, Markdown
|
|
23
|
+
from rich.panel import Panel
|
|
24
|
+
from rich.syntax import Syntax
|
|
25
|
+
from rich.text import Text
|
|
26
|
+
from rich.theme import Theme
|
|
27
|
+
|
|
28
|
+
from .config import (
|
|
29
|
+
COMMAND_HINTS,
|
|
30
|
+
SPINNER,
|
|
31
|
+
SPINNER_DELAY,
|
|
32
|
+
ACTIVE_SESSION_DATA,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def configure_utf8_console():
|
|
37
|
+
"""Включить UTF-8 и Virtual Terminal Processing в консоли Windows."""
|
|
38
|
+
if os.name == "nt":
|
|
39
|
+
subprocess.run(
|
|
40
|
+
["cmd.exe", "/d", "/c", "chcp", "65001"],
|
|
41
|
+
stdout=subprocess.DEVNULL,
|
|
42
|
+
stderr=subprocess.DEVNULL,
|
|
43
|
+
check=False,
|
|
44
|
+
)
|
|
45
|
+
try:
|
|
46
|
+
import ctypes
|
|
47
|
+
kernel32 = ctypes.windll.kernel32
|
|
48
|
+
for handle_id in (-11, -12):
|
|
49
|
+
handle = kernel32.GetStdHandle(handle_id)
|
|
50
|
+
mode = ctypes.c_ulong()
|
|
51
|
+
if kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
|
|
52
|
+
mode.value |= 0x0004 | 0x0001
|
|
53
|
+
kernel32.SetConsoleMode(handle, mode)
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
for stream in (sys.stdout, sys.stderr):
|
|
58
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
59
|
+
if callable(reconfigure):
|
|
60
|
+
reconfigure(encoding="utf-8", errors="replace")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
configure_utf8_console()
|
|
64
|
+
|
|
65
|
+
CHAT_THEME = Theme(
|
|
66
|
+
{
|
|
67
|
+
"markdown.bold": "bold bright_white",
|
|
68
|
+
"markdown.code": "bold bright_cyan on grey11",
|
|
69
|
+
"markdown.item.bullet": "bright_cyan",
|
|
70
|
+
"markdown.link": "underline bright_blue",
|
|
71
|
+
"markdown.h1": "bold bright_cyan",
|
|
72
|
+
"markdown.h2": "bold cyan",
|
|
73
|
+
"markdown.h3": "bold cyan",
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
console = Console(theme=CHAT_THEME)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class CopyableCodeBlock(CodeBlock):
|
|
80
|
+
"""Markdown-код без декоративного отступа, портящего копирование."""
|
|
81
|
+
|
|
82
|
+
def __rich_console__(
|
|
83
|
+
self,
|
|
84
|
+
console_: Console,
|
|
85
|
+
options: ConsoleOptions,
|
|
86
|
+
) -> RenderResult:
|
|
87
|
+
code = str(self.text).rstrip()
|
|
88
|
+
yield Syntax(
|
|
89
|
+
code,
|
|
90
|
+
self.lexer_name,
|
|
91
|
+
theme=self.theme,
|
|
92
|
+
word_wrap=True,
|
|
93
|
+
padding=0,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class ChatMarkdown(Markdown):
|
|
98
|
+
elements = Markdown.elements.copy()
|
|
99
|
+
elements["fence"] = CopyableCodeBlock
|
|
100
|
+
elements["code_block"] = CopyableCodeBlock
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _stream_preview(text, role):
|
|
104
|
+
flattened = " ".join((text or "").split())
|
|
105
|
+
preview = flattened[-48:] if flattened else "…"
|
|
106
|
+
label = "Размышляет › " if role == "thinking" else "Печатает › "
|
|
107
|
+
style = "dim" if role == "thinking" else "cyan"
|
|
108
|
+
return Text(f"{label}{preview}", style=style, overflow="ellipsis", no_wrap=True)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def render_message(text, role="assistant", *, streaming=False, live=None):
|
|
112
|
+
"""Единая точка вывода текста модели, thinking и инструментов."""
|
|
113
|
+
text = "" if text is None else str(text)
|
|
114
|
+
|
|
115
|
+
if streaming:
|
|
116
|
+
renderable = _stream_preview(text, role)
|
|
117
|
+
if live is not None:
|
|
118
|
+
live.update(renderable, refresh=True)
|
|
119
|
+
else:
|
|
120
|
+
console.print(renderable)
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
if role == "assistant":
|
|
124
|
+
renderable = ChatMarkdown(text)
|
|
125
|
+
elif role == "thinking":
|
|
126
|
+
renderable = Panel(
|
|
127
|
+
ChatMarkdown(text),
|
|
128
|
+
title="Размышление",
|
|
129
|
+
border_style="bright_black",
|
|
130
|
+
padding=(0, 1),
|
|
131
|
+
)
|
|
132
|
+
elif role == "tool":
|
|
133
|
+
renderable = Text()
|
|
134
|
+
renderable.append("Инструмент › ", style="bold magenta")
|
|
135
|
+
renderable.append(text)
|
|
136
|
+
elif role == "assistant_label":
|
|
137
|
+
renderable = Text(text, style="bold cyan")
|
|
138
|
+
elif role == "notice":
|
|
139
|
+
renderable = Text(text, style="yellow")
|
|
140
|
+
else:
|
|
141
|
+
renderable = Text(text)
|
|
142
|
+
|
|
143
|
+
console.print(renderable)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def uses_modern_windows_terminal():
|
|
147
|
+
if os.name != "nt":
|
|
148
|
+
return True
|
|
149
|
+
return bool(
|
|
150
|
+
os.environ.get("WT_SESSION")
|
|
151
|
+
or os.environ.get("TERM_PROGRAM")
|
|
152
|
+
or os.environ.get("ConEmuANSI") == "ON"
|
|
153
|
+
or os.environ.get("ANSICON")
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def show_terminal_emoji_hint():
|
|
158
|
+
if not uses_modern_windows_terminal():
|
|
159
|
+
render_message(
|
|
160
|
+
"Для корректного отображения emoji рекомендуется Windows Terminal.",
|
|
161
|
+
role="notice",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
ANSI_SEQUENCES["\x1b[13;2u"] = Keys.ControlJ
|
|
166
|
+
ANSI_SEQUENCES["\x1b[13;5u"] = Keys.ControlJ
|
|
167
|
+
ANSI_SEQUENCES["\x1b[27;2;13~"] = Keys.ControlJ
|
|
168
|
+
ANSI_SEQUENCES["\x1b[27;5;13~"] = Keys.ControlJ
|
|
169
|
+
ANSI_SEQUENCES["\x1b[3;5~"] = Keys.ControlDelete
|
|
170
|
+
|
|
171
|
+
if os.name == "nt":
|
|
172
|
+
from prompt_toolkit.input.win32 import ConsoleInputReader
|
|
173
|
+
|
|
174
|
+
_original_event_to_key_presses = ConsoleInputReader._event_to_key_presses
|
|
175
|
+
|
|
176
|
+
def _event_to_key_presses_with_ctrl_backspace(self, event):
|
|
177
|
+
ctrl_pressed = event.ControlKeyState & (
|
|
178
|
+
self.LEFT_CTRL_PRESSED | self.RIGHT_CTRL_PRESSED
|
|
179
|
+
)
|
|
180
|
+
shift_pressed = bool(event.ControlKeyState & self.SHIFT_PRESSED)
|
|
181
|
+
if event.VirtualKeyCode == 8 and ctrl_pressed:
|
|
182
|
+
return [KeyPress(Keys.ControlW, "")]
|
|
183
|
+
if event.VirtualKeyCode == 13 and shift_pressed:
|
|
184
|
+
return [KeyPress(Keys.ControlJ, "\n")]
|
|
185
|
+
return _original_event_to_key_presses(self, event)
|
|
186
|
+
|
|
187
|
+
ConsoleInputReader._event_to_key_presses = (
|
|
188
|
+
_event_to_key_presses_with_ctrl_backspace
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _windows_shift_is_pressed():
|
|
193
|
+
"""Проверить зажатие Shift напрямую через Win32 API."""
|
|
194
|
+
if os.name != "nt":
|
|
195
|
+
return False
|
|
196
|
+
try:
|
|
197
|
+
import ctypes
|
|
198
|
+
|
|
199
|
+
return bool(ctypes.windll.user32.GetAsyncKeyState(0x10) & 0x8000)
|
|
200
|
+
except Exception:
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _windows_ctrl_is_pressed():
|
|
205
|
+
"""Проверить Ctrl напрямую через Win32 API."""
|
|
206
|
+
if os.name != "nt":
|
|
207
|
+
return False
|
|
208
|
+
try:
|
|
209
|
+
import ctypes
|
|
210
|
+
|
|
211
|
+
return bool(ctypes.windll.user32.GetAsyncKeyState(0x11) & 0x8000)
|
|
212
|
+
except Exception:
|
|
213
|
+
return False
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _read_clipboard_text():
|
|
217
|
+
"""Получить Unicode-текст из буфера Windows без порчи кириллицы/emoji."""
|
|
218
|
+
if os.name == "nt":
|
|
219
|
+
try:
|
|
220
|
+
import ctypes
|
|
221
|
+
from ctypes import wintypes
|
|
222
|
+
|
|
223
|
+
user32 = ctypes.windll.user32
|
|
224
|
+
kernel32 = ctypes.windll.kernel32
|
|
225
|
+
|
|
226
|
+
user32.OpenClipboard.argtypes = [wintypes.HWND]
|
|
227
|
+
user32.OpenClipboard.restype = wintypes.BOOL
|
|
228
|
+
user32.CloseClipboard.argtypes = []
|
|
229
|
+
user32.CloseClipboard.restype = wintypes.BOOL
|
|
230
|
+
user32.GetClipboardData.argtypes = [wintypes.UINT]
|
|
231
|
+
user32.GetClipboardData.restype = wintypes.HANDLE
|
|
232
|
+
kernel32.GlobalLock.argtypes = [wintypes.HGLOBAL]
|
|
233
|
+
kernel32.GlobalLock.restype = ctypes.c_wchar_p
|
|
234
|
+
kernel32.GlobalUnlock.argtypes = [wintypes.HGLOBAL]
|
|
235
|
+
kernel32.GlobalUnlock.restype = wintypes.BOOL
|
|
236
|
+
|
|
237
|
+
CF_UNICODETEXT = 13
|
|
238
|
+
if user32.OpenClipboard(None):
|
|
239
|
+
try:
|
|
240
|
+
handle = user32.GetClipboardData(CF_UNICODETEXT)
|
|
241
|
+
if handle:
|
|
242
|
+
text_ptr = kernel32.GlobalLock(handle)
|
|
243
|
+
if text_ptr:
|
|
244
|
+
result = str(text_ptr)
|
|
245
|
+
kernel32.GlobalUnlock(handle)
|
|
246
|
+
return result
|
|
247
|
+
finally:
|
|
248
|
+
user32.CloseClipboard()
|
|
249
|
+
except Exception:
|
|
250
|
+
pass
|
|
251
|
+
|
|
252
|
+
import tkinter
|
|
253
|
+
|
|
254
|
+
root = tkinter.Tk()
|
|
255
|
+
root.withdraw()
|
|
256
|
+
try:
|
|
257
|
+
root.update()
|
|
258
|
+
return root.clipboard_get()
|
|
259
|
+
finally:
|
|
260
|
+
root.destroy()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def copy_to_clipboard(text):
|
|
264
|
+
"""Скопировать текст в буфер обмена кроссплатформенно (Windows, macOS, Linux)."""
|
|
265
|
+
if sys.platform == "win32":
|
|
266
|
+
try:
|
|
267
|
+
subprocess.run(
|
|
268
|
+
["powershell.exe", "-NoProfile", "-Command", "Set-Clipboard"],
|
|
269
|
+
input=text,
|
|
270
|
+
text=True,
|
|
271
|
+
check=True,
|
|
272
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
273
|
+
)
|
|
274
|
+
return
|
|
275
|
+
except Exception:
|
|
276
|
+
pass
|
|
277
|
+
elif sys.platform == "darwin": # macOS
|
|
278
|
+
try:
|
|
279
|
+
subprocess.run(["pbcopy"], input=text, text=True, check=True)
|
|
280
|
+
return
|
|
281
|
+
except Exception:
|
|
282
|
+
pass
|
|
283
|
+
else: # Linux (Wayland / X11)
|
|
284
|
+
for cmd in (["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]):
|
|
285
|
+
try:
|
|
286
|
+
subprocess.run(cmd, input=text, text=True, check=True)
|
|
287
|
+
return
|
|
288
|
+
except Exception:
|
|
289
|
+
continue
|
|
290
|
+
|
|
291
|
+
# Универсальный fallback через tkinter
|
|
292
|
+
try:
|
|
293
|
+
import tkinter
|
|
294
|
+
root = tkinter.Tk()
|
|
295
|
+
root.withdraw()
|
|
296
|
+
root.clipboard_clear()
|
|
297
|
+
root.clipboard_append(text)
|
|
298
|
+
root.update()
|
|
299
|
+
root.destroy()
|
|
300
|
+
except Exception:
|
|
301
|
+
pass
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
bindings = KeyBindings()
|
|
305
|
+
_last_paste_burst_time = 0.0
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _is_multiline_paste_event(event) -> bool:
|
|
309
|
+
global _last_paste_burst_time
|
|
310
|
+
now = time.monotonic()
|
|
311
|
+
|
|
312
|
+
key_processor = getattr(event.app, "key_processor", None)
|
|
313
|
+
if key_processor and getattr(key_processor, "input_queue", None):
|
|
314
|
+
if any(
|
|
315
|
+
not isinstance(k.key, Keys)
|
|
316
|
+
or k.key in {Keys.ControlM, Keys.ControlJ, Keys.BracketedPaste, Keys.Any}
|
|
317
|
+
for k in key_processor.input_queue
|
|
318
|
+
):
|
|
319
|
+
_last_paste_burst_time = now
|
|
320
|
+
return True
|
|
321
|
+
|
|
322
|
+
if (now - _last_paste_burst_time) < 0.08:
|
|
323
|
+
return True
|
|
324
|
+
|
|
325
|
+
if os.name == "nt":
|
|
326
|
+
try:
|
|
327
|
+
input_obj = getattr(event.app, "input", None)
|
|
328
|
+
reader = getattr(input_obj, "console_input_reader", None)
|
|
329
|
+
handle = getattr(reader, "handle", None)
|
|
330
|
+
if handle:
|
|
331
|
+
import ctypes
|
|
332
|
+
from ctypes import wintypes
|
|
333
|
+
|
|
334
|
+
count = wintypes.DWORD(0)
|
|
335
|
+
if ctypes.windll.kernel32.GetNumberOfConsoleInputEvents(
|
|
336
|
+
handle, ctypes.byref(count)
|
|
337
|
+
):
|
|
338
|
+
if count.value > 0:
|
|
339
|
+
_last_paste_burst_time = now
|
|
340
|
+
return True
|
|
341
|
+
except Exception:
|
|
342
|
+
pass
|
|
343
|
+
|
|
344
|
+
return False
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@bindings.add("enter")
|
|
348
|
+
def submit_message(event):
|
|
349
|
+
"""Enter выбирает подсказку, переносит строку при Shift/вставке либо отправляет."""
|
|
350
|
+
buffer = event.current_buffer
|
|
351
|
+
completion_state = buffer.complete_state
|
|
352
|
+
if completion_state is not None and completion_state.current_completion:
|
|
353
|
+
buffer.apply_completion(completion_state.current_completion)
|
|
354
|
+
return
|
|
355
|
+
if buffer.suggestion is not None and buffer.suggestion.text:
|
|
356
|
+
buffer.insert_text(buffer.suggestion.text)
|
|
357
|
+
return
|
|
358
|
+
|
|
359
|
+
if _windows_shift_is_pressed():
|
|
360
|
+
buffer.insert_text("\n")
|
|
361
|
+
return
|
|
362
|
+
|
|
363
|
+
if _is_multiline_paste_event(event):
|
|
364
|
+
buffer.insert_text("\n")
|
|
365
|
+
return
|
|
366
|
+
|
|
367
|
+
buffer.validate_and_handle()
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@bindings.add(Keys.BracketedPaste)
|
|
371
|
+
def bracketed_paste_handler(event):
|
|
372
|
+
text = event.data.replace("\r\n", "\n").replace("\r", "\n")
|
|
373
|
+
event.current_buffer.insert_text(text)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@bindings.add("c-j")
|
|
377
|
+
def insert_line_ctrl_j(event):
|
|
378
|
+
event.current_buffer.insert_text("\n")
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@bindings.add("escape", "c-j")
|
|
382
|
+
def insert_line_ctrl_enter(event):
|
|
383
|
+
event.current_buffer.insert_text("\n")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@bindings.add("escape", "enter")
|
|
387
|
+
def insert_line_alt_enter(event):
|
|
388
|
+
event.current_buffer.insert_text("\n")
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@bindings.add("c-q")
|
|
392
|
+
def exit_app_ctrl_q(event):
|
|
393
|
+
event.app.exit(result="/exit")
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
@bindings.add("c-s")
|
|
397
|
+
def undo_and_restore_prompt(event):
|
|
398
|
+
from .app import save_current_chat
|
|
399
|
+
messages = ACTIVE_SESSION_DATA.get("messages", [])
|
|
400
|
+
system_prompt = ACTIVE_SESSION_DATA.get("system_prompt", "")
|
|
401
|
+
last_user_prompt = ""
|
|
402
|
+
|
|
403
|
+
if messages and messages[-1].get("role") == "assistant":
|
|
404
|
+
messages.pop()
|
|
405
|
+
if messages and messages[-1].get("role") == "user":
|
|
406
|
+
last_user_prompt = messages.pop().get("content", "")
|
|
407
|
+
|
|
408
|
+
if last_user_prompt:
|
|
409
|
+
save_current_chat(system_prompt, messages)
|
|
410
|
+
buffer = event.app.current_buffer
|
|
411
|
+
buffer.text = last_user_prompt
|
|
412
|
+
buffer.cursor_position = len(buffer.text)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@bindings.add("c-delete")
|
|
416
|
+
def delete_word_right(event):
|
|
417
|
+
buffer = event.app.current_buffer
|
|
418
|
+
if buffer.selection_state is not None:
|
|
419
|
+
buffer.cut_selection()
|
|
420
|
+
return
|
|
421
|
+
|
|
422
|
+
position = buffer.document.find_next_word_ending(
|
|
423
|
+
include_current_position=True
|
|
424
|
+
)
|
|
425
|
+
count = (
|
|
426
|
+
len(buffer.document.text_after_cursor)
|
|
427
|
+
if position is None
|
|
428
|
+
else position
|
|
429
|
+
)
|
|
430
|
+
buffer.delete(count=count)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
@bindings.add("c-a")
|
|
434
|
+
def select_all_input(event):
|
|
435
|
+
buffer = event.app.current_buffer
|
|
436
|
+
if not buffer.text:
|
|
437
|
+
return
|
|
438
|
+
|
|
439
|
+
buffer.cursor_position = 0
|
|
440
|
+
buffer.start_selection(selection_type=SelectionType.CHARACTERS)
|
|
441
|
+
buffer.cursor_position = len(buffer.text)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
@bindings.add("c-x")
|
|
445
|
+
def cut_selection_to_clipboard(event):
|
|
446
|
+
"""Ctrl+X вырезает выделенный текст (или весь ввод) и копирует в буфер обмена."""
|
|
447
|
+
buffer = event.app.current_buffer
|
|
448
|
+
if buffer.selection_state is not None:
|
|
449
|
+
data = buffer.cut_selection()
|
|
450
|
+
if data and data.text:
|
|
451
|
+
copy_to_clipboard(data.text)
|
|
452
|
+
elif buffer.text:
|
|
453
|
+
text_to_cut = buffer.text
|
|
454
|
+
buffer.text = ""
|
|
455
|
+
buffer.cursor_position = 0
|
|
456
|
+
copy_to_clipboard(text_to_cut)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
@bindings.add("c-c")
|
|
460
|
+
def copy_selection_or_abort(event):
|
|
461
|
+
"""Ctrl+C при наличии выделения копирует текст в буфер, иначе прерывает."""
|
|
462
|
+
buffer = event.app.current_buffer
|
|
463
|
+
if buffer.selection_state is not None:
|
|
464
|
+
data = buffer.copy_selection()
|
|
465
|
+
if data and data.text:
|
|
466
|
+
copy_to_clipboard(data.text)
|
|
467
|
+
buffer.exit_selection()
|
|
468
|
+
else:
|
|
469
|
+
event.app.exit(exception=KeyboardInterrupt, style="class:aborting")
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
@bindings.add("c-v", eager=True)
|
|
473
|
+
def paste_clipboard(event):
|
|
474
|
+
try:
|
|
475
|
+
text = _read_clipboard_text()
|
|
476
|
+
except Exception:
|
|
477
|
+
event.app.output.bell()
|
|
478
|
+
return
|
|
479
|
+
|
|
480
|
+
event.app.current_buffer.insert_text(
|
|
481
|
+
text.replace("\r\n", "\n").replace("\r", "\n")
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
@bindings.add("delete")
|
|
486
|
+
def delete_or_clear_selection(event):
|
|
487
|
+
buffer = event.app.current_buffer
|
|
488
|
+
if buffer.selection_state is not None:
|
|
489
|
+
buffer.cut_selection()
|
|
490
|
+
else:
|
|
491
|
+
buffer.delete(count=1)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _delete_word_left(buffer):
|
|
495
|
+
position = buffer.document.find_start_of_previous_word()
|
|
496
|
+
count = (
|
|
497
|
+
len(buffer.document.text_before_cursor)
|
|
498
|
+
if position is None
|
|
499
|
+
else -position
|
|
500
|
+
)
|
|
501
|
+
if count > 0:
|
|
502
|
+
buffer.delete_before_cursor(count=count)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
@bindings.add("c-h")
|
|
506
|
+
def backspace_or_clear_selection(event):
|
|
507
|
+
buffer = event.app.current_buffer
|
|
508
|
+
if buffer.selection_state is not None:
|
|
509
|
+
buffer.cut_selection()
|
|
510
|
+
elif _windows_ctrl_is_pressed():
|
|
511
|
+
_delete_word_left(buffer)
|
|
512
|
+
else:
|
|
513
|
+
buffer.delete_before_cursor(count=1)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
@bindings.add("c-w")
|
|
517
|
+
def delete_word_left(event):
|
|
518
|
+
buffer = event.app.current_buffer
|
|
519
|
+
if buffer.selection_state is not None:
|
|
520
|
+
buffer.cut_selection()
|
|
521
|
+
return
|
|
522
|
+
_delete_word_left(buffer)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
class SlashCommandCompleter(Completer):
|
|
526
|
+
def get_completions(self, document, complete_event):
|
|
527
|
+
typed = document.text_before_cursor
|
|
528
|
+
if not typed.startswith("/") or any(char.isspace() for char in typed):
|
|
529
|
+
return
|
|
530
|
+
|
|
531
|
+
normalized = typed.casefold()
|
|
532
|
+
for command, description in COMMAND_HINTS.items():
|
|
533
|
+
if command.casefold().startswith(normalized):
|
|
534
|
+
yield Completion(
|
|
535
|
+
command,
|
|
536
|
+
start_position=-len(typed),
|
|
537
|
+
display=command,
|
|
538
|
+
display_meta=description,
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
class SlashCommandAutoSuggest(AutoSuggest):
|
|
543
|
+
def get_suggestion(self, buffer, document):
|
|
544
|
+
typed = document.text_before_cursor
|
|
545
|
+
if not typed.startswith("/") or any(char.isspace() for char in typed):
|
|
546
|
+
return None
|
|
547
|
+
|
|
548
|
+
normalized = typed.casefold()
|
|
549
|
+
matches = [
|
|
550
|
+
command
|
|
551
|
+
for command in COMMAND_HINTS
|
|
552
|
+
if command.casefold().startswith(normalized)
|
|
553
|
+
and len(command) > len(typed)
|
|
554
|
+
]
|
|
555
|
+
if not matches:
|
|
556
|
+
return None
|
|
557
|
+
|
|
558
|
+
command = matches[0]
|
|
559
|
+
return Suggestion(command[len(typed):])
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
_session = None
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def get_session():
|
|
566
|
+
"""Ленивая инициализация PromptSession с защитой от запуска в неинтерактивных пайпах."""
|
|
567
|
+
global _session
|
|
568
|
+
if _session is None:
|
|
569
|
+
try:
|
|
570
|
+
_session = PromptSession(
|
|
571
|
+
key_bindings=bindings,
|
|
572
|
+
history=InMemoryHistory(),
|
|
573
|
+
completer=SlashCommandCompleter(),
|
|
574
|
+
complete_while_typing=True,
|
|
575
|
+
auto_suggest=SlashCommandAutoSuggest(),
|
|
576
|
+
)
|
|
577
|
+
except Exception:
|
|
578
|
+
try:
|
|
579
|
+
from prompt_toolkit.output import DummyOutput
|
|
580
|
+
_session = PromptSession(
|
|
581
|
+
key_bindings=bindings,
|
|
582
|
+
history=InMemoryHistory(),
|
|
583
|
+
completer=SlashCommandCompleter(),
|
|
584
|
+
complete_while_typing=True,
|
|
585
|
+
auto_suggest=SlashCommandAutoSuggest(),
|
|
586
|
+
output=DummyOutput(),
|
|
587
|
+
)
|
|
588
|
+
except Exception:
|
|
589
|
+
_session = None
|
|
590
|
+
return _session
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
class _SessionProxy:
|
|
594
|
+
def __getattr__(self, name):
|
|
595
|
+
s = get_session()
|
|
596
|
+
if s is None:
|
|
597
|
+
raise RuntimeError("PromptSession недоступен в этом окружении.")
|
|
598
|
+
return getattr(s, name)
|
|
599
|
+
|
|
600
|
+
def prompt(self, *args, **kwargs):
|
|
601
|
+
s = get_session()
|
|
602
|
+
if s is None:
|
|
603
|
+
return input(str(args[0]) if args else "")
|
|
604
|
+
return s.prompt(*args, **kwargs)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
session = _SessionProxy()
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def read_input():
|
|
611
|
+
return session.prompt(
|
|
612
|
+
HTML("<b><ansigreen>Ты › </ansigreen></b>"),
|
|
613
|
+
multiline=True,
|
|
614
|
+
prompt_continuation=lambda width, line_number, wrap_count: HTML(
|
|
615
|
+
"<ansibrightblack>... │ </ansibrightblack>"
|
|
616
|
+
),
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def run_with_spinner(text, function):
|
|
621
|
+
finished = threading.Event()
|
|
622
|
+
result = {"value": None, "error": None}
|
|
623
|
+
terminal_width = shutil.get_terminal_size(fallback=(80, 24)).columns
|
|
624
|
+
max_text_width = max(12, terminal_width - 4)
|
|
625
|
+
display_text = (
|
|
626
|
+
text
|
|
627
|
+
if len(text) <= max_text_width
|
|
628
|
+
else text[: max_text_width - 1] + "…"
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
def worker():
|
|
632
|
+
try:
|
|
633
|
+
result["value"] = function()
|
|
634
|
+
except Exception as error:
|
|
635
|
+
result["error"] = error
|
|
636
|
+
finally:
|
|
637
|
+
finished.set()
|
|
638
|
+
|
|
639
|
+
thread = threading.Thread(target=worker, daemon=True)
|
|
640
|
+
thread.start()
|
|
641
|
+
frame = 0
|
|
642
|
+
|
|
643
|
+
try:
|
|
644
|
+
while not finished.is_set():
|
|
645
|
+
symbol = SPINNER[frame % len(SPINNER)]
|
|
646
|
+
sys.stdout.write(f"\r{symbol} {display_text}")
|
|
647
|
+
sys.stdout.flush()
|
|
648
|
+
frame += 1
|
|
649
|
+
time.sleep(SPINNER_DELAY)
|
|
650
|
+
except KeyboardInterrupt:
|
|
651
|
+
from .tools import cancel_active_agy
|
|
652
|
+
cancel_active_agy()
|
|
653
|
+
raise
|
|
654
|
+
finally:
|
|
655
|
+
sys.stdout.write("\r" + " " * (len(display_text) + 4) + "\r")
|
|
656
|
+
sys.stdout.flush()
|
|
657
|
+
|
|
658
|
+
thread.join()
|
|
659
|
+
if result["error"] is not None:
|
|
660
|
+
raise result["error"]
|
|
661
|
+
return result["value"]
|