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/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"]