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/ui.py
ADDED
|
@@ -0,0 +1,1106 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""hexcli.ui — presentation layer for Hex CLI.
|
|
3
|
+
|
|
4
|
+
Pure rendering/formatting: no imports from hexcli.agent (one-way dependency,
|
|
5
|
+
hexcli.agent -> hexcli.ui). Functions here take plain data (dicts, strings,
|
|
6
|
+
lists) rather than calling back into the data/backend layer.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import msvcrt
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import textwrap
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
import unicodedata
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from datetime import UTC, datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
_COLOR_ON = sys.stdout.isatty() and __import__("os").environ.get("NO_COLOR") is None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class C:
|
|
29
|
+
RESET = "\033[0m" if _COLOR_ON else ""
|
|
30
|
+
BOLD = "\033[1m" if _COLOR_ON else ""
|
|
31
|
+
DIM = "\033[2m" if _COLOR_ON else ""
|
|
32
|
+
RED = "\033[31m" if _COLOR_ON else ""
|
|
33
|
+
GREEN = "\033[32m" if _COLOR_ON else ""
|
|
34
|
+
YELLOW = "\033[33m" if _COLOR_ON else ""
|
|
35
|
+
BLUE = "\033[34m" if _COLOR_ON else ""
|
|
36
|
+
MAGENTA = "\033[35m" if _COLOR_ON else ""
|
|
37
|
+
CYAN = "\033[36m" if _COLOR_ON else ""
|
|
38
|
+
GRAY = "\033[90m" if _COLOR_ON else ""
|
|
39
|
+
BRED = "\033[91m" if _COLOR_ON else ""
|
|
40
|
+
BGREEN = "\033[92m" if _COLOR_ON else ""
|
|
41
|
+
BYELLOW = "\033[93m" if _COLOR_ON else ""
|
|
42
|
+
BBLUE = "\033[94m" if _COLOR_ON else ""
|
|
43
|
+
BMAGENTA = "\033[95m" if _COLOR_ON else ""
|
|
44
|
+
BCYAN = "\033[96m" if _COLOR_ON else ""
|
|
45
|
+
BWHITE = "\033[97m" if _COLOR_ON else ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cprint(text: str, color: str = "", bold: bool = False, file: Any = None) -> None:
|
|
49
|
+
prefix = (C.BOLD if bold else "") + color
|
|
50
|
+
suffix = C.RESET if prefix else ""
|
|
51
|
+
print(f"{prefix}{text}{suffix}", file=file or sys.stdout)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def set_color_enabled(enabled: bool) -> None:
|
|
55
|
+
"""Force ANSI styling on/off, overriding the isatty/NO_COLOR autodetect.
|
|
56
|
+
|
|
57
|
+
Used by shellai.py's --raw flag, which must take effect before any
|
|
58
|
+
output is printed.
|
|
59
|
+
"""
|
|
60
|
+
global _COLOR_ON
|
|
61
|
+
_COLOR_ON = enabled
|
|
62
|
+
codes = {
|
|
63
|
+
"RESET": "\033[0m", "BOLD": "\033[1m", "DIM": "\033[2m",
|
|
64
|
+
"RED": "\033[31m", "GREEN": "\033[32m", "YELLOW": "\033[33m",
|
|
65
|
+
"BLUE": "\033[34m", "MAGENTA": "\033[35m", "CYAN": "\033[36m",
|
|
66
|
+
"GRAY": "\033[90m", "BRED": "\033[91m", "BGREEN": "\033[92m",
|
|
67
|
+
"BYELLOW": "\033[93m", "BBLUE": "\033[94m", "BMAGENTA": "\033[95m",
|
|
68
|
+
"BCYAN": "\033[96m", "BWHITE": "\033[97m",
|
|
69
|
+
}
|
|
70
|
+
for name, code in codes.items():
|
|
71
|
+
setattr(C, name, code if enabled else "")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Spinner
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
# The status bar's live area when one is installed (hexcli.statusbar). The
|
|
79
|
+
# spinner then animates in the status line instead of on the transcript row.
|
|
80
|
+
LIVE_AREA: Any = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _live_area() -> Any:
|
|
84
|
+
live = LIVE_AREA
|
|
85
|
+
return live if live is not None and live.enabled else None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@contextlib.contextmanager
|
|
89
|
+
def paused_status() -> Any:
|
|
90
|
+
"""Take the status box down for an inline console prompt (a y/N confirm),
|
|
91
|
+
then restore it. A no-op when there is no box up, and safe to nest."""
|
|
92
|
+
live = _live_area()
|
|
93
|
+
if live is None:
|
|
94
|
+
yield
|
|
95
|
+
return
|
|
96
|
+
live.suspend()
|
|
97
|
+
try:
|
|
98
|
+
yield
|
|
99
|
+
finally:
|
|
100
|
+
live.resume()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Spinner:
|
|
104
|
+
_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
105
|
+
|
|
106
|
+
def __init__(self, label: str) -> None:
|
|
107
|
+
self.label = label
|
|
108
|
+
self._stop = threading.Event()
|
|
109
|
+
self._thread = threading.Thread(target=self._spin, daemon=True)
|
|
110
|
+
|
|
111
|
+
def _spin(self) -> None:
|
|
112
|
+
i = 0
|
|
113
|
+
while not self._stop.wait(0.08):
|
|
114
|
+
frame = self._FRAMES[i % len(self._FRAMES)]
|
|
115
|
+
live = _live_area()
|
|
116
|
+
if live is not None:
|
|
117
|
+
live.tick(frame)
|
|
118
|
+
elif self._on_terminal():
|
|
119
|
+
sys.stderr.write(f"\r{C.BCYAN}{frame}{C.RESET} {C.DIM}{self.label}...{C.RESET}")
|
|
120
|
+
sys.stderr.flush()
|
|
121
|
+
i += 1
|
|
122
|
+
|
|
123
|
+
@staticmethod
|
|
124
|
+
def _on_terminal() -> bool:
|
|
125
|
+
"""A pipe or a log gets no animation: the frames would land in the
|
|
126
|
+
captured output as a smear of carriage returns."""
|
|
127
|
+
try:
|
|
128
|
+
return sys.stderr.isatty()
|
|
129
|
+
except Exception: # noqa: BLE001
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
def __enter__(self) -> Spinner:
|
|
133
|
+
live = _live_area()
|
|
134
|
+
if live is not None:
|
|
135
|
+
live.set_activity(self.label)
|
|
136
|
+
self._thread.start()
|
|
137
|
+
return self
|
|
138
|
+
|
|
139
|
+
def __exit__(self, *_: object) -> None:
|
|
140
|
+
self._stop.set()
|
|
141
|
+
self._thread.join(timeout=1)
|
|
142
|
+
live = _live_area()
|
|
143
|
+
if live is not None:
|
|
144
|
+
# A "▸ tool" label set while the stream ran is for the tool
|
|
145
|
+
# about to execute; leave it. Anything else was ours.
|
|
146
|
+
if not (live.activity or "").startswith("▸ "):
|
|
147
|
+
live.set_activity(None)
|
|
148
|
+
return
|
|
149
|
+
if self._on_terminal():
|
|
150
|
+
sys.stderr.write("\r\033[K")
|
|
151
|
+
sys.stderr.flush()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# Console QuickEdit
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def enable_vt_processing() -> None:
|
|
159
|
+
"""Switch on ANSI/VT handling for stdout and stderr on a classic console.
|
|
160
|
+
|
|
161
|
+
The launcher does this for the shortcut window; a direct `python -m
|
|
162
|
+
hexcli` in a bare conhost would otherwise print the margin's reflow and
|
|
163
|
+
clear sequences (`ESC[K`, `ESC[nD`) as text. No-op under Windows
|
|
164
|
+
Terminal and on non-console streams.
|
|
165
|
+
"""
|
|
166
|
+
if os.name != "nt":
|
|
167
|
+
return
|
|
168
|
+
try:
|
|
169
|
+
import ctypes
|
|
170
|
+
k32 = ctypes.windll.kernel32
|
|
171
|
+
for std in (-11, -12):
|
|
172
|
+
handle = k32.GetStdHandle(std)
|
|
173
|
+
mode = ctypes.c_uint32()
|
|
174
|
+
if k32.GetConsoleMode(handle, ctypes.byref(mode)):
|
|
175
|
+
k32.SetConsoleMode(handle, mode.value | 0x0004)
|
|
176
|
+
except Exception:
|
|
177
|
+
pass
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def disable_quick_edit() -> None:
|
|
181
|
+
"""Turn off conhost QuickEdit for this console for the life of the REPL.
|
|
182
|
+
|
|
183
|
+
With QuickEdit on (the classic-console default) a click inside the window
|
|
184
|
+
starts a selection and EVERY console write blocks until a key is pressed:
|
|
185
|
+
the answer streams into a frozen screen and Ctrl+C — "copy" while text is
|
|
186
|
+
selected — is what releases it, without ever reaching Python. Measured
|
|
187
|
+
2026-09-04 in a window launched exactly like the Start Menu shortcut.
|
|
188
|
+
Windows Terminal ignores the flag; non-console stdin is left alone.
|
|
189
|
+
The original mode is restored at exit so a shared cmd window is not
|
|
190
|
+
changed permanently.
|
|
191
|
+
"""
|
|
192
|
+
if os.name != "nt":
|
|
193
|
+
return
|
|
194
|
+
try:
|
|
195
|
+
import atexit
|
|
196
|
+
import ctypes
|
|
197
|
+
k32 = ctypes.windll.kernel32
|
|
198
|
+
stdin = k32.GetStdHandle(-10)
|
|
199
|
+
mode = ctypes.c_uint32()
|
|
200
|
+
if not k32.GetConsoleMode(stdin, ctypes.byref(mode)):
|
|
201
|
+
return
|
|
202
|
+
original = mode.value
|
|
203
|
+
ENABLE_MOUSE_INPUT, ENABLE_QUICK_EDIT, ENABLE_EXTENDED_FLAGS = 0x0010, 0x0040, 0x0080
|
|
204
|
+
# Mouse input off as well: with it on and QuickEdit off, Windows
|
|
205
|
+
# Terminal routes the mouse to the application instead of selecting
|
|
206
|
+
# text (Shift+drag was the only way to copy). Hex reads no mouse
|
|
207
|
+
# events, so nothing is lost.
|
|
208
|
+
wanted = (original & ~ENABLE_QUICK_EDIT & ~ENABLE_MOUSE_INPUT) | ENABLE_EXTENDED_FLAGS
|
|
209
|
+
if k32.SetConsoleMode(stdin, wanted):
|
|
210
|
+
atexit.register(lambda: k32.SetConsoleMode(stdin, original))
|
|
211
|
+
except Exception:
|
|
212
|
+
pass
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
# Left margin
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
_ANSI_SEQ = re.compile(r"\033\[[0-9;?]*[A-Za-z]")
|
|
220
|
+
_REFLOW_MAX_WORD = 30 # longer "words" (URLs, hashes) break where they fall
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _cell_width(ch: str) -> int:
|
|
224
|
+
if ch == "\t":
|
|
225
|
+
return 0 # handled by the caller (advance to the next tab stop)
|
|
226
|
+
if unicodedata.combining(ch):
|
|
227
|
+
return 0
|
|
228
|
+
return 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class _Margin:
|
|
232
|
+
"""Shared state for the two wrapped streams: one screen, one cursor.
|
|
233
|
+
|
|
234
|
+
Rows are wrapped HERE, at `width - 2*pad` visible cells, so the terminal
|
|
235
|
+
never wraps for us — its continuation rows would start at column 0 with
|
|
236
|
+
no margin (the first bug report: "only the first line is indented").
|
|
237
|
+
Wrapping is word-aware even for text that arrives token by token: when a
|
|
238
|
+
row fills mid-word, the partial word already on screen is erased (cursor
|
|
239
|
+
left + clear to end of line) and reprinted at the start of the next row.
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
def __init__(self, pad: int, width: Callable[[], int] | None = None) -> None:
|
|
243
|
+
self.pad = pad
|
|
244
|
+
self.fill = " " * pad
|
|
245
|
+
self._width = width
|
|
246
|
+
self.col = 0 # visible cells printed on the current row
|
|
247
|
+
self.word = "" # raw text since the last break opportunity on this row
|
|
248
|
+
self.word_vis = 0
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def usable(self) -> int:
|
|
252
|
+
if self._width is not None:
|
|
253
|
+
width = self._width()
|
|
254
|
+
else:
|
|
255
|
+
try:
|
|
256
|
+
width = os.get_terminal_size().columns
|
|
257
|
+
except OSError:
|
|
258
|
+
width = 80
|
|
259
|
+
return max(10, width - 2 * self.pad)
|
|
260
|
+
|
|
261
|
+
def _newline(self, out: list[str], ctl: str = "\n") -> None:
|
|
262
|
+
out.append(ctl + self.fill)
|
|
263
|
+
self.col = 0
|
|
264
|
+
self.word, self.word_vis = "", 0
|
|
265
|
+
|
|
266
|
+
def render(self, s: str) -> str:
|
|
267
|
+
out: list[str] = []
|
|
268
|
+
usable = self.usable
|
|
269
|
+
i, n = 0, len(s)
|
|
270
|
+
while i < n:
|
|
271
|
+
ch = s[i]
|
|
272
|
+
if ch == "\033":
|
|
273
|
+
m = _ANSI_SEQ.match(s, i)
|
|
274
|
+
if m:
|
|
275
|
+
seq = m.group()
|
|
276
|
+
out.append(seq)
|
|
277
|
+
self.word += seq
|
|
278
|
+
i = m.end()
|
|
279
|
+
continue
|
|
280
|
+
i += 1
|
|
281
|
+
if ch == "\n" or ch == "\r":
|
|
282
|
+
self._newline(out, ch)
|
|
283
|
+
continue
|
|
284
|
+
if ch == "\t":
|
|
285
|
+
step = 8 - self.col % 8
|
|
286
|
+
if self.col + step > usable:
|
|
287
|
+
continue # a tab past the edge is invisible anyway
|
|
288
|
+
out.append(ch)
|
|
289
|
+
self.col += step
|
|
290
|
+
self.word, self.word_vis = "", 0
|
|
291
|
+
continue
|
|
292
|
+
w = _cell_width(ch)
|
|
293
|
+
if self.col + w > usable:
|
|
294
|
+
if ch == " ":
|
|
295
|
+
self.word, self.word_vis = "", 0
|
|
296
|
+
continue # the row ended on a space: nothing to show
|
|
297
|
+
# Row full mid-word. Reflow the partial word if it started
|
|
298
|
+
# after a space on this row and is short enough to bother.
|
|
299
|
+
if 0 < self.word_vis < self.col and self.word_vis <= _REFLOW_MAX_WORD:
|
|
300
|
+
out.append(f"\033[{self.word_vis}D\033[K")
|
|
301
|
+
word = self.word
|
|
302
|
+
self._newline(out)
|
|
303
|
+
out.append(word)
|
|
304
|
+
self.word, self.word_vis = word, _visible_cells(word)
|
|
305
|
+
self.col = self.word_vis
|
|
306
|
+
else:
|
|
307
|
+
self._newline(out)
|
|
308
|
+
out.append(ch)
|
|
309
|
+
self.col += w
|
|
310
|
+
if ch == " ":
|
|
311
|
+
self.word, self.word_vis = "", 0
|
|
312
|
+
else:
|
|
313
|
+
self.word += ch
|
|
314
|
+
self.word_vis += w
|
|
315
|
+
return "".join(out)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _visible_cells(text: str) -> int:
|
|
319
|
+
return sum(_cell_width(c) for c in _ANSI_SEQ.sub("", text))
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class _MarginStream:
|
|
323
|
+
"""A console stream with a left AND right margin (see _Margin).
|
|
324
|
+
|
|
325
|
+
Attribute access falls through to the wrapped stream (isatty, encoding,
|
|
326
|
+
buffer, reconfigure, ...). The line editor is told the same margin so its
|
|
327
|
+
wrap math and cursor moves agree (LineEditor.margin): its rows never
|
|
328
|
+
exceed the usable width, so this layer never wraps them.
|
|
329
|
+
"""
|
|
330
|
+
|
|
331
|
+
def __init__(self, base: Any, margin: _Margin) -> None:
|
|
332
|
+
self._base = base
|
|
333
|
+
self._margin = margin
|
|
334
|
+
|
|
335
|
+
@property
|
|
336
|
+
def pad(self) -> int:
|
|
337
|
+
return self._margin.pad
|
|
338
|
+
|
|
339
|
+
def write(self, s: str) -> int:
|
|
340
|
+
if s:
|
|
341
|
+
self._base.write(self._margin.render(s))
|
|
342
|
+
return len(s)
|
|
343
|
+
|
|
344
|
+
def writelines(self, lines: Any) -> None:
|
|
345
|
+
for line in lines:
|
|
346
|
+
self.write(line)
|
|
347
|
+
|
|
348
|
+
def __getattr__(self, name: str) -> Any:
|
|
349
|
+
return getattr(self._base, name)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def install_margin(pad: int) -> None:
|
|
353
|
+
"""Wrap stdout and stderr with a `pad`-column margin on both sides (tty only)."""
|
|
354
|
+
pad = max(0, int(pad or 0))
|
|
355
|
+
if not pad or isinstance(sys.stdout, _MarginStream):
|
|
356
|
+
return
|
|
357
|
+
try:
|
|
358
|
+
if not sys.stdout.isatty():
|
|
359
|
+
return
|
|
360
|
+
except Exception:
|
|
361
|
+
return
|
|
362
|
+
margin = _Margin(pad)
|
|
363
|
+
base = sys.stdout
|
|
364
|
+
sys.stdout = _MarginStream(base, margin)
|
|
365
|
+
sys.stderr = _MarginStream(sys.stderr, margin)
|
|
366
|
+
base.write(margin.fill) # the cursor is at column 0 right now
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# ---------------------------------------------------------------------------
|
|
370
|
+
# Console font (classic conhost only; Windows Terminal zooms by itself)
|
|
371
|
+
# ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
_FONT_STATE_PATH = Path.home() / ".shellai" / "console_font"
|
|
374
|
+
_FONT_MIN, _FONT_MAX, _FONT_STEP = 8, 40, 2
|
|
375
|
+
_ZOOM_ANCHOR_PX: tuple[int, int] | None = None # window size to keep across zooms
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _console_font_api() -> tuple[Any, Any, Any] | None:
|
|
379
|
+
if os.name != "nt":
|
|
380
|
+
return None
|
|
381
|
+
import ctypes
|
|
382
|
+
from ctypes import wintypes
|
|
383
|
+
|
|
384
|
+
class _Coord(ctypes.Structure):
|
|
385
|
+
_fields_ = [("X", wintypes.SHORT), ("Y", wintypes.SHORT)]
|
|
386
|
+
|
|
387
|
+
class _FontInfo(ctypes.Structure):
|
|
388
|
+
_fields_ = [("cbSize", wintypes.ULONG), ("nFont", wintypes.DWORD),
|
|
389
|
+
("dwFontSize", _Coord), ("FontFamily", wintypes.UINT),
|
|
390
|
+
("FontWeight", wintypes.UINT), ("FaceName", wintypes.WCHAR * 32)]
|
|
391
|
+
|
|
392
|
+
k32 = ctypes.windll.kernel32
|
|
393
|
+
handle = k32.GetStdHandle(-11)
|
|
394
|
+
info = _FontInfo()
|
|
395
|
+
info.cbSize = ctypes.sizeof(_FontInfo)
|
|
396
|
+
if not k32.GetCurrentConsoleFontEx(handle, False, ctypes.byref(info)):
|
|
397
|
+
return None
|
|
398
|
+
return k32, handle, info
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def console_font_height() -> int | None:
|
|
402
|
+
"""Current console font height in pixels, or None outside a console."""
|
|
403
|
+
api = _console_font_api()
|
|
404
|
+
return int(api[2].dwFontSize.Y) if api else None
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def set_console_font_height(height: int) -> bool:
|
|
408
|
+
api = _console_font_api()
|
|
409
|
+
if not api:
|
|
410
|
+
return False
|
|
411
|
+
import ctypes
|
|
412
|
+
k32, handle, info = api
|
|
413
|
+
info.dwFontSize.X = 0 # let the console pick the matching width
|
|
414
|
+
info.dwFontSize.Y = max(_FONT_MIN, min(_FONT_MAX, int(height)))
|
|
415
|
+
return bool(k32.SetCurrentConsoleFontEx(handle, False, ctypes.byref(info)))
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _console_client_px() -> tuple[int, int] | None:
|
|
419
|
+
"""Pixel size of the console window's client area (classic conhost)."""
|
|
420
|
+
if os.name != "nt" or os.environ.get("WT_SESSION"):
|
|
421
|
+
return None
|
|
422
|
+
import ctypes
|
|
423
|
+
from ctypes import wintypes
|
|
424
|
+
hwnd = ctypes.windll.kernel32.GetConsoleWindow()
|
|
425
|
+
if not hwnd:
|
|
426
|
+
return None
|
|
427
|
+
rect = wintypes.RECT()
|
|
428
|
+
if not ctypes.windll.user32.GetClientRect(hwnd, ctypes.byref(rect)):
|
|
429
|
+
return None
|
|
430
|
+
return rect.right - rect.left, rect.bottom - rect.top
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _refit_console_cells(client_px: tuple[int, int]) -> None:
|
|
434
|
+
"""After a font change, pick the column/row count that fills the SAME
|
|
435
|
+
pixel area, so the window keeps its size and only the text scales.
|
|
436
|
+
Without this conhost keeps the cell count and grows the window instead."""
|
|
437
|
+
import ctypes
|
|
438
|
+
from ctypes import wintypes
|
|
439
|
+
|
|
440
|
+
class _Coord(ctypes.Structure):
|
|
441
|
+
_fields_ = [("X", wintypes.SHORT), ("Y", wintypes.SHORT)]
|
|
442
|
+
|
|
443
|
+
class _SmallRect(ctypes.Structure):
|
|
444
|
+
_fields_ = [("Left", wintypes.SHORT), ("Top", wintypes.SHORT),
|
|
445
|
+
("Right", wintypes.SHORT), ("Bottom", wintypes.SHORT)]
|
|
446
|
+
|
|
447
|
+
class _BufferInfo(ctypes.Structure):
|
|
448
|
+
_fields_ = [("dwSize", _Coord), ("dwCursorPosition", _Coord), ("wAttributes", wintypes.WORD),
|
|
449
|
+
("srWindow", _SmallRect), ("dwMaximumWindowSize", _Coord)]
|
|
450
|
+
|
|
451
|
+
api = _console_font_api()
|
|
452
|
+
if not api:
|
|
453
|
+
return
|
|
454
|
+
k32, handle, info = api
|
|
455
|
+
k32.GetConsoleFontSize.restype = _Coord
|
|
456
|
+
cell = k32.GetConsoleFontSize(handle, info.nFont)
|
|
457
|
+
if cell.X <= 0 or cell.Y <= 0:
|
|
458
|
+
return
|
|
459
|
+
cols = max(40, client_px[0] // cell.X)
|
|
460
|
+
rows = max(10, client_px[1] // cell.Y)
|
|
461
|
+
buf = _BufferInfo()
|
|
462
|
+
if not k32.GetConsoleScreenBufferInfo(handle, ctypes.byref(buf)):
|
|
463
|
+
return
|
|
464
|
+
win = buf.srWindow
|
|
465
|
+
cur_cols, cur_rows = win.Right - win.Left + 1, win.Bottom - win.Top + 1
|
|
466
|
+
if (cols, rows) == (cur_cols, cur_rows):
|
|
467
|
+
return
|
|
468
|
+
height = max(buf.dwSize.Y, rows) # keep the scrollback
|
|
469
|
+
bottom = min(max(win.Bottom, rows - 1), height - 1)
|
|
470
|
+
target = _SmallRect(0, bottom - rows + 1, cols - 1, bottom)
|
|
471
|
+
if cols < cur_cols or rows < cur_rows:
|
|
472
|
+
# Shrink the window first: a buffer narrower than the window is refused.
|
|
473
|
+
shrink = _SmallRect(0, win.Bottom - min(rows, cur_rows) + 1,
|
|
474
|
+
min(cols, cur_cols) - 1, win.Bottom)
|
|
475
|
+
k32.SetConsoleWindowInfo(handle, True, ctypes.byref(shrink))
|
|
476
|
+
k32.SetConsoleScreenBufferSize(handle, _Coord(cols, height))
|
|
477
|
+
k32.SetConsoleWindowInfo(handle, True, ctypes.byref(target))
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def console_zoom(delta: int) -> int | None:
|
|
481
|
+
"""Ctrl+Plus / Ctrl+Minus: grow or shrink the console font by one step,
|
|
482
|
+
keep the window the same size on screen, and remember the size for the
|
|
483
|
+
next launch. Returns the new height, or None where the font cannot be
|
|
484
|
+
changed (not a classic console)."""
|
|
485
|
+
global _ZOOM_ANCHOR_PX
|
|
486
|
+
current = console_font_height()
|
|
487
|
+
if current is None:
|
|
488
|
+
return None
|
|
489
|
+
new = max(_FONT_MIN, min(_FONT_MAX, current + _FONT_STEP * (1 if delta > 0 else -1)))
|
|
490
|
+
if new == current:
|
|
491
|
+
return current
|
|
492
|
+
client_px = _console_client_px()
|
|
493
|
+
# Anchor on the window size the user had before the FIRST zoom, so
|
|
494
|
+
# repeated zooms return to exactly the same cell count instead of
|
|
495
|
+
# drifting a column per round trip from integer rounding. A window the
|
|
496
|
+
# user resized by hand (off by more than a cell) re-anchors.
|
|
497
|
+
if client_px:
|
|
498
|
+
if _ZOOM_ANCHOR_PX is None or any(abs(a - b) > 2 * current for a, b in zip(_ZOOM_ANCHOR_PX, client_px)):
|
|
499
|
+
_ZOOM_ANCHOR_PX = client_px
|
|
500
|
+
client_px = _ZOOM_ANCHOR_PX
|
|
501
|
+
if not set_console_font_height(new):
|
|
502
|
+
return current
|
|
503
|
+
if client_px:
|
|
504
|
+
_refit_console_cells(client_px)
|
|
505
|
+
try:
|
|
506
|
+
_FONT_STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
507
|
+
_FONT_STATE_PATH.write_text(str(new), encoding="utf-8")
|
|
508
|
+
except OSError:
|
|
509
|
+
pass
|
|
510
|
+
return new
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
_USER_BG = "\033[48;5;237m" # a shade above One Half Dark's background; subtle, not a box
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def user_row(row: str, width: int) -> str:
|
|
517
|
+
"""One physical row of the user's echoed message with a light background
|
|
518
|
+
band across `width` cells, the way Claude Code marks the user's turns
|
|
519
|
+
so the eye finds turn boundaries while scanning. Plain when colour is
|
|
520
|
+
off. Rows already wider than `width` (terminal auto-wrap) get the band
|
|
521
|
+
on their text only."""
|
|
522
|
+
if not _COLOR_ON:
|
|
523
|
+
return row
|
|
524
|
+
fill = max(0, width - _visible_cells(row))
|
|
525
|
+
# The prompt's own reset ("\033[1m>\033[0m") would end the band after the
|
|
526
|
+
# marker; re-arm the background after every reset inside the row.
|
|
527
|
+
body = row.replace(C.RESET, C.RESET + _USER_BG)
|
|
528
|
+
return f"{_USER_BG}{body}{' ' * fill}{C.RESET}"
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def user_echo(content: str, width: int) -> str:
|
|
532
|
+
"""The full echo for a stored user message: `> first line`, then
|
|
533
|
+
continuation rows prefixed like the editor's, each on a band."""
|
|
534
|
+
from hexcli.lineedit import _wrap_words_visible # lazy: lineedit imports ui
|
|
535
|
+
lines = content.split("\n") or [""]
|
|
536
|
+
logical = [f"{C.BOLD}>{C.RESET} {lines[0]}"]
|
|
537
|
+
logical += [f"... {line}" for line in lines[1:]]
|
|
538
|
+
# Broken at spaces, exactly as the editor leaves a finished line, so a
|
|
539
|
+
# redraw reproduces the echo row for row.
|
|
540
|
+
rows = [r for line in logical for r in _wrap_words_visible(line, width).split("\n")]
|
|
541
|
+
return "\n".join(user_row(r, width) for r in rows)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def clear_screen(scrollback: bool = True) -> None:
|
|
545
|
+
"""Wipe the window (and, by default, the terminal's scrollback) and put
|
|
546
|
+
the cursor at the top-left."""
|
|
547
|
+
sys.stdout.write("\033[2J" + ("\033[3J" if scrollback else "") + "\033[H\r")
|
|
548
|
+
sys.stdout.flush()
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def redraw_transcript(session: dict[str, Any], clear: bool = True,
|
|
552
|
+
pending: str | None = None) -> None:
|
|
553
|
+
"""Reprint the conversation at the current width, after clearing the
|
|
554
|
+
screen unless the caller already laid out something above it.
|
|
555
|
+
|
|
556
|
+
Used after a zoom or a resize: the column count changed, and the
|
|
557
|
+
terminal's own reflow of what was already on screen starts
|
|
558
|
+
continuation rows at column 0, losing the margin. Reprinting through
|
|
559
|
+
the margin layer lays every row out fresh. Tool banners and spinner
|
|
560
|
+
lines are not part of the session and do not come back; the questions
|
|
561
|
+
and answers do. `pending` is the question of a turn still running,
|
|
562
|
+
which joins the session only when the turn ends; it is echoed last.
|
|
563
|
+
|
|
564
|
+
Spacing matches the live flow: an answer ends on a blank row, so only
|
|
565
|
+
the first echo needs one above it.
|
|
566
|
+
"""
|
|
567
|
+
if clear:
|
|
568
|
+
clear_screen()
|
|
569
|
+
first = True
|
|
570
|
+
for msg in session.get("messages", []):
|
|
571
|
+
role, content = msg.get("role"), str(msg.get("content", ""))
|
|
572
|
+
if role == "user":
|
|
573
|
+
if first:
|
|
574
|
+
print()
|
|
575
|
+
print(user_echo(content, _usable_width()))
|
|
576
|
+
first = False
|
|
577
|
+
elif role == "assistant":
|
|
578
|
+
render_result("Result", content)
|
|
579
|
+
first = False
|
|
580
|
+
if pending is not None:
|
|
581
|
+
if first:
|
|
582
|
+
print()
|
|
583
|
+
print(user_echo(pending, _usable_width()))
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def apply_saved_console_font() -> None:
|
|
587
|
+
"""Restore the size chosen with Ctrl+Plus / Ctrl+Minus last time."""
|
|
588
|
+
try:
|
|
589
|
+
height = int(_FONT_STATE_PATH.read_text(encoding="utf-8").strip())
|
|
590
|
+
except (OSError, ValueError):
|
|
591
|
+
return
|
|
592
|
+
if _FONT_MIN <= height <= _FONT_MAX and height != console_font_height():
|
|
593
|
+
set_console_font_height(height)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
# ---------------------------------------------------------------------------
|
|
597
|
+
# Tool / error / command event rendering
|
|
598
|
+
# ---------------------------------------------------------------------------
|
|
599
|
+
|
|
600
|
+
def tool_event(tag: str, detail: str) -> None:
|
|
601
|
+
cprint(f"{C.GRAY}▸{C.RESET} {C.DIM}[{tag}] {detail}{C.RESET}")
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def tool_header(tool_name: str) -> None:
|
|
605
|
+
"""One blank line, then the card. Everything the tool prints attaches
|
|
606
|
+
below it with no further blank lines."""
|
|
607
|
+
cprint(f"\n{C.BCYAN}◆{C.RESET} {C.BOLD}{tool_name}{C.RESET}")
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def command_echo(command: str) -> None:
|
|
611
|
+
cprint(f"{C.DIM}${C.RESET} {C.BOLD}{command}{C.RESET}")
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def tool_error(message: str) -> None:
|
|
615
|
+
"""A failed tool call, on one dim red line under its card. The model
|
|
616
|
+
still receives the full error text; the transcript gets the first line."""
|
|
617
|
+
first = str(message).strip().splitlines()[0] if str(message).strip() else "failed"
|
|
618
|
+
# A first line that introduces detail on later lines ("...(similarity
|
|
619
|
+
# 48%):") would end on a bare colon here; the detail is for the model.
|
|
620
|
+
first = first.rstrip().rstrip(":").rstrip()
|
|
621
|
+
cprint(f"{C.RED}▸ error{C.RESET} {first}", file=sys.stderr)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _usable_width() -> int:
|
|
625
|
+
try:
|
|
626
|
+
width = os.get_terminal_size().columns
|
|
627
|
+
except OSError:
|
|
628
|
+
width = 80
|
|
629
|
+
pad = getattr(sys.stdout, "pad", 0) or 0
|
|
630
|
+
return max(20, width - 2 * int(pad))
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def error_box(message: str, *, file: Any = None) -> None:
|
|
634
|
+
lines = str(message).strip().splitlines() or [""]
|
|
635
|
+
width = min(max(len(ln) for ln in lines) + 4, _usable_width() - 2)
|
|
636
|
+
out = file or sys.stderr
|
|
637
|
+
cprint("┌" + "─" * width, C.RED, file=out)
|
|
638
|
+
for ln in lines:
|
|
639
|
+
cprint(f"│ {ln}", C.RED, file=out)
|
|
640
|
+
cprint("└" + "─" * width, C.RED, file=out)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def print_banner(model: str, backend: str, engine: str | None = None) -> None:
|
|
644
|
+
"""The one banner. `engine` names the hardware ("Hexagon NPU"); without
|
|
645
|
+
it the line falls back to naming the server, never the transport."""
|
|
646
|
+
title = "HEX CLI"
|
|
647
|
+
try:
|
|
648
|
+
cols = os.get_terminal_size().columns
|
|
649
|
+
except OSError:
|
|
650
|
+
cols = 80
|
|
651
|
+
# Fit the box to the window so a narrow terminal does not wrap the rules
|
|
652
|
+
# into broken fragments. Below the frame's minimum, drop it for a plain
|
|
653
|
+
# heading.
|
|
654
|
+
inner = max(len(title) + 4, 44)
|
|
655
|
+
avail = cols - 6 # side padding + the two border cells, with slack
|
|
656
|
+
print()
|
|
657
|
+
if avail >= inner:
|
|
658
|
+
cprint("┌" + "─" * inner + "┐", C.BCYAN)
|
|
659
|
+
cprint("│" + title.center(inner) + "│", C.BOLD + C.BCYAN)
|
|
660
|
+
cprint("└" + "─" * inner + "┘", C.BCYAN)
|
|
661
|
+
elif avail >= len(title) + 2:
|
|
662
|
+
w = max(len(title) + 2, avail)
|
|
663
|
+
cprint("┌" + "─" * w + "┐", C.BCYAN)
|
|
664
|
+
cprint("│" + title.center(w) + "│", C.BOLD + C.BCYAN)
|
|
665
|
+
cprint("└" + "─" * w + "┘", C.BCYAN)
|
|
666
|
+
else:
|
|
667
|
+
cprint(title, C.BOLD + C.BCYAN)
|
|
668
|
+
where = f"on the {engine}" if engine else f"via {backend}"
|
|
669
|
+
usable = _usable_width()
|
|
670
|
+
for tail in (f"{model} {where} · /help · press Esc to cancel",
|
|
671
|
+
f"{model} {where} · /help",
|
|
672
|
+
f"{model} · /help"):
|
|
673
|
+
if len(tail) + 2 <= usable:
|
|
674
|
+
break
|
|
675
|
+
styled = tail.replace(model, f"{C.BWHITE}{model}{C.RESET}{C.DIM}", 1)
|
|
676
|
+
cprint(f" {styled}", C.DIM)
|
|
677
|
+
print()
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
# ---------------------------------------------------------------------------
|
|
681
|
+
# Help text
|
|
682
|
+
# ---------------------------------------------------------------------------
|
|
683
|
+
|
|
684
|
+
HELP_TEXT = textwrap.dedent("""
|
|
685
|
+
Hex CLI, a local agent on the Hexagon NPU
|
|
686
|
+
|
|
687
|
+
Session
|
|
688
|
+
/new start a new session, keep the screen
|
|
689
|
+
/clear clear the screen and start a new session
|
|
690
|
+
/history list saved sessions
|
|
691
|
+
/resume <n> reopen session n
|
|
692
|
+
/search <text> find sessions by content
|
|
693
|
+
/compact compress the chat history
|
|
694
|
+
/undo revert the last exchange, including files it wrote
|
|
695
|
+
/diff show what the agent changed this turn
|
|
696
|
+
|
|
697
|
+
Status
|
|
698
|
+
/context how full the context is and when it compacts
|
|
699
|
+
/stats turns, time and tokens for this session
|
|
700
|
+
/doctor check the install
|
|
701
|
+
/tools list the agent's tools
|
|
702
|
+
|
|
703
|
+
Setup
|
|
704
|
+
/config [key [value]] view or set a config value for this session
|
|
705
|
+
/setup config wizard, writes the config file
|
|
706
|
+
/memory [status|list|search|clear|prune] the memory store
|
|
707
|
+
/cwd [path] show or change the working directory
|
|
708
|
+
/exit quit
|
|
709
|
+
|
|
710
|
+
Custom commands: a .md file in .shellai/commands/ or ~/.shellai/commands/
|
|
711
|
+
becomes /<filename> and its text is sent as the prompt. $ARGUMENTS is
|
|
712
|
+
replaced with what follows the command. Built-in names win.
|
|
713
|
+
|
|
714
|
+
Keys
|
|
715
|
+
Up / Down history, filtered by what is typed
|
|
716
|
+
Tab complete commands, config keys and paths
|
|
717
|
+
Shift+Enter new line; \\ then Enter also works
|
|
718
|
+
Ctrl+Left / Ctrl+Right move by word
|
|
719
|
+
Ctrl+W / Ctrl+U / Ctrl+K delete the word, to line start, to line end
|
|
720
|
+
Esc clear the line, or cancel a running turn
|
|
721
|
+
Ctrl+L clear the screen
|
|
722
|
+
Ctrl+Plus / Ctrl+Minus text size in the classic console
|
|
723
|
+
|
|
724
|
+
The agent runs qwen3-4b-instruct-2507 on the Hexagon NPU through npurun.
|
|
725
|
+
""").strip()
|
|
726
|
+
|
|
727
|
+
TOOLS_HELP = textwrap.dedent("""
|
|
728
|
+
Tools available to the agent:
|
|
729
|
+
run_command(command) run PowerShell; risky ones ask first
|
|
730
|
+
read_file(path, offset, limit) read a file, paged by offset and limit
|
|
731
|
+
edit_file(path, old_string, new_string) replace text; undoable
|
|
732
|
+
write_file(path, content) write a file; undoable
|
|
733
|
+
append_file(path, content) append to a file
|
|
734
|
+
list_directory(path) list files and folders
|
|
735
|
+
search_files(pattern, path, glob) search file contents
|
|
736
|
+
find_files(glob, path) find files by glob
|
|
737
|
+
verify_syntax(path, language) syntax check for .py .json .ps1 .js
|
|
738
|
+
run_code(path, args, timeout) run a script in a sandbox
|
|
739
|
+
lint_code(path) run ruff; needs ruff on PATH
|
|
740
|
+
search_memory(query, top_k) recall prior sessions
|
|
741
|
+
fetch_url(url, max_chars) fetch a URL as readable text
|
|
742
|
+
batch(actions) up to 8 read-only tools at once
|
|
743
|
+
delegate(task) a sub-agent of up to 5 steps
|
|
744
|
+
|
|
745
|
+
Writes stay in the working directory; see workspace_write_scope.
|
|
746
|
+
""").strip()
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
# ---------------------------------------------------------------------------
|
|
750
|
+
# History list
|
|
751
|
+
# ---------------------------------------------------------------------------
|
|
752
|
+
|
|
753
|
+
def _utc_now() -> datetime:
|
|
754
|
+
return datetime.now(UTC)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def format_relative_time(timestamp: str) -> str:
|
|
758
|
+
try:
|
|
759
|
+
moment = datetime.fromisoformat(timestamp)
|
|
760
|
+
except ValueError:
|
|
761
|
+
return "unknown"
|
|
762
|
+
if moment.tzinfo is None:
|
|
763
|
+
moment = moment.replace(tzinfo=UTC)
|
|
764
|
+
seconds = max(0, int((_utc_now() - moment).total_seconds()))
|
|
765
|
+
if seconds < 60:
|
|
766
|
+
return "now"
|
|
767
|
+
if seconds < 3600:
|
|
768
|
+
return f"{seconds // 60}m ago"
|
|
769
|
+
if seconds < 86400:
|
|
770
|
+
return f"{seconds // 3600}h ago"
|
|
771
|
+
return f"{seconds // 86400}d ago"
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def truncate_summary(text: str, width: int) -> str:
|
|
775
|
+
return text if len(text) <= width else text[: width - 3] + "..."
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def render_history_list(sessions: list[dict[str, Any]], current_id: str) -> None:
|
|
779
|
+
if not sessions:
|
|
780
|
+
cprint(" No saved sessions.", C.DIM)
|
|
781
|
+
return
|
|
782
|
+
print()
|
|
783
|
+
# The summary column takes whatever the window leaves after the fixed
|
|
784
|
+
# columns (marker, number, two dates), so rows never wrap.
|
|
785
|
+
width = max(16, _usable_width() - 30)
|
|
786
|
+
header = f" {'#':<4}{'Session':<{width + 4}}{'Modified':<12}{'Created'}"
|
|
787
|
+
cprint(header, C.BOLD)
|
|
788
|
+
cprint(" " + "─" * (len(header) - 2), C.DIM)
|
|
789
|
+
for i, s in enumerate(sessions, start=1):
|
|
790
|
+
current = s.get("id") == current_id
|
|
791
|
+
marker = "▸" if current else " "
|
|
792
|
+
summary = truncate_summary(str(s.get("title", "New session")), width)
|
|
793
|
+
modified = format_relative_time(str(s.get("modified_at", "")))
|
|
794
|
+
created = format_relative_time(str(s.get("created_at", "")))
|
|
795
|
+
cprint(f"{marker} {i:>2}. {summary:<{width}} {modified:<10} {created}", C.BOLD if current else "")
|
|
796
|
+
print()
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def render_search_results(term: str, hits: list[dict[str, Any]]) -> None:
|
|
800
|
+
"""Render /search hits: same numbering as /history, matches highlighted."""
|
|
801
|
+
if not hits:
|
|
802
|
+
cprint(f' No sessions match "{term}".', C.DIM)
|
|
803
|
+
return
|
|
804
|
+
print()
|
|
805
|
+
cprint(f' Sessions matching "{term}"', C.BOLD)
|
|
806
|
+
for h in hits:
|
|
807
|
+
s = h["session"]
|
|
808
|
+
summary = truncate_summary(str(s.get("title", "New session")), 48)
|
|
809
|
+
modified = format_relative_time(str(s.get("modified_at", "")))
|
|
810
|
+
print()
|
|
811
|
+
cprint(f" {h['index']:>2}. {summary} {C.DIM}{modified}{C.RESET}")
|
|
812
|
+
for role, prefix, match, suffix in h["snippets"]:
|
|
813
|
+
print(f" {C.DIM}{role}{C.RESET} {prefix}"
|
|
814
|
+
f"{C.BOLD}{C.BYELLOW}{match}{C.RESET}{suffix}")
|
|
815
|
+
print()
|
|
816
|
+
cprint(" /resume <n> reopens one.", C.DIM)
|
|
817
|
+
print()
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
# ---------------------------------------------------------------------------
|
|
821
|
+
# Models list
|
|
822
|
+
# ---------------------------------------------------------------------------
|
|
823
|
+
|
|
824
|
+
# ---------------------------------------------------------------------------
|
|
825
|
+
# Context estimate
|
|
826
|
+
# ---------------------------------------------------------------------------
|
|
827
|
+
|
|
828
|
+
def show_context(
|
|
829
|
+
session: dict[str, Any],
|
|
830
|
+
config: dict[str, Any],
|
|
831
|
+
budget: tuple[int, int] | None = None,
|
|
832
|
+
system_prompt_tokens: int | None = None,
|
|
833
|
+
) -> None:
|
|
834
|
+
"""Show context usage against the REAL per-turn budget.
|
|
835
|
+
|
|
836
|
+
Pre-v1.8 this printed hardcoded 1,300/1,600 thresholds that were calibrated
|
|
837
|
+
to a system prompt half the actual size, so it told users they had headroom
|
|
838
|
+
they did not have. Thresholds now come from the caller's measured budget.
|
|
839
|
+
"""
|
|
840
|
+
messages: list[dict[str, str]] = session.get("messages", [])
|
|
841
|
+
total_chars = sum(len(m.get("content", "")) for m in messages)
|
|
842
|
+
est_tokens = total_chars // 4
|
|
843
|
+
compact_count = session.get("compact_count", 0)
|
|
844
|
+
warn, crit = budget if budget else (1_300, 1_600)
|
|
845
|
+
print()
|
|
846
|
+
cprint("Context estimate", C.BOLD)
|
|
847
|
+
print(f" Messages: {len(messages)}")
|
|
848
|
+
print(f" Chars (total): {total_chars:,}")
|
|
849
|
+
print(f" History (est.): ~{est_tokens:,} tokens (budget {warn:,}, "
|
|
850
|
+
f"{context_gauge(min(100, round(100 * est_tokens / max(warn, 1))))})")
|
|
851
|
+
if system_prompt_tokens:
|
|
852
|
+
print(f" System prompt: ~{system_prompt_tokens:,} tokens")
|
|
853
|
+
print(f" Turn total: ~{est_tokens + system_prompt_tokens:,} tokens")
|
|
854
|
+
print(f" Compact runs: {compact_count}")
|
|
855
|
+
print(f" Max agent steps: {config.get('max_agent_steps', 15)}")
|
|
856
|
+
print(f" Model: {config.get('model', 'unknown')}")
|
|
857
|
+
if est_tokens >= warn:
|
|
858
|
+
cprint(" ⚠ Auto-compact runs after the next turn.", C.BYELLOW)
|
|
859
|
+
print()
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def show_context_brief(
|
|
863
|
+
session: dict[str, Any],
|
|
864
|
+
config: dict[str, Any],
|
|
865
|
+
budget: tuple[int, int],
|
|
866
|
+
system_prompt_tokens: int,
|
|
867
|
+
history_tokens: int,
|
|
868
|
+
) -> None:
|
|
869
|
+
"""/context — just the numbers that decide what happens next."""
|
|
870
|
+
messages: list[dict[str, str]] = session.get("messages", [])
|
|
871
|
+
warn, _ = budget
|
|
872
|
+
pct = max(0, min(100, round(100 * history_tokens / max(warn, 1))))
|
|
873
|
+
window = int(config.get("context_window_tokens") or 0)
|
|
874
|
+
compact_count = int(session.get("compact_count", 0))
|
|
875
|
+
if history_tokens >= warn:
|
|
876
|
+
nxt = "auto-compact runs after the next turn"
|
|
877
|
+
else:
|
|
878
|
+
nxt = f"auto-compact after about {warn - history_tokens:,} more tokens"
|
|
879
|
+
print()
|
|
880
|
+
cprint(f" Context {context_gauge(pct)}", C.BOLD)
|
|
881
|
+
print(f" history {history_tokens:,} / {warn:,} tokens, {len(messages)} messages")
|
|
882
|
+
print(f" system prompt {system_prompt_tokens:,} tokens")
|
|
883
|
+
if window:
|
|
884
|
+
print(f" server budget {window:,} tokens per call")
|
|
885
|
+
if compact_count:
|
|
886
|
+
print(f" compactions {compact_count}")
|
|
887
|
+
print(f" next {nxt}")
|
|
888
|
+
print()
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
# ---------------------------------------------------------------------------
|
|
892
|
+
# REPL prompt
|
|
893
|
+
# ---------------------------------------------------------------------------
|
|
894
|
+
|
|
895
|
+
def get_git_branch() -> str | None:
|
|
896
|
+
try:
|
|
897
|
+
out = subprocess.check_output(
|
|
898
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
899
|
+
stderr=subprocess.DEVNULL,
|
|
900
|
+
timeout=2,
|
|
901
|
+
)
|
|
902
|
+
branch = out.decode().strip()
|
|
903
|
+
return branch if branch and branch != "HEAD" else None
|
|
904
|
+
except Exception:
|
|
905
|
+
return None
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def short_cwd() -> str:
|
|
909
|
+
cwd = Path.cwd()
|
|
910
|
+
home = Path.home()
|
|
911
|
+
try:
|
|
912
|
+
rel = cwd.relative_to(home)
|
|
913
|
+
return "~\\" + str(rel) if str(rel) != "." else "~"
|
|
914
|
+
except ValueError:
|
|
915
|
+
return str(cwd)
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
_GAUGE_GLYPHS = "○◔◑◕●"
|
|
919
|
+
# The quarter-pie glyphs are in Cascadia (Windows Terminal) but not in
|
|
920
|
+
# Consolas, and classic conhost does not fall back — the Start Menu
|
|
921
|
+
# shortcut runs there. Percentage only in that case, not boxes.
|
|
922
|
+
_PIE_OK = os.name != "nt" or bool(os.environ.get("WT_SESSION"))
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def context_gauge(percent: int, pie: bool | None = None) -> str:
|
|
926
|
+
"""A five-step pie glyph plus the number: ○ 0% ◔ 25% ◑ 50% ◕ 75% ● 100%
|
|
927
|
+
(just the number where the console font cannot draw the pie)."""
|
|
928
|
+
pct = max(0, min(100, int(percent)))
|
|
929
|
+
if not (_PIE_OK if pie is None else pie):
|
|
930
|
+
return f"{pct}%"
|
|
931
|
+
glyph = _GAUGE_GLYPHS[min(4, (pct + 12) // 25)]
|
|
932
|
+
return f"{glyph} {pct}%"
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def repl_prompt(config: dict[str, Any], context_percent: int | None = None,
|
|
936
|
+
boxed: bool = False) -> str:
|
|
937
|
+
"""The input prompt. `boxed` is the status-bar layout: the bar carries
|
|
938
|
+
the location and the gauge, so the prompt is just the marker. Without
|
|
939
|
+
it the header line keeps the model, location and gauge as before."""
|
|
940
|
+
if boxed:
|
|
941
|
+
return f"{C.BOLD}>{C.RESET} " if _COLOR_ON else "> "
|
|
942
|
+
model = str(config.get("model", "?"))
|
|
943
|
+
cwd_str = short_cwd()
|
|
944
|
+
branch = get_git_branch()
|
|
945
|
+
branch_str = f" ({branch})" if branch else ""
|
|
946
|
+
gauge = context_gauge(context_percent) if context_percent is not None else ""
|
|
947
|
+
if _COLOR_ON:
|
|
948
|
+
if gauge:
|
|
949
|
+
tone = C.BRED if context_percent >= 100 else C.BYELLOW if context_percent >= 75 else C.DIM
|
|
950
|
+
gauge = f"{C.DIM} | {tone}{gauge}"
|
|
951
|
+
return (
|
|
952
|
+
f"{C.DIM}[{C.BCYAN}{model}{C.DIM} | "
|
|
953
|
+
f"{C.BYELLOW}{cwd_str}{branch_str}{gauge}{C.DIM}]{C.RESET}\n"
|
|
954
|
+
f"{C.BOLD}>{C.RESET} "
|
|
955
|
+
)
|
|
956
|
+
if gauge:
|
|
957
|
+
gauge = f" | {gauge}"
|
|
958
|
+
return f"[{model} | {cwd_str}{branch_str}{gauge}]\n> "
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
# ---------------------------------------------------------------------------
|
|
962
|
+
# Consent prompts
|
|
963
|
+
# ---------------------------------------------------------------------------
|
|
964
|
+
|
|
965
|
+
CONFIRM_TIMEOUT_S: float = 120.0
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def confirm_or_deny(prompt: str, timeout_s: float | None = None) -> bool:
|
|
969
|
+
"""Ask for y/N consent without ever blocking forever; anything but an explicit
|
|
970
|
+
yes is a deny.
|
|
971
|
+
|
|
972
|
+
Three ways there is no human to answer, all of which must fail closed:
|
|
973
|
+
* stdin is a pipe / redirected file -> isatty() is False, deny at once;
|
|
974
|
+
* stdin is at EOF -> the read yields nothing, deny;
|
|
975
|
+
* stdin is a **hidden or detached console** -> isatty() is True and a normal
|
|
976
|
+
read never returns. Not hypothetical: this shape hung an unattended eval
|
|
977
|
+
for 7.5 hours on one prompt.
|
|
978
|
+
|
|
979
|
+
That third case rules out both obvious implementations. ``input()`` blocks
|
|
980
|
+
forever, and running it on a daemon thread does NOT help, because the Windows
|
|
981
|
+
console read holds the GIL - the main thread never runs, so ``join(timeout)``
|
|
982
|
+
is itself blocked (measured: a 3 s join took 60 s). So poll ``msvcrt`` for a
|
|
983
|
+
keypress instead, the same way ``lineedit`` reads keys, and give up on time.
|
|
984
|
+
|
|
985
|
+
The timeout is an **idle** timeout, reset by every keypress. A fixed deadline
|
|
986
|
+
would also fire on an attended human who is mid-answer or reading the command
|
|
987
|
+
carefully, throwing away characters they had already typed; only *silence*
|
|
988
|
+
indicates the dead-console case this exists for.
|
|
989
|
+
|
|
990
|
+
**Ctrl-C denies, it does not raise.** These prompts guard destructive and
|
|
991
|
+
sensitive commands, where Ctrl-C is the most natural way to say "no". Raising
|
|
992
|
+
would abort the whole turn, skipping the audit-log entry that records the
|
|
993
|
+
refusal and discarding the turn's undo snapshots.
|
|
994
|
+
"""
|
|
995
|
+
if timeout_s is None: # resolved per call so the constant stays patchable
|
|
996
|
+
timeout_s = CONFIRM_TIMEOUT_S
|
|
997
|
+
if not sys.stdin.isatty():
|
|
998
|
+
return False
|
|
999
|
+
# Take the status box down while we own the console for the y/N read, so
|
|
1000
|
+
# the question is not printed on top of a still-live input box (nested
|
|
1001
|
+
# inside a confirm_* wrapper this is already down, so it is a no-op).
|
|
1002
|
+
with paused_status():
|
|
1003
|
+
allowed = _confirm_or_deny_read(prompt, timeout_s)
|
|
1004
|
+
cprint(" Allowed." if allowed else " Denied.", C.DIM)
|
|
1005
|
+
return allowed
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def ask_line(prompt: str, timeout_s: float | None = None) -> str | None:
|
|
1009
|
+
"""Read one line at an inline prompt the way the confirms do: keys are
|
|
1010
|
+
polled and echoed through our own streams, so the status box can be
|
|
1011
|
+
lowered around it and the margin's column stays right (``input()`` echoes
|
|
1012
|
+
through the console itself, which left the box jumbled after Enter).
|
|
1013
|
+
Returns the text, or None when no human answered: not a console, Ctrl-C,
|
|
1014
|
+
or the idle timeout. Callers choose their own default for None."""
|
|
1015
|
+
if timeout_s is None:
|
|
1016
|
+
timeout_s = CONFIRM_TIMEOUT_S
|
|
1017
|
+
if not sys.stdin.isatty():
|
|
1018
|
+
return None
|
|
1019
|
+
with paused_status():
|
|
1020
|
+
# No note of its own: the caller says what a None answer meant.
|
|
1021
|
+
return _console_read_line(prompt, timeout_s, cancel_note="", timeout_suffix=".")
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def _confirm_or_deny_read(prompt: str, timeout_s: float) -> bool:
|
|
1025
|
+
answer = _console_read_line(prompt, timeout_s, cancel_note="", timeout_suffix=".")
|
|
1026
|
+
return answer is not None and answer.strip().lower() in {"y", "yes"}
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def _console_read_line(prompt: str, timeout_s: float, cancel_note: str,
|
|
1030
|
+
timeout_suffix: str) -> str | None:
|
|
1031
|
+
sys.stdout.write(prompt)
|
|
1032
|
+
sys.stdout.flush()
|
|
1033
|
+
deadline = time.monotonic() + timeout_s
|
|
1034
|
+
buf = ""
|
|
1035
|
+
while time.monotonic() < deadline:
|
|
1036
|
+
if not msvcrt.kbhit():
|
|
1037
|
+
time.sleep(0.05)
|
|
1038
|
+
continue
|
|
1039
|
+
ch = msvcrt.getwch()
|
|
1040
|
+
deadline = time.monotonic() + timeout_s # a human is here; start the clock over
|
|
1041
|
+
if ch in ("\r", "\n"):
|
|
1042
|
+
print()
|
|
1043
|
+
return buf
|
|
1044
|
+
if ch == "\x03": # Ctrl-C — an emphatic no, not a crash
|
|
1045
|
+
print()
|
|
1046
|
+
if cancel_note:
|
|
1047
|
+
cprint(cancel_note, C.DIM)
|
|
1048
|
+
return None
|
|
1049
|
+
if ch in ("\b", "\x7f"):
|
|
1050
|
+
if buf:
|
|
1051
|
+
buf = buf[:-1]
|
|
1052
|
+
# Erase the echo too, or the screen shows an answer that is not
|
|
1053
|
+
# the one being evaluated.
|
|
1054
|
+
sys.stdout.write("\b \b")
|
|
1055
|
+
sys.stdout.flush()
|
|
1056
|
+
continue
|
|
1057
|
+
if ch == "\x00" or ch == "\xe0": # function/arrow key: consume the scan code
|
|
1058
|
+
msvcrt.getwch()
|
|
1059
|
+
continue
|
|
1060
|
+
buf += ch
|
|
1061
|
+
sys.stdout.write(ch)
|
|
1062
|
+
sys.stdout.flush()
|
|
1063
|
+
print()
|
|
1064
|
+
cprint(f" No answer after {timeout_s:.0f} s{timeout_suffix}", C.DIM)
|
|
1065
|
+
return None
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
def confirm_network_fetch(url: str) -> bool:
|
|
1069
|
+
"""Outbound network access is the exception in an offline-first product;
|
|
1070
|
+
require explicit consent per fetch. Denied when non-interactive."""
|
|
1071
|
+
with paused_status():
|
|
1072
|
+
print()
|
|
1073
|
+
cprint("⚠ The agent wants to fetch a URL:", C.BYELLOW, bold=True)
|
|
1074
|
+
cprint(f" {url}", C.CYAN)
|
|
1075
|
+
return confirm_or_deny(" Allow? [y/N] ")
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def confirm_sensitive_command(cmd: str) -> bool:
|
|
1079
|
+
"""Sensitive-data access (keys, credentials, security files, obfuscated
|
|
1080
|
+
execution) requires explicit consent; denied when non-interactive."""
|
|
1081
|
+
with paused_status():
|
|
1082
|
+
print()
|
|
1083
|
+
cprint("⚠ The agent wants to read sensitive data or run an obfuscated command:", C.BYELLOW, bold=True)
|
|
1084
|
+
cprint(f" {cmd}", C.RED)
|
|
1085
|
+
cprint(" Deny unless you asked for exactly this.", C.DIM)
|
|
1086
|
+
return confirm_or_deny(" Allow? [y/N] ")
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def confirm_destructive_command(cmd: str) -> bool:
|
|
1090
|
+
"""Print a destructive-command warning and return True only if the user types
|
|
1091
|
+
y/yes; denied when non-interactive or unanswered."""
|
|
1092
|
+
with paused_status():
|
|
1093
|
+
print()
|
|
1094
|
+
cprint("⚠ The agent wants to run a destructive command:", C.BYELLOW, bold=True)
|
|
1095
|
+
cprint(f" {cmd}", C.RED)
|
|
1096
|
+
return confirm_or_deny(" Allow? [y/N] ")
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def render_result(title: str, body: str) -> None:
|
|
1100
|
+
"""An answer that did not stream (or streamed differently). Printed the
|
|
1101
|
+
way a streamed answer lands: a blank line, the text, a blank line. The
|
|
1102
|
+
`title` is kept for callers but not shown; streamed answers carry none."""
|
|
1103
|
+
from hexcli.markdown_stream import render_markdown # lazy: it imports C from here
|
|
1104
|
+
print()
|
|
1105
|
+
print(render_markdown(body))
|
|
1106
|
+
print()
|