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.

@@ -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: dict[str, str] | None = None):
27
+ def __init__(self, soul: Soul, welcome_info: list["WelcomeInfoItem"] | None = None):
24
28
  self.soul = soul
25
- self.welcome_info = welcome_info or {}
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.soul.model, self.welcome_info)
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
- command = user_input.command
66
- if command.startswith("/"):
67
- logger.debug("Running meta command: {command}", command=command)
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=command)
72
- await self._run_soul_command(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
- loop.remove_signal_handler(signal.SIGINT)
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, command: str) -> bool:
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.add_signal_handler(signal.SIGINT, _handler)
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
- command,
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
- loop.remove_signal_handler(signal.SIGINT)
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
- def _print_welcome_info(name: str, model: str, info_items: dict[str, str]) -> None:
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
- rows.extend(
245
- Text.from_markup(f"[grey50]{key}: {value}[/grey50]") for key, value in info_items.items()
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
@@ -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
- _ARROW_KEY_MAP: dict[str, KeyEvent] = {
102
- "\x1b[A": KeyEvent.UP,
103
- "\x1b[B": KeyEvent.DOWN,
104
- "\x1b[C": KeyEvent.RIGHT,
105
- "\x1b[D": KeyEvent.LEFT,
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
 
@@ -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 = "✨" if self._mode == PromptMode.AGENT else "$"
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
- return UserInput(mode=self._mode, command=command)
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()