hexcli 2.8.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.
hexcli/lineedit.py ADDED
@@ -0,0 +1,1110 @@
1
+ #!/usr/bin/env python3
2
+ """hexcli.lineedit — a real input line for the REPL.
3
+
4
+ Replaces bare ``input()`` with persistent history, Tab completion, word-wise
5
+ editing and multi-line paste. Pure stdlib: the key source is ``msvcrt`` (which
6
+ hexcli.agent already depends on for Esc-to-cancel), so this adds no third-party
7
+ dependency and keeps the "stdlib + numpy/onnxruntime" rule intact.
8
+
9
+ Design notes
10
+ ------------
11
+ *Testability.* The editor never touches ``msvcrt`` directly. It pulls tokens
12
+ from an injectable ``read_key`` callable and writes through an injectable
13
+ ``write``. Tests drive it with a scripted token list and assert on the returned
14
+ string, so the whole editor is covered by the offline suite with no terminal.
15
+
16
+ *Rendering.* Every redraw is anchored on the cursor position we set ourselves,
17
+ so the anchor is always known: move up ``cursor_row`` rows, clear downward,
18
+ rewrite, then move to the computed (row, col).
19
+
20
+ The one subtlety is deferred wrap: a line of exactly ``width`` characters
21
+ leaves the cursor at the right margin rather than on the next row, so a naive
22
+ ``ceil`` row count is off by one for exact multiples. Rather than special-case
23
+ it everywhere, ``_pad`` appends one space to any line whose visible length is
24
+ an exact positive multiple of the width. No line is then ever an exact
25
+ multiple, ``rows = n // width + 1`` holds universally, and the pad is
26
+ invisible.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import os
31
+ import re
32
+ import sys
33
+ import time
34
+ import unicodedata
35
+ from collections.abc import Callable, Iterable, Sequence
36
+ from pathlib import Path
37
+ from typing import Any
38
+
39
+ # ── key tokens ──────────────────────────────────────────────────────────────
40
+ # Multi-character names never collide with printable input, which is always a
41
+ # single character.
42
+ ENTER = "<enter>"
43
+ NEWLINE = "<newline>" # a newline that came from a paste, not a keypress
44
+ BACKSPACE = "<backspace>"
45
+ DELETE = "<delete>"
46
+ TAB = "<tab>"
47
+ LEFT = "<left>"
48
+ RIGHT = "<right>"
49
+ UP = "<up>"
50
+ DOWN = "<down>"
51
+ HOME = "<home>"
52
+ END = "<end>"
53
+ WORD_LEFT = "<word-left>"
54
+ WORD_RIGHT = "<word-right>"
55
+ KILL_WORD = "<kill-word>" # Ctrl+W
56
+ KILL_LINE = "<kill-line>" # Ctrl+K
57
+ KILL_TO_START = "<kill-to-start>" # Ctrl+U
58
+ CLEAR_SCREEN = "<clear-screen>" # Ctrl+L
59
+ INTERRUPT = "<interrupt>" # Ctrl+C
60
+ EOF_KEY = "<eof>" # Ctrl+D on an empty buffer
61
+ ESCAPE = "<escape>"
62
+ ZOOM_IN = "<zoom-in>" # Ctrl+Plus (main row or numpad)
63
+ ZOOM_OUT = "<zoom-out>" # Ctrl+Minus
64
+ PASTE = "<paste>" # prefix: the rest of the token is pasted text
65
+ EXHAUSTED = "<exhausted>" # key source ran out (tests / closed stdin)
66
+ IDLE = "<idle>" # nothing typed for a while: a chance to repaint
67
+ RESIZE = "<resize>" # the console window was resized: re-anchor
68
+
69
+ _ANSI_RE = re.compile(r"\033\[[0-9;?]*[A-Za-z]")
70
+
71
+ # Extended (0x00 / 0xe0 prefixed) scancodes on Windows.
72
+ _EXTENDED = {
73
+ "H": UP, "P": DOWN, "K": LEFT, "M": RIGHT,
74
+ "G": HOME, "O": END, "S": DELETE,
75
+ "s": WORD_LEFT, "t": WORD_RIGHT,
76
+ }
77
+ _CONTROL = {
78
+ "\r": ENTER, "\n": NEWLINE, "\t": TAB,
79
+ "\x08": BACKSPACE, "\x7f": BACKSPACE,
80
+ "\x01": HOME, "\x05": END,
81
+ "\x02": LEFT, "\x06": RIGHT,
82
+ "\x0e": DOWN, "\x10": UP,
83
+ "\x03": INTERRUPT, "\x04": EOF_KEY,
84
+ "\x0b": KILL_LINE, "\x15": KILL_TO_START, "\x17": KILL_WORD,
85
+ "\x0c": CLEAR_SCREEN, "\x1b": ESCAPE,
86
+ }
87
+
88
+
89
+ def _cell_width(ch: str) -> int:
90
+ if unicodedata.combining(ch):
91
+ return 0
92
+ return 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
93
+
94
+
95
+ def visible_len(text: str) -> int:
96
+ """Cells ignoring ANSI styling — what the terminal actually shows. Wide
97
+ (CJK, emoji) glyphs count two."""
98
+ return sum(_cell_width(ch) for ch in _ANSI_RE.sub("", text))
99
+
100
+
101
+ def _wrap_visible(text: str, width: int) -> str:
102
+ """Insert a newline after every `width` visible characters, leaving ANSI
103
+ styling untouched and never ending on a newline."""
104
+ out: list[str] = []
105
+ col = 0
106
+ i = 0
107
+ while i < len(text):
108
+ m = _ANSI_RE.match(text, i)
109
+ if m:
110
+ out.append(m.group())
111
+ i = m.end()
112
+ continue
113
+ w = _cell_width(text[i])
114
+ if col + w > width:
115
+ out.append("\n")
116
+ col = 0
117
+ out.append(text[i])
118
+ col += w
119
+ i += 1
120
+ return "".join(out)
121
+
122
+
123
+ def _wrap_words_visible(text: str, width: int) -> str:
124
+ """Like _wrap_visible, but a row that would end mid-word breaks at the
125
+ last space on it instead (the space is dropped); a word wider than the
126
+ row still breaks hard. For finished lines, where no cursor arithmetic
127
+ depends on the break positions."""
128
+ out: list[str] = []
129
+ col = 0
130
+ space_at: int | None = None # index in `out` of the last space on this row
131
+ col_after_space = 0
132
+ i = 0
133
+ while i < len(text):
134
+ m = _ANSI_RE.match(text, i)
135
+ if m:
136
+ out.append(m.group())
137
+ i = m.end()
138
+ continue
139
+ ch = text[i]
140
+ w = _cell_width(ch)
141
+ if col + w > width:
142
+ if space_at is not None and ch != " ":
143
+ out[space_at] = "\n"
144
+ col -= col_after_space
145
+ else:
146
+ out.append("\n")
147
+ col = 0
148
+ space_at = None
149
+ if ch == " " and col == 0:
150
+ i += 1 # a break at a space: the space is the break
151
+ continue
152
+ out.append(ch)
153
+ col += w
154
+ if ch == " ":
155
+ space_at = len(out) - 1
156
+ col_after_space = col
157
+ i += 1
158
+ return "".join(out)
159
+
160
+
161
+ # ── key source ──────────────────────────────────────────────────────────────
162
+
163
+ _VK_ZOOM_IN = frozenset({0xBB, 0x6B}) # VK_OEM_PLUS, VK_ADD
164
+ _VK_ZOOM_OUT = frozenset({0xBD, 0x6D}) # VK_OEM_MINUS, VK_SUBTRACT
165
+ # Keys whose key-down carries no character and that msvcrt drops silently.
166
+ _VK_MODIFIERS = frozenset({0x10, 0x11, 0x12, 0x14, 0x5B, 0x5C, 0x90, 0x91,
167
+ 0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5})
168
+ _CTRL_PRESSED = 0x0008 | 0x0004 # LEFT_CTRL_PRESSED | RIGHT_CTRL_PRESSED
169
+ _SHIFT_PRESSED = 0x0010
170
+ _VK_RETURN = 0x0D
171
+
172
+
173
+ def _console_peek() -> Callable[[], str | None] | None:
174
+ """A look at the head of the console input queue, ahead of ``msvcrt``.
175
+
176
+ Ctrl+Plus / Ctrl+Minus produce no character, so ``getwch`` never returns
177
+ them — it skips the event. The returned callable blocks until an event is
178
+ queued, then: returns a zoom token for those chords (consuming the event);
179
+ returns "" after consuming an event msvcrt would drop anyway (key-ups,
180
+ bare modifiers, mouse/focus events) so the Ctrl key-down that precedes
181
+ the chord cannot hide it; returns None to hand a real key to msvcrt.
182
+ The factory returns None where stdin is not a console (tests, pipes).
183
+ """
184
+ if os.name != "nt":
185
+ return None
186
+ import ctypes
187
+ from ctypes import wintypes
188
+
189
+ class _KeyEvent(ctypes.Structure):
190
+ _fields_ = [("bKeyDown", wintypes.BOOL), ("wRepeatCount", wintypes.WORD),
191
+ ("wVirtualKeyCode", wintypes.WORD), ("wVirtualScanCode", wintypes.WORD),
192
+ ("UnicodeChar", wintypes.WCHAR), ("dwControlKeyState", wintypes.DWORD)]
193
+
194
+ class _Event(ctypes.Union):
195
+ _fields_ = [("KeyEvent", _KeyEvent), ("_raw", ctypes.c_byte * 16)]
196
+
197
+ class _Record(ctypes.Structure):
198
+ _fields_ = [("EventType", wintypes.WORD), ("Event", _Event)]
199
+
200
+ k32 = ctypes.windll.kernel32
201
+ handle = k32.GetStdHandle(-10)
202
+ mode = wintypes.DWORD()
203
+ if not k32.GetConsoleMode(handle, ctypes.byref(mode)):
204
+ return None
205
+ rec = _Record()
206
+ count = wintypes.DWORD()
207
+
208
+ def consume() -> None:
209
+ k32.ReadConsoleInputW(handle, ctypes.byref(rec), 1, ctypes.byref(count))
210
+
211
+ def peek() -> str | None:
212
+ # 100 ms slices, so a Ctrl+C at the prompt is still raised promptly.
213
+ # After a second with nothing queued, hand back IDLE so the editor
214
+ # can refresh the status line it draws under the input.
215
+ waited = 0
216
+ while k32.WaitForSingleObject(handle, 100) != 0:
217
+ waited += 1
218
+ if waited >= _IDLE_SLICES:
219
+ return IDLE
220
+ if not k32.PeekConsoleInputW(handle, ctypes.byref(rec), 1, ctypes.byref(count)) or not count.value:
221
+ return ""
222
+ if rec.EventType == 4: # WINDOW_BUFFER_SIZE_EVENT: the window resized
223
+ # A drag produces a burst of these; handle the last size once.
224
+ consume()
225
+ while (k32.PeekConsoleInputW(handle, ctypes.byref(rec), 1, ctypes.byref(count))
226
+ and count.value and rec.EventType == 4):
227
+ consume()
228
+ return RESIZE
229
+ if rec.EventType != 1: # not a KEY_EVENT
230
+ consume()
231
+ return ""
232
+ key = rec.Event.KeyEvent
233
+ if key.bKeyDown and key.wVirtualKeyCode == _VK_RETURN and key.dwControlKeyState & _SHIFT_PRESSED:
234
+ # Shift+Enter: a new line inside the entry, as in every chat UI.
235
+ # msvcrt would hand this over as a plain Enter and submit.
236
+ consume()
237
+ return NEWLINE
238
+ if key.bKeyDown and key.dwControlKeyState & _CTRL_PRESSED:
239
+ if key.wVirtualKeyCode in _VK_ZOOM_IN:
240
+ consume()
241
+ return ZOOM_IN
242
+ if key.wVirtualKeyCode in _VK_ZOOM_OUT:
243
+ consume()
244
+ return ZOOM_OUT
245
+ if not key.bKeyDown or (key.UnicodeChar == "\x00" and key.wVirtualKeyCode in _VK_MODIFIERS):
246
+ consume()
247
+ return ""
248
+ return None
249
+
250
+ return peek
251
+
252
+
253
+ def windows_key_reader() -> Callable[[], str]:
254
+ """Token stream over ``msvcrt``.
255
+
256
+ Paste detection: a pasted block arrives as a burst, so a carriage return
257
+ with more input already buffered behind it is a line break inside pasted
258
+ text, not the user pressing Enter. Without this, pasting a three-line
259
+ traceback submits the first line and leaves the rest as stray commands.
260
+ """
261
+ import msvcrt
262
+ peek = _console_peek()
263
+ pending: list[str] = []
264
+
265
+ def read() -> str:
266
+ if pending:
267
+ return pending.pop(0)
268
+ while peek is not None:
269
+ token = peek()
270
+ if token is None:
271
+ break
272
+ if token:
273
+ return token
274
+ ch = msvcrt.getwch()
275
+ if not msvcrt.kbhit():
276
+ # Ordinary typing: one key, nothing queued behind it.
277
+ if ch in ("\x00", "\xe0"):
278
+ return _EXTENDED.get(msvcrt.getwch(), "")
279
+ return _CONTROL.get(ch, ENTER if ch == "\r" else ch)
280
+ # A burst. Ctrl+V in a classic console injects the clipboard as
281
+ # keystrokes, so drain everything queued (with a short grace period
282
+ # for the console to finish injecting) and hand it over as ONE paste:
283
+ # one insert, one redraw, and never a submit — the old
284
+ # one-key-at-a-time path retyped the block visibly and treated a
285
+ # carriage return with an empty queue behind it as Enter, so a block
286
+ # that ended in a newline sent itself.
287
+ raw = [ch]
288
+ while True:
289
+ while msvcrt.kbhit():
290
+ raw.append(msvcrt.getwch())
291
+ time.sleep(_BURST_GRACE_S)
292
+ if not msvcrt.kbhit():
293
+ break
294
+ if _is_paste(raw):
295
+ return PASTE + _paste_text(raw)
296
+ tokens = _burst_tokens(raw)
297
+ pending.extend(tokens[1:])
298
+ return tokens[0] if tokens else ""
299
+
300
+ return read
301
+
302
+
303
+ _BURST_GRACE_S = 0.02
304
+ _PASTE_MIN_CHARS = 3
305
+ _IDLE_SLICES = 10 # × 100 ms between idle repaints of the status line
306
+
307
+
308
+ def _is_paste(raw: list[str]) -> bool:
309
+ """Three or more queued characters is a paste, not key rollover."""
310
+ return len(raw) >= _PASTE_MIN_CHARS
311
+
312
+
313
+ def _burst_tokens(raw: list[str]) -> list[str]:
314
+ """A short burst (fast typing) replayed as ordinary tokens."""
315
+ out: list[str] = []
316
+ i = 0
317
+ while i < len(raw):
318
+ ch = raw[i]
319
+ i += 1
320
+ if ch in ("\x00", "\xe0"):
321
+ if i < len(raw):
322
+ out.append(_EXTENDED.get(raw[i], ""))
323
+ i += 1
324
+ continue
325
+ if ch == "\r":
326
+ out.append(ENTER if i == len(raw) else NEWLINE)
327
+ continue
328
+ out.append(_CONTROL.get(ch, ch))
329
+ return [t for t in out if t]
330
+
331
+
332
+ def _paste_text(raw: list[str]) -> str:
333
+ """Clipboard keystrokes as text: CR and CRLF become newlines, tabs become
334
+ four spaces (the editor measures a tab as one cell), extended-key pairs
335
+ and other control characters are dropped, and one trailing newline is
336
+ removed so the cursor lands at the end of the last pasted line."""
337
+ out: list[str] = []
338
+ i = 0
339
+ while i < len(raw):
340
+ ch = raw[i]
341
+ i += 1
342
+ if ch in ("\x00", "\xe0"):
343
+ i += 1
344
+ continue
345
+ if ch == "\r":
346
+ out.append("\n")
347
+ if i < len(raw) and raw[i] == "\n":
348
+ i += 1
349
+ continue
350
+ if ch == "\n":
351
+ out.append("\n")
352
+ elif ch == "\t":
353
+ out.append(" ")
354
+ elif ch >= " " and ch != "\x7f":
355
+ out.append(ch)
356
+ text = "".join(out)
357
+ return text[:-1] if text.endswith("\n") else text
358
+
359
+
360
+ # ── history ─────────────────────────────────────────────────────────────────
361
+
362
+ class History:
363
+ """Newline-delimited history file, most recent last.
364
+
365
+ Entries containing newlines are stored with literal ``\\n`` escapes so the
366
+ file stays one-entry-per-line and survives hand editing.
367
+ """
368
+
369
+ def __init__(self, path: Path | None = None, limit: int = 500) -> None:
370
+ self.path = path
371
+ self.limit = limit
372
+ self.entries: list[str] = []
373
+ self.load()
374
+
375
+ def load(self) -> None:
376
+ if self.path is None or not self.path.exists():
377
+ return
378
+ try:
379
+ raw = self.path.read_text(encoding="utf-8", errors="replace")
380
+ except OSError:
381
+ return
382
+ self.entries = [
383
+ line.replace("\\n", "\n")
384
+ for line in raw.splitlines()
385
+ if line.strip()
386
+ ][-self.limit:]
387
+
388
+ def add(self, entry: str) -> None:
389
+ entry = entry.strip()
390
+ # Skip blanks and immediate repeats; re-running the same command twice
391
+ # should not need two Up presses to get past.
392
+ if not entry or (self.entries and self.entries[-1] == entry):
393
+ return
394
+ self.entries.append(entry)
395
+ del self.entries[:-self.limit]
396
+ self.save()
397
+
398
+ def save(self) -> None:
399
+ if self.path is None:
400
+ return
401
+ try:
402
+ self.path.parent.mkdir(parents=True, exist_ok=True)
403
+ self.path.write_text(
404
+ "\n".join(e.replace("\n", "\\n") for e in self.entries) + "\n",
405
+ encoding="utf-8",
406
+ )
407
+ except OSError:
408
+ pass # history is a convenience; never break the REPL over it
409
+
410
+
411
+ # ── completion ──────────────────────────────────────────────────────────────
412
+
413
+ def _path_candidates(fragment: str) -> list[str]:
414
+ frag = fragment.replace("/", os.sep)
415
+ directory, _, stem = frag.rpartition(os.sep)
416
+ base = Path(directory) if directory else Path(".")
417
+ try:
418
+ names = sorted(p.name + (os.sep if p.is_dir() else "")
419
+ for p in base.iterdir())
420
+ except OSError:
421
+ return []
422
+ prefix = (directory + os.sep) if directory else ""
423
+ low = stem.lower()
424
+ return [prefix + n for n in names if n.lower().startswith(low)]
425
+
426
+
427
+ def default_completer(
428
+ commands: Sequence[str],
429
+ config_keys: Callable[[], Iterable[str]] | None = None,
430
+ ) -> Callable[[str], list[str]]:
431
+ """Completer over slash commands, their arguments, and file paths.
432
+
433
+ Returns a function mapping the text left of the cursor to candidate
434
+ completions *of the final word*.
435
+ """
436
+
437
+ def complete(text: str) -> list[str]:
438
+ stripped = text.lstrip()
439
+ parts = stripped.split()
440
+ trailing_space = text.endswith((" ", "\t"))
441
+ word = "" if trailing_space else (parts[-1] if parts else "")
442
+
443
+ # First word, and it looks like a command → complete command names.
444
+ if stripped.startswith("/") and len(parts) <= 1 and not trailing_space:
445
+ return [c for c in commands if c.startswith(word.lower())]
446
+
447
+ head = parts[0].lower() if parts else ""
448
+ if head == "/config" and len(parts) <= 2 and config_keys is not None:
449
+ return sorted(k for k in config_keys() if k.startswith(word))
450
+ if head in {"/resume", "/memory"}:
451
+ return [] # arguments are not on disk in a predictable place
452
+ return _path_candidates(word)
453
+
454
+ return complete
455
+
456
+
457
+ def common_prefix(items: Sequence[str]) -> str:
458
+ if not items:
459
+ return ""
460
+ first, last = min(items), max(items)
461
+ for i, ch in enumerate(first):
462
+ if i >= len(last) or last[i] != ch:
463
+ return first[:i]
464
+ return first
465
+
466
+
467
+ # ── the editor ──────────────────────────────────────────────────────────────
468
+
469
+ def _is_word_char(ch: str) -> bool:
470
+ return ch.isalnum() or ch in "_-."
471
+
472
+
473
+ class LineEditor:
474
+ """A single-buffer line editor supporting embedded newlines."""
475
+
476
+ CONT_PROMPT = "... "
477
+
478
+ def __init__(
479
+ self,
480
+ *,
481
+ history: History | None = None,
482
+ completer: Callable[[str], list[str]] | None = None,
483
+ read_key: Callable[[], str] | None = None,
484
+ write: Callable[[str], None] | None = None,
485
+ width: int | None = None,
486
+ height: int | None = None,
487
+ styled: bool | None = None,
488
+ margin: int = 0,
489
+ on_zoom: Callable[[int], Any] | None = None,
490
+ on_resize: Callable[[], Any] | None = None,
491
+ chrome: Callable[[int], tuple[list[str], list[str]]] | None = None,
492
+ placeholder: str = "",
493
+ finish_style: Callable[[str, int], str] | None = None,
494
+ geometry: Callable[[], tuple[int, int] | None] | None = None,
495
+ on_grow: Callable[[int], Any] | None = None,
496
+ make_room: Callable[[int], int] | None = None,
497
+ give_room: Callable[[int], int] | None = None,
498
+ ) -> None:
499
+ self.history = history or History()
500
+ self.completer = completer
501
+ self._read_key = read_key or windows_key_reader()
502
+ self._write = write or (lambda s: (sys.stdout.write(s), sys.stdout.flush()) and None)
503
+ self._forced_width = width
504
+ self._forced_height = height
505
+ # Left margin the output stream adds after every "\n" and "\r"
506
+ # (ui.install_margin). Rows then start `margin` columns in, so the
507
+ # usable width shrinks by that much and wraps must be explicit
508
+ # newlines — a terminal auto-wrap would start the next row at column 0.
509
+ self.margin = max(0, int(margin or 0))
510
+ self.on_zoom = on_zoom
511
+ # Called when the console window is resized: the caller reprints the
512
+ # transcript at the new width and re-pins the box (repl wires this to
513
+ # ui.redraw_transcript + the status area), then the editor re-anchors.
514
+ self.on_resize = on_resize
515
+ self._last_size: tuple[int, int] | None = None
516
+ # Rows drawn above and below the input while editing (the box and
517
+ # the status line, hexcli.statusbar). Called with the usable width
518
+ # on every render; each row must already fit in it. Not part of the
519
+ # transcript: the finished line is written without them.
520
+ self.chrome = chrome
521
+ # Dim hint shown after the prompt while nothing is typed; never part
522
+ # of the finished line.
523
+ self.placeholder = placeholder
524
+ # Applied to each physical row of the finished line as it is left on
525
+ # screen (row text, usable width) -> styled row; the caller uses it
526
+ # to put a light band behind the user's message.
527
+ self.finish_style = finish_style
528
+ # Where the editor's first row sits in the window (from `geometry`,
529
+ # (cursor row, height)), so a multi-row entry that would run past
530
+ # the bottom can tell the caller how far the window scrolled
531
+ # (`on_grow(rows)`): the caller keeps its own row bookkeeping right.
532
+ # Before scrolling, `make_room(n)` asks the caller to free rows above
533
+ # (blank pad rows under the banner are deleted, so the banner keeps
534
+ # its place; returns how many it freed); `give_room(n)` puts them
535
+ # back when the entry shrinks again.
536
+ self.geometry = geometry
537
+ self.on_grow = on_grow
538
+ self.make_room = make_room
539
+ self.give_room = give_room
540
+ self._borrowed = 0
541
+ self._anchor_row: int | None = None
542
+ self.styled = sys.stdout.isatty() if styled is None else styled
543
+ self.buffer = ""
544
+ self.pos = 0
545
+ self._rendered_rows = 0
546
+ self._cursor_row = 0
547
+ self._last_text: str | None = None
548
+ self._hist_index: int | None = None
549
+ self._hist_prefix = ""
550
+ self._saved_draft = ""
551
+
552
+ # -- geometry -----------------------------------------------------------
553
+
554
+ @property
555
+ def width(self) -> int:
556
+ if self._forced_width:
557
+ return self._forced_width
558
+ try:
559
+ return max(20, os.get_terminal_size().columns)
560
+ except OSError:
561
+ return 80
562
+
563
+ @property
564
+ def height(self) -> int:
565
+ if self._forced_height:
566
+ return self._forced_height
567
+ try:
568
+ return max(4, os.get_terminal_size().lines)
569
+ except OSError:
570
+ return 24
571
+
572
+ @property
573
+ def usable(self) -> int:
574
+ """Columns a row can hold inside the margins — the same figure the
575
+ output stream wraps at (ui._Margin.usable), so it never wraps us."""
576
+ return max(10, self.width - 2 * self.margin)
577
+
578
+ def _pad(self, visible: int) -> str:
579
+ """See module docstring: kill the exact-multiple wrap ambiguity."""
580
+ w = self.usable
581
+ return " " if visible and visible % w == 0 else ""
582
+
583
+ def _rows(self, visible: int) -> int:
584
+ return visible // self.usable + 1
585
+
586
+ def _fit(self, text: str, words: bool = False) -> str:
587
+ """With a margin, break rows with explicit newlines (see __init__)."""
588
+ if not self.margin:
589
+ return text
590
+ return (_wrap_words_visible if words else _wrap_visible)(text, self.usable)
591
+
592
+ # -- rendering ----------------------------------------------------------
593
+
594
+ def _layout(self, prompt: str, chrome: bool = True, pad: bool = True,
595
+ words: bool = False) -> tuple[str, int, int, int]:
596
+ """Return (text_to_write, total_rows, cursor_row, cursor_col).
597
+ `pad=False` skips the deferred-wrap spare row: the finished line
598
+ needs no cursor arithmetic, and a styled spare row would show.
599
+ `words=True` breaks rows at spaces (the finished echo); the cursor
600
+ figures are then meaningless."""
601
+ prompt_lines = prompt.split("\n")
602
+ buf_lines = self.buffer.split("\n")
603
+ above: list[str] = []
604
+ below: list[str] = []
605
+ if chrome and self.chrome is not None:
606
+ above, below = self.chrome(self.usable)
607
+
608
+ # Logical lines: chrome rows above; the prompt's leading lines stand
609
+ # alone; its last line is the prefix of the first buffer line; later
610
+ # buffer lines get the continuation prompt; chrome rows below.
611
+ logical: list[tuple[str, int]] = [] # (rendered text, visible width)
612
+ for line in above:
613
+ logical.append((line, visible_len(line)))
614
+ for line in prompt_lines[:-1]:
615
+ logical.append((line, visible_len(line)))
616
+ last_prompt = prompt_lines[-1]
617
+ prefixes = [last_prompt] + [self.CONT_PROMPT] * (len(buf_lines) - 1)
618
+ for prefix, line in zip(prefixes, buf_lines):
619
+ logical.append((prefix + line, visible_len(prefix) + len(line)))
620
+ if chrome and self.placeholder and not self.buffer:
621
+ hint = self.placeholder[: max(0, self.usable - visible_len(last_prompt) - 1)]
622
+ styled = f"\033[2m{hint}\033[0m" if self.styled else hint
623
+ logical[-1] = (last_prompt + styled, visible_len(last_prompt) + len(hint))
624
+ for line in below:
625
+ logical.append((line, visible_len(line)))
626
+
627
+ # Cursor: which buffer line, and how far into it.
628
+ before = self.buffer[:self.pos]
629
+ cur_line = before.count("\n")
630
+ col_in_line = len(before) - (before.rfind("\n") + 1)
631
+ cursor_logical = len(above) + len(prompt_lines) - 1 + cur_line
632
+ cursor_vis = visible_len(prefixes[cur_line]) + col_in_line
633
+
634
+ pieces: list[str] = []
635
+ total_rows = 0
636
+ cursor_row = 0
637
+ for i, (text, vis) in enumerate(logical):
638
+ if i == cursor_logical:
639
+ cursor_row = total_rows + cursor_vis // self.usable
640
+ pieces.append(self._fit(text + (self._pad(vis) if pad else ""), words))
641
+ total_rows += self._rows(vis)
642
+ return "\n".join(pieces), total_rows, cursor_row, cursor_vis % self.usable
643
+
644
+ def _move_to_anchor(self) -> str:
645
+ """Cursor → column 0 of the first rendered row."""
646
+ out = "\r"
647
+ if self._cursor_row:
648
+ out += f"\033[{self._cursor_row}A"
649
+ return out
650
+
651
+ def _read_anchor(self) -> None:
652
+ self._anchor_row = None
653
+ self._borrowed = 0
654
+ if self.geometry is None:
655
+ return
656
+ # The caller's last cursor moves (the box coming down, the pad) are
657
+ # escape sequences with no newline: a line-buffered stdout still
658
+ # holds them, and the console would report the row from before.
659
+ self._write("")
660
+ try:
661
+ geo = self.geometry()
662
+ except Exception:
663
+ geo = None
664
+ if geo is not None:
665
+ self._anchor_row = geo[0]
666
+
667
+ def _note_growth(self, total_rows: int) -> None:
668
+ """More rows than fit below the anchor: first take blank rows from
669
+ above (`make_room`, the content shifts up and the banner stays), and
670
+ only then let the window scroll, reporting that distance once. The
671
+ anchor moves up either way."""
672
+ if self._anchor_row is None:
673
+ return
674
+ # The anchor already moved up with every row freed or scrolled so
675
+ # far, so whatever still hangs past the bottom is new.
676
+ overflow = self._anchor_row + total_rows - self.height
677
+ if overflow <= 0:
678
+ return
679
+ made = 0
680
+ if self.make_room is not None:
681
+ try:
682
+ made = max(0, min(overflow, int(self.make_room(overflow))))
683
+ except Exception:
684
+ made = 0
685
+ self._borrowed += made
686
+ self._anchor_row -= made
687
+ rest = overflow - made
688
+ if rest > 0 and self.on_grow is not None:
689
+ self._anchor_row -= rest
690
+ try:
691
+ self.on_grow(rest)
692
+ except Exception:
693
+ pass
694
+
695
+ def _note_shrink(self, total_rows: int) -> None:
696
+ """The entry shrank after growth had pushed rows out: give borrowed
697
+ pad rows back (`give_room`, the content shifts down again) and, for
698
+ rows the window scrolled away, move the anchor down over blank rows
699
+ so the box keeps the window's last rows. Writes directly: the old
700
+ rows are cleared first, or the insert would push them (and the
701
+ cursor) past the bottom, where the console clamps it."""
702
+ if self._anchor_row is None or not (0 < total_rows < self._rendered_rows):
703
+ return
704
+ slack = self.height - (self._anchor_row + total_rows)
705
+ if slack <= 0:
706
+ return
707
+ self._write(self._move_to_anchor() + "\033[J")
708
+ self._cursor_row = 0
709
+ given = 0
710
+ if self.give_room is not None and self._borrowed:
711
+ try:
712
+ given = max(0, min(slack, self._borrowed, int(self.give_room(min(slack, self._borrowed)))))
713
+ except Exception:
714
+ given = 0
715
+ self._borrowed -= given
716
+ self._anchor_row += given
717
+ rest = slack - given
718
+ if rest > 0:
719
+ self._write("\n" * rest)
720
+ self._anchor_row += rest
721
+
722
+ def render(self, prompt: str, if_changed: bool = False) -> None:
723
+ text, total_rows, cursor_row, cursor_col = self._layout(prompt)
724
+ if if_changed and text == self._last_text:
725
+ return # an idle tick with nothing new on the status line
726
+ self._last_text = text
727
+ self._note_growth(total_rows)
728
+ self._note_shrink(total_rows)
729
+ out = [self._move_to_anchor(), "\033[J", text]
730
+ # We are now at the end of the last row; walk back to the anchor and
731
+ # down to the cursor. Both legs are computed, so the next redraw's
732
+ # anchor stays exact.
733
+ end_row = total_rows - 1
734
+ out.append("\r")
735
+ if end_row > cursor_row:
736
+ out.append(f"\033[{end_row - cursor_row}A")
737
+ elif cursor_row > end_row:
738
+ out.append(f"\033[{cursor_row - end_row}B")
739
+ if cursor_col:
740
+ out.append(f"\033[{cursor_col}C")
741
+ self._rendered_rows = total_rows
742
+ self._cursor_row = cursor_row
743
+ payload = "".join(out)
744
+ if self.styled:
745
+ # Hide the cursor only for the duration of this redraw (kills the
746
+ # mid-repaint flicker), then show it again at its final position.
747
+ # Hiding it across the whole read() left users with no caret at
748
+ # all while typing.
749
+ payload = "\033[?25l" + payload + "\033[?25h"
750
+ self._write(payload)
751
+
752
+ def _finish_render(self, prompt: str) -> None:
753
+ """Leave the finished line on screen (without the chrome) and the
754
+ cursor below it. The line stays where it was typed: the caller keeps
755
+ the conversation anchored above the input, so the echo is already
756
+ in place."""
757
+ text, total_rows, cursor_row, _ = self._layout(prompt, chrome=False, pad=False, words=True)
758
+ if self.finish_style is not None:
759
+ text = "\n".join(self.finish_style(row, self.usable) for row in text.split("\n"))
760
+ self._write(self._move_to_anchor() + "\033[J" + text + "\n")
761
+ self._rendered_rows = 0
762
+ self._cursor_row = 0
763
+ self._last_text = None
764
+
765
+ # -- editing primitives -------------------------------------------------
766
+
767
+ def insert(self, text: str) -> None:
768
+ self.buffer = self.buffer[:self.pos] + text + self.buffer[self.pos:]
769
+ self.pos += len(text)
770
+
771
+ def _word_start(self) -> int:
772
+ i = self.pos
773
+ while i > 0 and not _is_word_char(self.buffer[i - 1]):
774
+ i -= 1
775
+ while i > 0 and _is_word_char(self.buffer[i - 1]):
776
+ i -= 1
777
+ return i
778
+
779
+ def _word_end(self) -> int:
780
+ i, n = self.pos, len(self.buffer)
781
+ while i < n and not _is_word_char(self.buffer[i]):
782
+ i += 1
783
+ while i < n and _is_word_char(self.buffer[i]):
784
+ i += 1
785
+ return i
786
+
787
+ def _line_start(self) -> int:
788
+ return self.buffer.rfind("\n", 0, self.pos) + 1
789
+
790
+ def _line_end(self) -> int:
791
+ nl = self.buffer.find("\n", self.pos)
792
+ return len(self.buffer) if nl < 0 else nl
793
+
794
+ # -- history navigation -------------------------------------------------
795
+
796
+ def _history_move(self, delta: int) -> None:
797
+ entries = self.history.entries
798
+ if not entries:
799
+ return
800
+ if self._hist_index is None:
801
+ if delta > 0:
802
+ return # already at the draft
803
+ self._saved_draft = self.buffer
804
+ # Prefix search: with text typed, Up walks only matching entries —
805
+ # the single most useful history behaviour there is.
806
+ self._hist_prefix = self.buffer[:self.pos]
807
+ self._hist_index = len(entries)
808
+
809
+ idx = self._hist_index
810
+ step = -1 if delta < 0 else 1
811
+ while True:
812
+ idx += step
813
+ if idx < 0:
814
+ return
815
+ if idx >= len(entries):
816
+ self._hist_index = None
817
+ self.buffer = self._saved_draft
818
+ self.pos = len(self.buffer)
819
+ return
820
+ if not self._hist_prefix or entries[idx].startswith(self._hist_prefix):
821
+ break
822
+ self._hist_index = idx
823
+ self.buffer = entries[idx]
824
+ self.pos = len(self.buffer)
825
+
826
+ # -- completion ---------------------------------------------------------
827
+
828
+ def _complete(self, prompt: str) -> None:
829
+ if self.completer is None:
830
+ return
831
+ left = self.buffer[:self.pos]
832
+ try:
833
+ candidates = self.completer(left)
834
+ except Exception:
835
+ return
836
+ if not candidates:
837
+ return
838
+ word = "" if left.endswith((" ", "\t")) else left.split()[-1] if left.split() else ""
839
+ # A path fragment's completions are full path strings, so replace the
840
+ # whole fragment rather than appending to it.
841
+ if len(candidates) == 1:
842
+ replacement = candidates[0]
843
+ suffix = "" if replacement.endswith(os.sep) else " "
844
+ self.buffer = left[:len(left) - len(word)] + replacement + suffix + self.buffer[self.pos:]
845
+ self.pos = len(left) - len(word) + len(replacement) + len(suffix)
846
+ return
847
+ shared = common_prefix(candidates)
848
+ if len(shared) > len(word):
849
+ self.buffer = left[:len(left) - len(word)] + shared + self.buffer[self.pos:]
850
+ self.pos = len(left) - len(word) + len(shared)
851
+ return
852
+ self._show_candidates(candidates, prompt)
853
+
854
+ def _show_candidates(self, candidates: Sequence[str], prompt: str) -> None:
855
+ shown = list(candidates[:40])
856
+ width = max((len(c) for c in shown), default=0) + 2
857
+ per_row = max(1, self.width // width)
858
+ lines = []
859
+ for i in range(0, len(shown), per_row):
860
+ lines.append("".join(c.ljust(width) for c in shown[i:i + per_row]).rstrip())
861
+ if len(candidates) > len(shown):
862
+ lines.append(f"... and {len(candidates) - len(shown)} more")
863
+ self._write(self._move_to_anchor() + "\033[J" + "\n".join(lines) + "\n")
864
+ self._cursor_row = 0
865
+ self.render(prompt)
866
+
867
+ # -- main loop ----------------------------------------------------------
868
+
869
+ def read(self, prompt: str = "> ") -> str:
870
+ """Read one logical input. Raises KeyboardInterrupt / EOFError like
871
+ ``input()`` does, so callers keep their existing handlers."""
872
+ self.buffer, self.pos = "", 0
873
+ self._hist_index, self._hist_prefix, self._saved_draft = None, "", ""
874
+ self._rendered_rows, self._cursor_row = 0, 0
875
+ self._last_text = None
876
+ self._last_size = (self.usable, self.height)
877
+ self._read_anchor()
878
+ try:
879
+ self.render(prompt)
880
+ while True:
881
+ key = self._read_key()
882
+ if key == "":
883
+ continue
884
+ if key == RESIZE:
885
+ self._handle_resize(prompt)
886
+ continue
887
+ if key == IDLE:
888
+ # A resize event can be missed (WT sends it late, or the
889
+ # peek dropped it); catch it by size comparison too.
890
+ if (self.usable, self.height) != self._last_size:
891
+ self._handle_resize(prompt)
892
+ else:
893
+ self.render(prompt, if_changed=True)
894
+ continue
895
+ result = self._handle(key, prompt)
896
+ if result is not None:
897
+ self._finish_render(prompt)
898
+ self.history.add(result)
899
+ return result
900
+ self.render(prompt)
901
+ except (KeyboardInterrupt, EOFError):
902
+ # Ctrl+C / Ctrl+D: keep what was typed on screen, drop the
903
+ # chrome, and leave the cursor on a fresh row like a finished
904
+ # line would; the caller's handler then prints as it always did.
905
+ self._finish_render(prompt)
906
+ raise
907
+ finally:
908
+ if self.styled:
909
+ self._write("\033[?25h")
910
+
911
+ def _handle_resize(self, prompt: str) -> None:
912
+ """Recover the display after the window was resized, touching only
913
+ our own rows: the transcript above stays exactly as the terminal
914
+ reflowed it.
915
+
916
+ The terminal re-wraps every row we wrote at the old width. The cursor
917
+ is still on the input row (terminals keep it on its cell), so the
918
+ number of rows now sitting between the top of the box and the cursor
919
+ is the sum, over the rows we wrote before the cursor row, of how many
920
+ rows each occupies at the new width, plus the cursor's own wrap
921
+ offset. Walk up that far, clear to the end of the screen (everything
922
+ below the box top is ours), let the caller re-pin the box, and render
923
+ at the new width.
924
+ """
925
+ old_usable, _old_height = self._last_size or (self.usable, self.height)
926
+ m = self.margin
927
+ saved_width = self._forced_width
928
+ self._forced_width = old_usable + 2 * m
929
+ try:
930
+ text, _rows, cursor_row, cursor_col = self._layout(prompt)
931
+ finally:
932
+ self._forced_width = saved_width
933
+ new_width = max(1, self.usable + 2 * m)
934
+
935
+ def occupied(row: str) -> int:
936
+ cells = m + visible_len(row)
937
+ return max(1, -(-cells // new_width))
938
+
939
+ up = sum(occupied(r) for r in text.split("\n")[:cursor_row]) + (m + cursor_col) // new_width
940
+ self._write("\r" + (f"\033[{up}A" if up else "") + "\033[J")
941
+ self._cursor_row = 0
942
+ self._rendered_rows = 0
943
+ self._last_text = None
944
+ if self.on_resize is not None:
945
+ try:
946
+ self.on_resize()
947
+ except Exception:
948
+ pass
949
+ self._last_size = (self.usable, self.height)
950
+ self._read_anchor()
951
+ self.render(prompt)
952
+
953
+ def _handle(self, key: str, prompt: str) -> str | None:
954
+ """Apply one key. Returns the finished line, or None to keep editing."""
955
+ buf = self.buffer
956
+
957
+ if key == ENTER:
958
+ # A trailing backslash is an explicit "keep going" — the one way to
959
+ # get a multi-line entry without pasting. It must be preceded by
960
+ # whitespace: on Windows every completed directory ends in "\", and
961
+ # treating that as a continuation made Tab-completing a folder and
962
+ # pressing Enter do nothing at all.
963
+ if buf.endswith("\\") and (len(buf) == 1 or buf[-2] in " \t"):
964
+ self.buffer = buf[:-1] + "\n"
965
+ self.pos = len(self.buffer)
966
+ return None
967
+ return buf
968
+ if key == NEWLINE:
969
+ self.insert("\n")
970
+ return None
971
+ if key == INTERRUPT:
972
+ raise KeyboardInterrupt
973
+ if key in (EOF_KEY, EXHAUSTED):
974
+ if buf:
975
+ return buf if key == EXHAUSTED else None
976
+ raise EOFError
977
+ if key == TAB:
978
+ self._complete(prompt)
979
+ return None
980
+ if key == BACKSPACE:
981
+ if self.pos:
982
+ self.buffer = buf[:self.pos - 1] + buf[self.pos:]
983
+ self.pos -= 1
984
+ return None
985
+ if key == DELETE:
986
+ self.buffer = buf[:self.pos] + buf[self.pos + 1:]
987
+ return None
988
+ if key == LEFT:
989
+ self.pos = max(0, self.pos - 1)
990
+ return None
991
+ if key == RIGHT:
992
+ self.pos = min(len(buf), self.pos + 1)
993
+ return None
994
+ if key == WORD_LEFT:
995
+ self.pos = self._word_start()
996
+ return None
997
+ if key == WORD_RIGHT:
998
+ self.pos = self._word_end()
999
+ return None
1000
+ if key == HOME:
1001
+ self.pos = self._line_start()
1002
+ return None
1003
+ if key == END:
1004
+ self.pos = self._line_end()
1005
+ return None
1006
+ if key == KILL_WORD:
1007
+ start = self._word_start()
1008
+ self.buffer = buf[:start] + buf[self.pos:]
1009
+ self.pos = start
1010
+ return None
1011
+ if key == KILL_TO_START:
1012
+ start = self._line_start()
1013
+ self.buffer = buf[:start] + buf[self.pos:]
1014
+ self.pos = start
1015
+ return None
1016
+ if key == KILL_LINE:
1017
+ self.buffer = buf[:self.pos] + buf[self._line_end():]
1018
+ return None
1019
+ if key in (UP, DOWN):
1020
+ self._history_move(-1 if key == UP else 1)
1021
+ return None
1022
+ if key == CLEAR_SCREEN:
1023
+ self._write("\033[2J\033[H")
1024
+ self._cursor_row = 0
1025
+ self._rendered_rows = 0
1026
+ if self.on_resize is not None:
1027
+ # The caller re-pins the box (and forgets its old pad rows,
1028
+ # which the clear just wiped) exactly as after a resize.
1029
+ try:
1030
+ self.on_resize()
1031
+ except Exception:
1032
+ pass
1033
+ elif self.chrome is not None:
1034
+ # Keep the box on the window's last rows after the clear.
1035
+ rows = self._layout(prompt)[1]
1036
+ self._write("\n" * max(0, self.height - rows))
1037
+ self._read_anchor()
1038
+ return None
1039
+ if key == ESCAPE:
1040
+ self.buffer, self.pos = "", 0
1041
+ self._hist_index = None
1042
+ return None
1043
+ if key in (ZOOM_IN, ZOOM_OUT):
1044
+ if self.on_zoom is not None and self.on_zoom(1 if key == ZOOM_IN else -1):
1045
+ # The handler redrew the screen: the prompt is gone and the
1046
+ # cursor sits on a fresh row, so the next render starts there.
1047
+ self._cursor_row = 0
1048
+ self._rendered_rows = 0
1049
+ return None
1050
+ if key.startswith(PASTE):
1051
+ self.insert(key[len(PASTE):])
1052
+ return None
1053
+ if len(key) == 1 and (key.isprintable() or key == " "):
1054
+ self.insert(key)
1055
+ return None
1056
+
1057
+
1058
+ # ── integration helper ──────────────────────────────────────────────────────
1059
+
1060
+ def make_reader(
1061
+ config: dict[str, Any],
1062
+ commands: Sequence[str],
1063
+ config_keys: Callable[[], Iterable[str]] | None = None,
1064
+ on_zoom: Callable[[int], Any] | None = None,
1065
+ on_resize: Callable[[], Any] | None = None,
1066
+ chrome: Callable[[int], tuple[list[str], list[str]]] | None = None,
1067
+ placeholder: str = "",
1068
+ write: Callable[[str], None] | None = None,
1069
+ finish_style: Callable[[str, int], str] | None = None,
1070
+ geometry: Callable[[], tuple[int, int] | None] | None = None,
1071
+ on_grow: Callable[[int], Any] | None = None,
1072
+ make_room: Callable[[int], int] | None = None,
1073
+ give_room: Callable[[int], int] | None = None,
1074
+ ) -> Callable[[str], str] | None:
1075
+ """Build the REPL's input function, or None if a rich line is unavailable.
1076
+
1077
+ Callers fall back to ``input()`` on None, so a non-tty (piped stdin, CI,
1078
+ ``--raw``) keeps working exactly as before. ``on_zoom`` receives +1 / -1
1079
+ for Ctrl+Plus / Ctrl+Minus; the margin follows config["side_padding"],
1080
+ the same value the REPL hands ui.install_margin; ``chrome`` is the
1081
+ status bar's rows above and below the input (hexcli.statusbar).
1082
+ """
1083
+ if not bool(config.get("rich_input", True)):
1084
+ return None
1085
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
1086
+ return None
1087
+ try:
1088
+ import msvcrt # noqa: F401
1089
+ except ImportError:
1090
+ return None
1091
+
1092
+ path_text = str(config.get("input_history_file", "") or "")
1093
+ path = Path(path_text).expanduser() if path_text else \
1094
+ Path.home() / ".shellai" / "input_history"
1095
+ editor = LineEditor(
1096
+ history=History(path, int(config.get("input_history_limit", 500))),
1097
+ completer=default_completer(commands, config_keys),
1098
+ margin=int(config.get("side_padding", 0) or 0),
1099
+ on_zoom=on_zoom,
1100
+ on_resize=on_resize,
1101
+ chrome=chrome,
1102
+ placeholder=placeholder,
1103
+ write=write,
1104
+ finish_style=finish_style,
1105
+ geometry=geometry,
1106
+ on_grow=on_grow,
1107
+ make_room=make_room,
1108
+ give_room=give_room,
1109
+ )
1110
+ return editor.read