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/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()