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