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/statusbar.py ADDED
@@ -0,0 +1,894 @@
1
+ #!/usr/bin/env python3
2
+ """hexcli.statusbar — the bottom section: an input box above a status line.
3
+
4
+ The REPL used to print its prompt inline, so the place you type moved with
5
+ the transcript and the context gauge sat in the prompt header. This module
6
+ gives the terminal the Claude Code shape instead: the transcript scrolls
7
+ above, and the last rows of the screen are always
8
+
9
+ ────────────────────────────────────────────────────────────
10
+ > the line being typed
11
+ ────────────────────────────────────────────────────────────
12
+ ⠹ thinking context ◔ 32% npu 96% mem 10.1 GB ~\\proj (main)
13
+
14
+ How it stays at the bottom
15
+ --------------------------
16
+ No scroll region and no full-screen redraw: the transcript keeps going into
17
+ the terminal's scrollback exactly as before. Two states share the drawing:
18
+
19
+ * While the line editor is active it renders the box itself (``chrome`` in
20
+ ``lineedit.LineEditor``): the rule above, the rule and status below, the
21
+ cursor on the input row. An idle tick from the key reader re-renders when
22
+ the status text changes, so the numbers move while nothing is typed.
23
+ * Between reads (a turn is running, a command is printing) ``LiveArea``
24
+ owns the box. It wraps ``sys.stdout``/``sys.stderr``: every write first
25
+ erases the box (the cursor is parked at the transcript position, so one
26
+ clear-to-end-of-screen does it), writes the text, then redraws the box
27
+ below the new cursor and walks back up. The spinner's frames and the
28
+ metrics refresh through the same repaint. All of it is serialised on one
29
+ lock, since the spinner ticks from its own thread.
30
+
31
+ The wrappers sit outside ``ui._MarginStream`` and borrow its column
32
+ bookkeeping to put the cursor back where the transcript left off.
33
+
34
+ Metrics
35
+ -------
36
+ ``npu`` is Windows' own NPU load: the ``GPU Engine`` performance counters
37
+ for the adapter that DirectX does not list (the NPU is an MCDM compute
38
+ device; Task Manager's NPU graph reads the same counters). Read through
39
+ ``pdh.dll`` with ctypes, no dependency. Measured on the Hexagon: 0 % idle,
40
+ 90–97 % during decode, attributed to npurun's pid. ``mem`` is physical
41
+ memory in use system-wide (``GlobalMemoryStatusEx``). Both are sampled once
42
+ a second on a daemon thread that never touches the terminal.
43
+ """
44
+ from __future__ import annotations
45
+
46
+ import os
47
+ import re
48
+ import sys
49
+ import threading
50
+ import time
51
+ from collections.abc import Callable
52
+ from typing import Any
53
+
54
+ from hexcli import ui
55
+ from hexcli.ui import C
56
+
57
+ _ANSI_RE = re.compile(r"\033\[[0-9;?]*[A-Za-z]")
58
+ _SAMPLE_INTERVAL_S = 1.0
59
+ _REDISCOVER_S = 30.0
60
+ _LUID_RE = re.compile(r"luid_(0x[0-9A-Fa-f]+_0x[0-9A-Fa-f]+).*engtype_(.+)$")
61
+
62
+ # ── text helpers ─────────────────────────────────────────────────────────────
63
+
64
+
65
+ def visible_len(text: str) -> int:
66
+ """Terminal cells, not characters: CJK and other wide glyphs take two."""
67
+ return ui._visible_cells(text)
68
+
69
+
70
+ def clip_visible(text: str, width: int) -> str:
71
+ """Cut `text` to `width` visible cells, keeping ANSI styling intact and
72
+ closing it so nothing leaks onto the next row."""
73
+ if width <= 0:
74
+ return ""
75
+ out: list[str] = []
76
+ seen = 0
77
+ i = 0
78
+ while i < len(text):
79
+ m = _ANSI_RE.match(text, i)
80
+ if m:
81
+ out.append(m.group())
82
+ i = m.end()
83
+ continue
84
+ w = ui._cell_width(text[i])
85
+ if seen + w > width:
86
+ seen = width
87
+ break
88
+ out.append(text[i])
89
+ seen += w
90
+ i += 1
91
+ clipped = "".join(out)
92
+ if seen >= width and i < len(text) and C.RESET:
93
+ clipped += C.RESET
94
+ return clipped
95
+
96
+
97
+ # ── metrics ──────────────────────────────────────────────────────────────────
98
+
99
+
100
+ def pick_npu_luid(engines: dict[str, set[str]], known: set[str]) -> str | None:
101
+ """The NPU's adapter LUID, from the GPU Engine instance set.
102
+
103
+ DirectX registers the GPU and the Basic Render Driver under
104
+ ``HKLM\\SOFTWARE\\Microsoft\\DirectX`` with their LUIDs; the NPU is not
105
+ there. Of the LUIDs left over, prefer the one that exposes only a
106
+ Compute engine, which is what an MCDM NPU looks like.
107
+ """
108
+ known_l = {k.lower() for k in known}
109
+ rest = [luid for luid in engines if luid.lower() not in known_l]
110
+ if not rest:
111
+ return None
112
+ compute_only = [luid for luid in rest if engines[luid] == {"Compute"}]
113
+ return (compute_only or rest)[0]
114
+
115
+
116
+ def _directx_luids() -> set[str]:
117
+ out: set[str] = set()
118
+ try:
119
+ import winreg
120
+ except ImportError:
121
+ return out
122
+ try:
123
+ with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\DirectX") as key:
124
+ i = 0
125
+ while True:
126
+ try:
127
+ name = winreg.EnumKey(key, i)
128
+ except OSError:
129
+ break
130
+ i += 1
131
+ try:
132
+ with winreg.OpenKey(key, name) as sub:
133
+ value, _ = winreg.QueryValueEx(sub, "AdapterLuid")
134
+ out.add(f"0x{int(value) >> 32:08X}_0x{int(value) & 0xFFFFFFFF:08X}")
135
+ except OSError:
136
+ continue
137
+ except OSError:
138
+ pass
139
+ return out
140
+
141
+
142
+ class _NpuCounter:
143
+ """One open PDH query over ``\\GPU Engine(*luid_<npu>*)\\Utilization Percentage``."""
144
+
145
+ _FMT_DOUBLE = 0x200
146
+
147
+ def __init__(self) -> None:
148
+ import ctypes
149
+ from ctypes import wintypes
150
+
151
+ self._ct = ctypes
152
+ self._wt = wintypes
153
+ self._pdh = ctypes.windll.pdh
154
+ self._query = ctypes.c_void_p()
155
+ self._counter = ctypes.c_void_p()
156
+ self.luid: str | None = None
157
+
158
+ class _Fmt(ctypes.Structure):
159
+ _fields_ = [("CStatus", wintypes.DWORD), ("doubleValue", ctypes.c_double)]
160
+
161
+ class _Item(ctypes.Structure):
162
+ _fields_ = [("szName", ctypes.c_wchar_p), ("FmtValue", _Fmt)]
163
+
164
+ self._item = _Item
165
+
166
+ def _engine_luids(self) -> dict[str, set[str]]:
167
+ ct, wt, pdh = self._ct, self._wt, self._pdh
168
+ clen, ilen = wt.DWORD(0), wt.DWORD(0)
169
+ pdh.PdhEnumObjectItemsW(None, None, "GPU Engine", None, ct.byref(clen),
170
+ None, ct.byref(ilen), 400, 0)
171
+ if not ilen.value:
172
+ return {}
173
+ cbuf = ct.create_unicode_buffer(max(clen.value, 1))
174
+ ibuf = ct.create_unicode_buffer(ilen.value)
175
+ if pdh.PdhEnumObjectItemsW(None, None, "GPU Engine", cbuf, ct.byref(clen),
176
+ ibuf, ct.byref(ilen), 400, 0):
177
+ return {}
178
+ engines: dict[str, set[str]] = {}
179
+ for name in ct.wstring_at(ibuf, ilen.value).split("\0"):
180
+ m = _LUID_RE.search(name)
181
+ if m:
182
+ engines.setdefault(m.group(1), set()).add(m.group(2))
183
+ return engines
184
+
185
+ def open(self) -> bool:
186
+ """Find the NPU and add its counter. False when there is none."""
187
+ luid = pick_npu_luid(self._engine_luids(), _directx_luids())
188
+ if luid is None:
189
+ return False
190
+ ct, pdh = self._ct, self._pdh
191
+ if pdh.PdhOpenQueryW(None, 0, ct.byref(self._query)):
192
+ return False
193
+ path = f"\\GPU Engine(*luid_{luid}*)\\Utilization Percentage"
194
+ if pdh.PdhAddEnglishCounterW(self._query, path, 0, ct.byref(self._counter)):
195
+ pdh.PdhCloseQuery(self._query)
196
+ self._query = ct.c_void_p()
197
+ return False
198
+ self.luid = luid
199
+ pdh.PdhCollectQueryData(self._query) # rate counter: first sample primes it
200
+ return True
201
+
202
+ def read(self) -> float:
203
+ """Percent busy since the previous read, summed over every process."""
204
+ ct, wt, pdh = self._ct, self._wt, self._pdh
205
+ if pdh.PdhCollectQueryData(self._query):
206
+ return 0.0
207
+ size, count = wt.DWORD(0), wt.DWORD(0)
208
+ pdh.PdhGetFormattedCounterArrayW(self._counter, self._FMT_DOUBLE,
209
+ ct.byref(size), ct.byref(count), None)
210
+ if not size.value:
211
+ return 0.0
212
+ buf = ct.create_string_buffer(size.value)
213
+ if pdh.PdhGetFormattedCounterArrayW(self._counter, self._FMT_DOUBLE,
214
+ ct.byref(size), ct.byref(count), buf):
215
+ return 0.0
216
+ items = ct.cast(buf, ct.POINTER(self._item))
217
+ total = sum(items[i].FmtValue.doubleValue for i in range(count.value))
218
+ return max(0.0, min(100.0, total))
219
+
220
+ def close(self) -> None:
221
+ if self._query:
222
+ self._pdh.PdhCloseQuery(self._query)
223
+ self._query = self._ct.c_void_p()
224
+
225
+
226
+ def memory_status() -> tuple[float, float] | None:
227
+ """(used GiB, total GiB) of physical memory, or None off Windows."""
228
+ if os.name != "nt":
229
+ return None
230
+ import ctypes
231
+ from ctypes import wintypes
232
+
233
+ class _MemStat(ctypes.Structure):
234
+ _fields_ = [("dwLength", wintypes.DWORD), ("dwMemoryLoad", wintypes.DWORD),
235
+ ("ullTotalPhys", ctypes.c_uint64), ("ullAvailPhys", ctypes.c_uint64),
236
+ ("ullTotalPageFile", ctypes.c_uint64), ("ullAvailPageFile", ctypes.c_uint64),
237
+ ("ullTotalVirtual", ctypes.c_uint64), ("ullAvailVirtual", ctypes.c_uint64),
238
+ ("ullAvailExtendedVirtual", ctypes.c_uint64)]
239
+
240
+ stat = _MemStat()
241
+ stat.dwLength = ctypes.sizeof(stat)
242
+ if not ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(stat)):
243
+ return None
244
+ total = stat.ullTotalPhys / 2 ** 30
245
+ return (stat.ullTotalPhys - stat.ullAvailPhys) / 2 ** 30, total
246
+
247
+
248
+ class SystemSampler:
249
+ """Background sampler for the status line. ``snapshot()`` is cheap and
250
+ lock-free (one tuple swap); the thread never writes to the terminal."""
251
+
252
+ def __init__(self, interval: float = _SAMPLE_INTERVAL_S,
253
+ on_update: Callable[[], None] | None = None) -> None:
254
+ self.interval = interval
255
+ # Called after each fresh sample so the status line can refresh even
256
+ # when nothing else writes to the terminal (a long tool subprocess
257
+ # with no spinner). The callback must be cheap and take its own lock.
258
+ self.on_update = on_update
259
+ self._stop = threading.Event()
260
+ self._thread: threading.Thread | None = None
261
+ # (npu percent or None, mem used GiB or None, mem total GiB or None)
262
+ self._snap: tuple[float | None, float | None, float | None] = (None, None, None)
263
+
264
+ def snapshot(self) -> tuple[float | None, float | None, float | None]:
265
+ return self._snap
266
+
267
+ def start(self) -> None:
268
+ if self._thread is not None or os.name != "nt":
269
+ return
270
+ self._thread = threading.Thread(target=self._run, name="hexcli-statusbar", daemon=True)
271
+ self._thread.start()
272
+
273
+ def stop(self) -> None:
274
+ self._stop.set()
275
+
276
+ def _run(self) -> None:
277
+ counter: _NpuCounter | None = None
278
+ next_discover = 0.0
279
+ while not self._stop.is_set():
280
+ npu: float | None = None
281
+ try:
282
+ if counter is None and time.monotonic() >= next_discover:
283
+ probe = _NpuCounter()
284
+ if probe.open():
285
+ counter = probe
286
+ else:
287
+ next_discover = time.monotonic() + _REDISCOVER_S
288
+ if counter is not None:
289
+ npu = counter.read()
290
+ except Exception: # noqa: BLE001 — a broken counter must never take the REPL down
291
+ counter = None
292
+ next_discover = time.monotonic() + _REDISCOVER_S
293
+ mem = None
294
+ try:
295
+ mem = memory_status()
296
+ except Exception: # noqa: BLE001
297
+ pass
298
+ self._snap = (npu, mem[0] if mem else None, mem[1] if mem else None)
299
+ if self.on_update is not None:
300
+ try:
301
+ self.on_update()
302
+ except Exception: # noqa: BLE001 — never let a repaint kill the sampler
303
+ pass
304
+ self._stop.wait(self.interval)
305
+ if counter is not None:
306
+ counter.close()
307
+
308
+
309
+ # ── the status line ──────────────────────────────────────────────────────────
310
+
311
+
312
+ def status_line(
313
+ width: int,
314
+ *,
315
+ context_percent: int | None,
316
+ npu_percent: float | None,
317
+ mem_used_gb: float | None,
318
+ mem_total_gb: float | None = None,
319
+ activity: str | None = None,
320
+ frame: str = "",
321
+ right: str = "",
322
+ ) -> str:
323
+ """One row: activity (when a turn is running), the three metrics, and
324
+ the location right-aligned when it fits. Never wider than `width`."""
325
+ parts: list[str] = []
326
+ if activity:
327
+ head = f"{C.BCYAN}{frame}{C.RESET} " if frame else ""
328
+ parts.append(f"{head}{C.DIM}{activity}{C.RESET}")
329
+ if context_percent is not None:
330
+ pct = max(0, min(100, int(context_percent)))
331
+ tone = C.BRED if pct >= 100 else C.BYELLOW if pct >= 75 else C.DIM
332
+ parts.append(f"{tone}context {ui.context_gauge(pct)}{C.RESET}")
333
+ if npu_percent is not None:
334
+ tone = C.BCYAN if npu_percent >= 50 else C.DIM
335
+ parts.append(f"{tone}npu {npu_percent:.0f}%{C.RESET}")
336
+ if mem_used_gb is not None:
337
+ hot = mem_total_gb is not None and mem_total_gb > 0 and mem_used_gb / mem_total_gb >= 0.9
338
+ tone = C.BYELLOW if hot else C.DIM
339
+ total = f"/{mem_total_gb:.1f}" if mem_total_gb else ""
340
+ parts.append(f"{tone}mem {mem_used_gb:.1f}{total} GB{C.RESET}")
341
+ left = " ".join(parts)
342
+ left_vis = visible_len(left)
343
+ if right:
344
+ gap = width - left_vis - visible_len(right)
345
+ if gap >= 2:
346
+ return left + " " * gap + f"{C.DIM}{right}{C.RESET}"
347
+ return clip_visible(left, width)
348
+
349
+
350
+ def rule(width: int) -> str:
351
+ return f"{C.DIM}{'─' * max(0, width)}{C.RESET}"
352
+
353
+
354
+ # ── the live area ────────────────────────────────────────────────────────────
355
+
356
+
357
+ def console_geometry() -> tuple[int, int] | None:
358
+ """(cursor row within the window, window height) from the console, or
359
+ None off Windows / off a console. The box is pinned to the window's
360
+ last rows with this: the transcript is padded down to it."""
361
+ if os.name != "nt":
362
+ return None
363
+ import ctypes
364
+ from ctypes import wintypes
365
+
366
+ class _Coord(ctypes.Structure):
367
+ _fields_ = [("X", ctypes.c_short), ("Y", ctypes.c_short)]
368
+
369
+ class _Rect(ctypes.Structure):
370
+ _fields_ = [("Left", ctypes.c_short), ("Top", ctypes.c_short),
371
+ ("Right", ctypes.c_short), ("Bottom", ctypes.c_short)]
372
+
373
+ class _Info(ctypes.Structure):
374
+ _fields_ = [("dwSize", _Coord), ("dwCursorPosition", _Coord), ("wAttributes", wintypes.WORD),
375
+ ("srWindow", _Rect), ("dwMaximumWindowSize", _Coord)]
376
+
377
+ k32 = ctypes.windll.kernel32
378
+ k32.GetStdHandle.restype = ctypes.c_void_p
379
+ info = _Info()
380
+ if not k32.GetConsoleScreenBufferInfo(ctypes.c_void_p(k32.GetStdHandle(-11)), ctypes.byref(info)):
381
+ return None
382
+ height = info.srWindow.Bottom - info.srWindow.Top + 1
383
+ row = info.dwCursorPosition.Y - info.srWindow.Top
384
+ if height <= 0 or row < 0 or row >= height:
385
+ return None
386
+ return row, height
387
+
388
+
389
+ class LiveArea:
390
+ """The box between reads. See the module docstring for the mechanics.
391
+
392
+ `margin` is the ``ui._Margin`` the wrapped streams pad and wrap with; its
393
+ `col` is where the transcript cursor sits on its row, which is what lets
394
+ a repaint put the cursor back after drawing rows below it.
395
+ """
396
+
397
+ def __init__(
398
+ self,
399
+ margin: Any,
400
+ context_percent: Callable[[], int | None],
401
+ sampler: SystemSampler | None = None,
402
+ prompt: str = "> ",
403
+ geometry: Callable[[], tuple[int, int] | None] | None = None,
404
+ ) -> None:
405
+ self.margin = margin
406
+ self._context_percent = context_percent
407
+ self.sampler = sampler or SystemSampler()
408
+ self.prompt = prompt
409
+ self._geometry = geometry or console_geometry
410
+ self.editor_rows = 4 # rule, input row, rule, status: what the editor draws
411
+ self._pad_top: int | None = None # first row of the blank pad above the conversation
412
+ self._pad_above = 0 # how many pad rows there are
413
+ self._last_shape: tuple[Any, int] | None = None # (window height, usable width) at the last draw
414
+ # A resize while a turn runs: the owner reprints the banner and the
415
+ # stored conversation (calling the `pin` it is handed in between,
416
+ # which puts the box up and the pad under the banner); the turn's
417
+ # own output so far is then replayed from `_turn_log`.
418
+ self.on_relayout: Callable[[Callable[[], None]], None] | None = None
419
+ self._turn_log: list[str] = []
420
+ self._turn_log_len = 0
421
+ self._in_turn = False
422
+ self._relaying = False
423
+ self.lock = threading.RLock()
424
+ self.enabled = False
425
+ self.activity: str | None = None
426
+ self._activity_since: float | None = None # when the current turn's activity began
427
+ self.frame = ""
428
+ self.location = ""
429
+ self._drawn = 0 # rows currently on screen below the transcript
430
+ self._inner: Any = None # the stream repaints go through (set by install)
431
+ self._signature: Any = None # what _draw last put on screen, for repaint skips
432
+
433
+ # -- content --------------------------------------------------------------
434
+
435
+ def refresh_location(self) -> None:
436
+ """cwd and branch for the right edge. Runs git, so only per read."""
437
+ try:
438
+ branch = ui.get_git_branch()
439
+ cwd = ui.short_cwd()
440
+ except Exception: # noqa: BLE001
441
+ self.location = ""
442
+ return
443
+ self.location = f"{cwd} ({branch})" if branch else cwd
444
+
445
+ def status(self, width: int) -> str:
446
+ npu, used, total = self.sampler.snapshot()
447
+ try:
448
+ pct = self._context_percent()
449
+ except Exception: # noqa: BLE001
450
+ pct = None
451
+ activity = self.activity
452
+ if activity:
453
+ # While a turn runs: the label with its elapsed time. The cancel
454
+ # hint lives in the banner only (the owner asked for one, not two).
455
+ elapsed = int(time.monotonic() - (self._activity_since or time.monotonic()))
456
+ activity = f"{activity} {elapsed}s" if elapsed >= 1 else activity
457
+ return status_line(
458
+ width, context_percent=pct, npu_percent=npu, mem_used_gb=used,
459
+ mem_total_gb=total, activity=activity, frame=self.frame,
460
+ right=self.location,
461
+ )
462
+
463
+ def chrome(self, width: int) -> tuple[list[str], list[str]]:
464
+ """What the line editor draws above and below the input row.
465
+
466
+ One cell short of the width: a row that exactly fills it is the
467
+ deferred-wrap case the editor pads with a spare row (lineedit,
468
+ ``_pad``), which showed up as a blank line under every rule."""
469
+ w = max(1, width - 1)
470
+ return [rule(w)], [rule(w), self.status(w)]
471
+
472
+ def rows(self, width: int) -> list[str]:
473
+ """The whole box while nothing is being typed."""
474
+ above, below = self.chrome(width)
475
+ return above + [f"{C.DIM}{self.prompt.rstrip()}{C.RESET}"] + below
476
+
477
+ # -- drawing --------------------------------------------------------------
478
+
479
+ def _erase(self, inner: Any) -> None:
480
+ # Straight to the base stream: the margin layer would otherwise file
481
+ # the escape under its current word and replay it on a reflow.
482
+ if self._drawn:
483
+ inner._base.write("\033[J")
484
+ self._drawn = 0
485
+
486
+ def screen_cleared(self) -> None:
487
+ """The screen was cleared by someone else (cls, a transcript redraw):
488
+ nothing of ours is on it any more."""
489
+ with self.lock:
490
+ self._drawn = 0
491
+ self._signature = None
492
+ self.reset_pad()
493
+
494
+ def note_scroll(self, rows: int) -> None:
495
+ """The window scrolled up by `rows` (the editor grew past the bottom
496
+ with a multi-line entry): the pad moved up with it, and any part of
497
+ it that left the window is gone."""
498
+ with self.lock:
499
+ if rows <= 0 or self._pad_top is None:
500
+ return
501
+ self._pad_top -= rows
502
+ if self._pad_top < 0:
503
+ self._pad_above = max(0, self._pad_above + self._pad_top)
504
+ self._pad_top = 0
505
+ if self._pad_above == 0:
506
+ self._pad_top = None
507
+
508
+ def _geometry_changed(self) -> bool:
509
+ """True when the window or the usable width differs from the last
510
+ draw: the terminal reflowed, so the pad rows are not where we think."""
511
+ geo = self._geo()
512
+ now = (geo[1] if geo else None, self.margin.usable)
513
+ changed = self._last_shape is not None and now != self._last_shape
514
+ self._last_shape = now
515
+ return changed
516
+
517
+ def _rows_below_cursor(self) -> int | None:
518
+ geo = self._geo()
519
+ if geo is None:
520
+ return None
521
+ row, height = geo
522
+ return height - 1 - row
523
+
524
+ # The pad: blank rows between the content at the top of the window (the
525
+ # banner, or whatever scrolled there) and the conversation, which is
526
+ # anchored just above the box. New lines appear at the bottom and the
527
+ # conversation grows UPWARD into the pad: each newline deletes one pad
528
+ # row at its top (ESC[M) so everything below shifts up one, while the
529
+ # banner keeps its place. Only when the pad is gone does a newline
530
+ # scroll the whole window, taking the banner into scrollback. Pinning
531
+ # the box when the cursor is high (start-up, a taller window) inserts
532
+ # pad rows (ESC[L) at the pad's top, which is the cursor row when there
533
+ # is no pad yet, so the banner above never moves.
534
+
535
+ def _flush_base(self) -> None:
536
+ """Cursor moves are escape sequences with no newline; a line-buffered
537
+ stdout still holds them, so the console would report the cursor
538
+ from before the move. Flush before every geometry read."""
539
+ inner = self._inner
540
+ base = getattr(inner, "_base", inner)
541
+ try:
542
+ base.flush()
543
+ except Exception: # noqa: BLE001
544
+ pass
545
+
546
+ def _geo(self) -> tuple[int, int] | None:
547
+ self._flush_base()
548
+ try:
549
+ return self._geometry()
550
+ except Exception: # noqa: BLE001
551
+ return None
552
+
553
+ def _cursor_col(self) -> int:
554
+ return int(self.margin.pad) + int(self.margin.col)
555
+
556
+ def reset_pad(self) -> None:
557
+ """Forget the pad after something redrew the screen (a resize, a
558
+ zoom redraw): its rows are no longer where they were."""
559
+ self._pad_top = None
560
+ self._pad_above = 0
561
+
562
+ def _insert_pad_rows(self, inner: Any, n: int) -> None:
563
+ geo = self._geo()
564
+ if geo is None or n <= 0:
565
+ return
566
+ row, _height = geo
567
+ fresh = self._pad_top is None or self._pad_top > row
568
+ top = row if fresh else self._pad_top
569
+ inner.write(f"\033[{top + 1};1H\033[{n}L\033[{row + n + 1};{self._cursor_col() + 1}H")
570
+ self._pad_top = top
571
+ # A stale or absent pad is replaced, never added to.
572
+ self._pad_above = n if fresh else self._pad_above + n
573
+
574
+ def _delete_pad_rows(self, inner: Any, n: int, col: int | None = None) -> int:
575
+ """Remove up to `n` rows from the top of the pad; returns how many.
576
+ `col` is the cursor's real column to return to (the margin's column
577
+ may already reflect text that is about to be written)."""
578
+ geo = self._geo()
579
+ if geo is None or n <= 0 or self._pad_above <= 0 or self._pad_top is None:
580
+ return 0
581
+ row, _height = geo
582
+ if self._pad_top > row:
583
+ self.reset_pad()
584
+ return 0
585
+ m = min(n, self._pad_above)
586
+ if col is None:
587
+ col = self._cursor_col()
588
+ inner.write(f"\033[{self._pad_top + 1};1H\033[{m}M\033[{row - m + 1};{col + 1}H")
589
+ self._pad_above -= m
590
+ if self._pad_above == 0:
591
+ self._pad_top = None
592
+ return m
593
+
594
+ def _compose(self) -> list[str]:
595
+ """The box rows clipped to width."""
596
+ m = self.margin
597
+ return [clip_visible(r, m.usable) for r in self.rows(m.usable)]
598
+
599
+ def _draw(self, inner: Any, rows: list[str] | None = None) -> None:
600
+ """Draw the box on the rows below the cursor, at the bottom of the
601
+ window: pad rows are inserted or removed so it lands there."""
602
+ m = self.margin
603
+ if rows is None:
604
+ rows = self._compose()
605
+ if self._geometry_changed():
606
+ self.reset_pad()
607
+ below = self._rows_below_cursor()
608
+ if below is not None:
609
+ if below > len(rows):
610
+ self._insert_pad_rows(inner, below - len(rows))
611
+ elif below < len(rows):
612
+ self._delete_pad_rows(inner, len(rows) - below) # the rest scrolls
613
+ saved = (m.col, m.word, m.word_vis)
614
+ out = ["\033[?25l", "\n", "\n".join(rows), "\r", f"\033[{len(rows)}A"]
615
+ if saved[0]:
616
+ out.append(f"\033[{saved[0]}C")
617
+ out.append("\033[?25h")
618
+ inner.write("".join(out))
619
+ m.col, m.word, m.word_vis = saved
620
+ self._drawn = len(rows)
621
+ self._signature = (tuple(rows), saved[0])
622
+
623
+ def make_room(self, n: int) -> int:
624
+ """The editor's entry is about to run past the bottom: delete up to
625
+ `n` pad rows at the pad's top so the rows below (the conversation and
626
+ the editor) move up and the window need not scroll. Returns how many
627
+ rows were freed; the banner keeps its place for those."""
628
+ with self.lock:
629
+ if self._inner is None or n <= 0:
630
+ return 0
631
+ return self._delete_pad_rows(self._inner, n)
632
+
633
+ def give_room(self, n: int) -> int:
634
+ """The entry shrank again: put up to `n` rows back into the pad, so
635
+ the conversation and the editor move down to where they were."""
636
+ with self.lock:
637
+ if self._inner is None or n <= 0 or self._pad_top is None:
638
+ return 0
639
+ self._insert_pad_rows(self._inner, n)
640
+ return n
641
+
642
+ def pad_for_editor(self) -> None:
643
+ """Put the cursor on the row where the editor's first row must go for
644
+ its rows to land on the window's last rows. Called with the box down
645
+ and the cursor on a fresh row; the content above stays put."""
646
+ geo = self._geo()
647
+ if geo is None or self._inner is None:
648
+ return
649
+ row, height = geo
650
+ target = height - self.editor_rows
651
+ if row < target:
652
+ self._insert_pad_rows(self._inner, target - row)
653
+
654
+ def write(self, inner: Any, text: str) -> None:
655
+ """A transcript write from one of the wrapped streams. The newlines
656
+ it carries consume pad rows first, so the text appears above the box
657
+ and the conversation grows upward."""
658
+ with self.lock:
659
+ if self.enabled and not self._relaying and self._geometry_changed():
660
+ self._relayout(inner)
661
+ if self._in_turn and not self._relaying:
662
+ self._log_turn(text)
663
+ if self.enabled:
664
+ self._erase(inner)
665
+ # The column to come back to after a pad delete is where the
666
+ # cursor is NOW; rendering advances the margin's column to where
667
+ # it will be after the write, so read it first.
668
+ col_before = self._cursor_col()
669
+ rendered = self.margin.render(text)
670
+ newlines = rendered.count("\n")
671
+ if newlines and self._pad_above and self._geometry_changed():
672
+ self.reset_pad()
673
+ if newlines and self._pad_above:
674
+ geo = self._geo()
675
+ if geo is not None:
676
+ row, height = geo
677
+ need = newlines + (self._drawn_rows_needed() if self.enabled else 0)
678
+ deficit = need - (height - 1 - row)
679
+ if deficit > 0:
680
+ self._delete_pad_rows(inner, deficit, col=col_before)
681
+ inner._base.write(rendered)
682
+ if self.enabled:
683
+ self._draw(inner)
684
+
685
+ def _drawn_rows_needed(self) -> int:
686
+ return len(self.rows(self.margin.usable))
687
+
688
+ _TURN_LOG_MAX = 400_000 # characters kept for a replay; the oldest go first
689
+
690
+ def _log_turn(self, text: str) -> None:
691
+ self._turn_log.append(text)
692
+ self._turn_log_len += len(text)
693
+ while self._turn_log_len > self._TURN_LOG_MAX and len(self._turn_log) > 1:
694
+ self._turn_log_len -= len(self._turn_log.pop(0))
695
+
696
+ def _relayout(self, inner: Any) -> None:
697
+ """The window changed shape while the box is up. The terminal
698
+ re-wrapped the rows; the ones that no longer fit went into its
699
+ scrollback (Windows Terminal), out of reach. Clear, let the owner
700
+ reprint the banner and the conversation, then replay this turn's
701
+ output so far, all through the pad-consuming path, so the layout
702
+ is what it would have been at this size from the start."""
703
+ if self.on_relayout is None:
704
+ self.reset_pad()
705
+ return
706
+ self._relaying = True
707
+ try:
708
+ inner._base.write("\033[2J\033[3J\033[H\r")
709
+ m = self.margin
710
+ m.col, m.word, m.word_vis = 0, "", 0
711
+ self._drawn = 0
712
+ self._signature = None
713
+ self.reset_pad()
714
+ # The box stays down while the banner prints: a write with the
715
+ # box up pins it under the cursor, and the pad would land
716
+ # between the banner's rows instead of under them.
717
+ self.enabled = False
718
+
719
+ def pin() -> None:
720
+ self.enabled = True
721
+ self._draw(inner)
722
+
723
+ try:
724
+ self.on_relayout(pin)
725
+ finally:
726
+ self.enabled = True
727
+ replay = "".join(self._turn_log)
728
+ if replay:
729
+ self.write(inner, replay)
730
+ finally:
731
+ self._relaying = False
732
+
733
+ def repaint(self) -> None:
734
+ """Redraw the box in place (a spinner tick, a metrics sample). Skips
735
+ the erase and rewrite when nothing visible changed, so a quiet
736
+ second or an identical sample costs nothing."""
737
+ with self.lock:
738
+ if not (self.enabled and self._inner is not None):
739
+ return
740
+ rows = self._compose()
741
+ if not self._relaying and self._geometry_changed():
742
+ self._relayout(self._inner) # and never skip: the box must move with the window
743
+ rows = self._compose()
744
+ elif self._drawn and (tuple(rows), self.margin.col) == self._signature:
745
+ return
746
+ self._erase(self._inner)
747
+ self._draw(self._inner, rows)
748
+
749
+ # -- state ----------------------------------------------------------------
750
+
751
+ def enable(self) -> None:
752
+ """Show the box below the transcript until `disable`. A turn's clock
753
+ starts with its first activity label and runs until disable()."""
754
+ with self.lock:
755
+ self.enabled = True
756
+ self.activity, self.frame = None, ""
757
+ self._activity_since = None
758
+ self._turn_log, self._turn_log_len, self._in_turn = [], 0, True
759
+ if self._inner is not None:
760
+ self._erase(self._inner)
761
+ self._draw(self._inner)
762
+
763
+ def disable(self) -> None:
764
+ """Take the box down and leave the cursor on a fresh transcript row,
765
+ which is where the line editor expects to start."""
766
+ with self.lock:
767
+ if self._inner is not None:
768
+ self._erase(self._inner)
769
+ if self.margin.col:
770
+ self._inner.write("\n")
771
+ self.enabled = False
772
+ self.activity, self.frame = None, ""
773
+ self._turn_log, self._turn_log_len, self._in_turn = [], 0, False
774
+ self.pad_for_editor()
775
+ self.refresh_location()
776
+
777
+ def suspend(self) -> None:
778
+ """Take the box down for an inline prompt (a y/N confirm) without
779
+ padding to the bottom, so the question prints right where the turn
780
+ is, not pushed to the last row. resume() puts the box back."""
781
+ with self.lock:
782
+ if self._inner is not None:
783
+ self._erase(self._inner)
784
+ if self.margin.col:
785
+ self._inner.write("\n")
786
+ self.enabled = False
787
+
788
+ def resume(self) -> None:
789
+ """Put the box back after suspend(), keeping the current activity."""
790
+ with self.lock:
791
+ self.enabled = True
792
+ if self._inner is not None:
793
+ self._erase(self._inner)
794
+ self._draw(self._inner)
795
+
796
+ def set_activity(self, label: str | None) -> None:
797
+ with self.lock:
798
+ if label is None:
799
+ self.frame = ""
800
+ elif self._activity_since is None:
801
+ self._activity_since = time.monotonic() # per turn: enable() resets it
802
+ self.activity = label
803
+ self.repaint()
804
+
805
+ def tick(self, frame: str) -> None:
806
+ with self.lock:
807
+ self.frame = frame
808
+ self.repaint()
809
+
810
+
811
+ class _LiveStream:
812
+ """stdout/stderr wrapper: transcript writes go through the live area."""
813
+
814
+ def __init__(self, inner: Any, live: LiveArea) -> None:
815
+ self._inner = inner
816
+ self._live = live
817
+
818
+ def write(self, s: str) -> int:
819
+ if s:
820
+ self._live.write(self._inner, s)
821
+ return len(s)
822
+
823
+ def writelines(self, lines: Any) -> None:
824
+ for line in lines:
825
+ self.write(line)
826
+
827
+ def __getattr__(self, name: str) -> Any:
828
+ return getattr(self._inner, name)
829
+
830
+
831
+ _LIVE: LiveArea | None = None
832
+
833
+
834
+ def current() -> LiveArea | None:
835
+ return _LIVE
836
+
837
+
838
+ def install(config: dict[str, Any], context_percent: Callable[[], int | None]) -> LiveArea | None:
839
+ """Wrap the console streams and start the sampler. None when the REPL
840
+ is not on an interactive console (piped stdin, CI, --raw) or the user
841
+ turned the box off with `status_bar: false`; the plain prompt is used
842
+ then, exactly as before."""
843
+ global _LIVE
844
+ if _LIVE is not None:
845
+ return _LIVE
846
+ if not bool(config.get("status_bar", True)) or not bool(config.get("rich_input", True)):
847
+ return None
848
+ try:
849
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
850
+ return None
851
+ import msvcrt # noqa: F401
852
+ except (ImportError, Exception):
853
+ return None
854
+ if not isinstance(sys.stdout, ui._MarginStream):
855
+ # side_padding 0: the live area still needs the column bookkeeping.
856
+ margin = ui._Margin(0)
857
+ sys.stdout = ui._MarginStream(sys.stdout, margin)
858
+ sys.stderr = ui._MarginStream(sys.stderr, margin)
859
+ margin = sys.stdout._margin
860
+ live = LiveArea(margin, context_percent)
861
+ live._inner = sys.stdout
862
+ sys.stdout = _LiveStream(sys.stdout, live)
863
+ sys.stderr = _LiveStream(sys.stderr, live)
864
+ # The sampler refreshes the status line once a second even when nothing
865
+ # else writes (a long tool subprocess with no spinner); the repaint skips
866
+ # itself when the numbers did not move.
867
+ live.sampler.on_update = live.repaint
868
+ live.sampler.start()
869
+ ui.LIVE_AREA = live
870
+ _LIVE = live
871
+ return live
872
+
873
+
874
+
875
+
876
+ def uninstall() -> None:
877
+ """Erase the box if it is up, stop the sampler and unwrap the streams."""
878
+ global _LIVE
879
+ live = _LIVE
880
+ if live is None:
881
+ return
882
+ with live.lock:
883
+ if live._inner is not None:
884
+ live._erase(live._inner)
885
+ if live.margin.col:
886
+ live._inner.write("\n")
887
+ live.enabled = False
888
+ live.sampler.stop()
889
+ for name in ("stdout", "stderr"):
890
+ stream = getattr(sys, name)
891
+ if isinstance(stream, _LiveStream):
892
+ setattr(sys, name, stream._inner)
893
+ ui.LIVE_AREA = None
894
+ _LIVE = None