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/__init__.py +6 -0
- hexcli/agent.py +1931 -0
- hexcli/assets/hexcli.ico +0 -0
- hexcli/assets/hexcli.png +0 -0
- hexcli/cancel.py +76 -0
- hexcli/chatlog.py +232 -0
- hexcli/commands.py +73 -0
- hexcli/compaction.py +309 -0
- hexcli/config.py +217 -0
- hexcli/diffview.py +106 -0
- hexcli/distribution.py +237 -0
- hexcli/doctor.py +265 -0
- hexcli/escalate.py +192 -0
- hexcli/http_client.py +156 -0
- hexcli/launcher.py +481 -0
- hexcli/lineedit.py +1110 -0
- hexcli/llm.py +599 -0
- hexcli/local_escalation.py +191 -0
- hexcli/lockfile.py +71 -0
- hexcli/loop_v2.py +393 -0
- hexcli/markdown_stream.py +241 -0
- hexcli/memory.py +416 -0
- hexcli/network.py +154 -0
- hexcli/parsing.py +215 -0
- hexcli/paths.py +127 -0
- hexcli/prompts.py +321 -0
- hexcli/protocol_v2.py +505 -0
- hexcli/repl.py +807 -0
- hexcli/safety.py +127 -0
- hexcli/sessions.py +226 -0
- hexcli/setup_wizard.py +144 -0
- hexcli/shell_session.py +186 -0
- hexcli/statusbar.py +894 -0
- hexcli/stream_render.py +250 -0
- hexcli/telemetry.py +131 -0
- hexcli/tools.py +775 -0
- hexcli/ui.py +1106 -0
- hexcli-2.8.0.dist-info/METADATA +394 -0
- hexcli-2.8.0.dist-info/RECORD +42 -0
- hexcli-2.8.0.dist-info/WHEEL +4 -0
- hexcli-2.8.0.dist-info/entry_points.txt +3 -0
- hexcli-2.8.0.dist-info/licenses/LICENSE +21 -0
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
|