webbee 0.3.16__tar.gz → 0.3.18__tar.gz
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.
- {webbee-0.3.16 → webbee-0.3.18}/CHANGELOG.md +48 -0
- {webbee-0.3.16 → webbee-0.3.18}/PKG-INFO +1 -1
- {webbee-0.3.16 → webbee-0.3.18}/pyproject.toml +1 -1
- webbee-0.3.18/src/webbee/__init__.py +1 -0
- webbee-0.3.18/src/webbee/http.py +11 -0
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/intel/store.py +9 -2
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/intel/vectors.py +9 -0
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/intel/watch.py +9 -1
- webbee-0.3.18/src/webbee/output_pane.py +347 -0
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/queue_panel.py +81 -23
- webbee-0.3.18/src/webbee/reflow.py +53 -0
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/render.py +93 -30
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/repl.py +86 -20
- webbee-0.3.18/src/webbee/selection.py +155 -0
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/session.py +148 -25
- webbee-0.3.18/src/webbee/sizing.py +40 -0
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/steer.py +33 -4
- webbee-0.3.18/src/webbee/stream.py +115 -0
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/thread.py +36 -22
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/todo_panel.py +35 -13
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/tokens.py +14 -0
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/tools.py +10 -1
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/tui.py +255 -53
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_intel_store.py +21 -0
- webbee-0.3.18/tests/test_intel_vectors.py +51 -0
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_intel_watch.py +12 -0
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_marathon.py +5 -2
- webbee-0.3.18/tests/test_reflow.py +85 -0
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_render.py +144 -0
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_repl.py +133 -1
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_session.py +437 -15
- webbee-0.3.18/tests/test_sizing.py +30 -0
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_steer.py +48 -0
- webbee-0.3.18/tests/test_stream.py +389 -0
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_thread.py +69 -0
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_todo_panel.py +76 -0
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_tokens.py +30 -0
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_tools.py +27 -0
- webbee-0.3.18/tests/test_tui.py +2936 -0
- webbee-0.3.16/src/webbee/__init__.py +0 -1
- webbee-0.3.16/src/webbee/output_pane.py +0 -204
- webbee-0.3.16/src/webbee/stream.py +0 -58
- webbee-0.3.16/tests/test_intel_vectors.py +0 -26
- webbee-0.3.16/tests/test_stream.py +0 -152
- webbee-0.3.16/tests/test_tui.py +0 -1397
- {webbee-0.3.16 → webbee-0.3.18}/.github/workflows/publish.yml +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/.gitignore +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/LICENSE +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/README.md +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/install.sh +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/account.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/banner_art.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/boot.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/checkpoints.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/cli.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/clipboard.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/coding_context.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/commands.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/config.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/consent.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/details.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/events.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/frames.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/intel/__init__.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/intel/chunker.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/intel/embed.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/intel/graph.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/intel/indexer.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/intel/models.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/intel/query.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/intel/service.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/remote.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/repo.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/sessions.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/src/webbee/update.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/tests/__init__.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_account.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_checkpoints.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_cli.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_clipboard.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_commands.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_config.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_cpc_contract_stable.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_details.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_events.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_freeze_fix.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_intel_chunker.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_intel_embed.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_intel_graph.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_intel_indexer.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_intel_no_numpy.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_intel_query.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_intel_service.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_packaging.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_repo.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_sessions.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_tui_hardening.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_update.py +0 -0
- {webbee-0.3.16 → webbee-0.3.18}/tests/test_version.py +0 -0
|
@@ -1,5 +1,53 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.18
|
|
4
|
+
|
|
5
|
+
The adaptive wave: the terminal finally behaves like a real app.
|
|
6
|
+
|
|
7
|
+
- Stretch or shrink the window and the WHOLE transcript re-wraps live to the
|
|
8
|
+
new size — history included, splash re-centered, nothing clipped, no dead
|
|
9
|
+
margins. Panels, the input box and text truncations now scale as
|
|
10
|
+
proportions of your screen, not fixed character counts.
|
|
11
|
+
- Select text with the mouse and drag past the edge — the transcript
|
|
12
|
+
auto-scrolls under your selection, keeps selecting while you hold at the
|
|
13
|
+
edge, and finishes the copy even if you release outside the pane. Scrolling
|
|
14
|
+
mid-selection no longer corrupts what you copy, and a lost release can
|
|
15
|
+
never hijack your next click or your clipboard.
|
|
16
|
+
|
|
17
|
+
## 0.3.17
|
|
18
|
+
|
|
19
|
+
Bulletproof core (W1): a marathon can no longer be killed by transport, the
|
|
20
|
+
queue survives every error class, and the client stops leaking memory.
|
|
21
|
+
|
|
22
|
+
- A gateway blip mid-run (502, deploy, network drop) no longer kills the turn:
|
|
23
|
+
the stream patiently reconnects and resumes exactly where it left off, the
|
|
24
|
+
toolbar shows an honest `⟳ reconnecting` state, and only a real sign-out
|
|
25
|
+
ends the run — with a clear "run /login" message instead of a raw error.
|
|
26
|
+
- A stream 401 gets ONE forced token refresh before it counts as a sign-out,
|
|
27
|
+
and a refresh that fails because the gateway was mid-deploy no longer burns
|
|
28
|
+
that chance.
|
|
29
|
+
- Every message now carries a dedup key end-to-end, so a retried send after an
|
|
30
|
+
ambiguous network failure can never execute the same instruction twice.
|
|
31
|
+
- The turn-start and result posts retry transient failures too — outage
|
|
32
|
+
recovery drops from minutes to seconds.
|
|
33
|
+
- A turn that ends in an error HOLDS the queued messages (with an honest note)
|
|
34
|
+
instead of burning them one failing turn at a time; a parked marathon keeps
|
|
35
|
+
its queued rows visible, tagged ⏸.
|
|
36
|
+
- A stuck busy flag can no longer starve remote-message pickup; a message the
|
|
37
|
+
kernel deduplicated ends its wait honestly instead of spinning forever.
|
|
38
|
+
- Pull a queued message to edit and resubmit it unchanged — it keeps its dedup
|
|
39
|
+
identity.
|
|
40
|
+
- Click the queue or todo panel header to collapse it to one row (▸/▾).
|
|
41
|
+
- Performance: one keep-alive connection replaces a TLS handshake every 4s;
|
|
42
|
+
the idle poll relaxes to 30s after 5 quiet minutes; hours-long marathons no
|
|
43
|
+
longer grow memory without bound; transcript rendering is O(new output) per
|
|
44
|
+
print; embedding vectors load memory-mapped.
|
|
45
|
+
- Windows groundwork: `.git` filters now match Windows paths (no reindex
|
|
46
|
+
storms).
|
|
47
|
+
|
|
48
|
+
Companion release: imperal-mcp 0.5.2 — only a real 401 means "signed out";
|
|
49
|
+
gateway 5xx/network errors during a token refresh are retryable.
|
|
50
|
+
|
|
3
51
|
## 0.3.16
|
|
4
52
|
|
|
5
53
|
- A message typed while Webbee is working now shows in the queue panel
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.18"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""ONE keep-alive httpx.AsyncClient for the client's small gateway calls.
|
|
2
|
+
Before W1 the idle-steer poller opened a NEW AsyncClient — a fresh TCP+TLS
|
|
3
|
+
handshake — every 4s tick, forever (thread.py per-call clients). The repl owns
|
|
4
|
+
one client for the loop's lifetime and passes it down; every callee keeps a
|
|
5
|
+
per-call fallback so tests and old call sites work unchanged."""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def make_client(cfg):
|
|
10
|
+
import httpx
|
|
11
|
+
return httpx.AsyncClient(base_url=cfg.api_url, timeout=10)
|
|
@@ -99,10 +99,17 @@ def load_vectors(cache_dir, repo_key, git_ref, model_id):
|
|
|
99
99
|
if (meta.get("schema_version") != SCHEMA_VERSION or meta.get("git_ref") != git_ref
|
|
100
100
|
or meta.get("model_id") != model_id):
|
|
101
101
|
return None
|
|
102
|
-
|
|
102
|
+
# mmap_mode="r": the on-disk cache can be large across a big repo's
|
|
103
|
+
# chunk set -- read it lazily off disk instead of paging the whole
|
|
104
|
+
# matrix into RAM up front. save_vectors always writes float32, so
|
|
105
|
+
# the astype copy below only fires on an actual dtype mismatch
|
|
106
|
+
# (e.g. an older/foreign cache file), not on every load.
|
|
107
|
+
mat = np.load(os.path.join(d, "embeddings.npy"), mmap_mode="r")
|
|
103
108
|
ids = meta.get("ids") or []
|
|
104
109
|
if len(ids) != mat.shape[0]:
|
|
105
110
|
return None
|
|
106
|
-
|
|
111
|
+
if mat.dtype != np.float32:
|
|
112
|
+
mat = mat.astype(np.float32)
|
|
113
|
+
return ids, mat
|
|
107
114
|
except Exception:
|
|
108
115
|
return None
|
|
@@ -20,6 +20,15 @@ class VectorStore:
|
|
|
20
20
|
rows = []
|
|
21
21
|
for i, _id in enumerate(ids):
|
|
22
22
|
if _id in self._pos:
|
|
23
|
+
if not self._mat.flags.writeable:
|
|
24
|
+
# Copy-on-write: _mat may be a read-only mmap loaded
|
|
25
|
+
# straight off the vector cache (store.load_vectors'
|
|
26
|
+
# mmap_mode="r" perf win) via from_arrays. A read-only
|
|
27
|
+
# boot+search session never reaches this branch; only an
|
|
28
|
+
# incremental re-embed of an existing chunk id (same id,
|
|
29
|
+
# changed content) needs to mutate a row in place, so pay
|
|
30
|
+
# for exactly one full copy here, lazily.
|
|
31
|
+
self._mat = np.array(self._mat)
|
|
23
32
|
self._mat[self._pos[_id]] = vecs[i]
|
|
24
33
|
else:
|
|
25
34
|
self._pos[_id] = len(self._ids)
|
|
@@ -6,6 +6,14 @@ import asyncio
|
|
|
6
6
|
import os
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
def _ignored(path: str) -> bool:
|
|
10
|
+
"""PURE. True for VCS/vendor churn. Normalized to forward slashes so the
|
|
11
|
+
filter works on Windows too (watchfiles yields backslash paths there --
|
|
12
|
+
unfiltered .git churn re-indexed the repo on every checkpoint)."""
|
|
13
|
+
p = path.replace(os.sep, "/").replace("\\", "/")
|
|
14
|
+
return "/.git/" in p or "/node_modules/" in p
|
|
15
|
+
|
|
16
|
+
|
|
9
17
|
async def watch_workspace(root: str, on_change) -> None:
|
|
10
18
|
"""Call on_change(set_of_relpaths) as files change. Fail-soft: if
|
|
11
19
|
watchfiles is unavailable, return immediately (no watcher)."""
|
|
@@ -16,7 +24,7 @@ async def watch_workspace(root: str, on_change) -> None:
|
|
|
16
24
|
async for changes in awatch(root):
|
|
17
25
|
rels = set()
|
|
18
26
|
for _chg, path in changes:
|
|
19
|
-
if
|
|
27
|
+
if _ignored(path):
|
|
20
28
|
continue
|
|
21
29
|
try:
|
|
22
30
|
rels.add(os.path.relpath(path, root))
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""The full-screen dock's scrollable, colored output region. Rich renders the
|
|
2
|
+
full transcript into a StringIO as ANSI; the pane VIRTUALIZES — the
|
|
3
|
+
FormattedTextControl is fed ONLY the currently-visible slice of lines, so
|
|
4
|
+
every frame costs O(viewport), not O(session). Wheel / PageUp move `_offset`;
|
|
5
|
+
left-drag copies to the local clipboard (`webbee.clipboard`), OSC 52 fallback."""
|
|
6
|
+
|
|
7
|
+
_MAX_RECORDS = 4000 # RecordingConsole ring bound (W2 front-2: replay material for reflow)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OutputPane:
|
|
11
|
+
def __init__(self, width: int = 100) -> None:
|
|
12
|
+
import io
|
|
13
|
+
from collections import deque
|
|
14
|
+
|
|
15
|
+
from prompt_toolkit.formatted_text import ANSI
|
|
16
|
+
from prompt_toolkit.layout.containers import Window
|
|
17
|
+
from prompt_toolkit.layout.controls import FormattedTextControl
|
|
18
|
+
from prompt_toolkit.mouse_events import MouseEventType, MouseButton
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
|
|
21
|
+
self._io = io.StringIO()
|
|
22
|
+
pane = self
|
|
23
|
+
pane_records: deque = deque(maxlen=_MAX_RECORDS)
|
|
24
|
+
|
|
25
|
+
class _RecordingConsole(Console):
|
|
26
|
+
"""Captures every printed renderable so a width change can REPLAY
|
|
27
|
+
the transcript (old pane kept only baked ANSI, which can't
|
|
28
|
+
re-wrap). Bounded ring: past 4000 records, only the tail replays."""
|
|
29
|
+
|
|
30
|
+
def print(self, *objects, **kw): # noqa: A003
|
|
31
|
+
if getattr(pane, "_replaying", False): # replay latch: never re-record
|
|
32
|
+
return super().print(*objects, **kw)
|
|
33
|
+
evicting = len(pane_records) == pane_records.maxlen
|
|
34
|
+
pane_records.append((objects, kw))
|
|
35
|
+
pane._all_lines() # sync the W1 cache to the pre-write pos
|
|
36
|
+
pos = pane._io.tell()
|
|
37
|
+
result = super().print(*objects, **kw)
|
|
38
|
+
pane._io.seek(pos)
|
|
39
|
+
delta = pane._io.read() # leaves position at EOF again
|
|
40
|
+
if evicting and pane._record_lines: # stay in lockstep with the deque's own drop
|
|
41
|
+
evicted_span = pane._record_lines[0] # capture BEFORE deleting
|
|
42
|
+
del pane._record_lines[0]
|
|
43
|
+
pane._ring_base_lines += evicted_span # the evicted lines don't vanish —
|
|
44
|
+
# they become pre-ring buffer content
|
|
45
|
+
pane._record_lines.append(delta.count("\n"))
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
def clear(self, *a, **kw):
|
|
49
|
+
pane_records.clear()
|
|
50
|
+
pane._reset_buffer() # StringIO + caches reset (Task 3 builds on it)
|
|
51
|
+
|
|
52
|
+
self._records = pane_records
|
|
53
|
+
self._record_lines: list = [] # per-record NEW-line count, prefix-summable (reflow.py)
|
|
54
|
+
self._ring_base_lines = 0 # buffer content lines BEFORE ring record 0 (deque
|
|
55
|
+
# eviction + trims) — invariant: len(_all_lines()) ==
|
|
56
|
+
# 1 + _ring_base_lines + sum(_record_lines)
|
|
57
|
+
self._replaying = False # True while a width-reflow replay is in flight
|
|
58
|
+
self.console = _RecordingConsole(file=self._io, force_terminal=True,
|
|
59
|
+
color_system="truecolor", width=width,
|
|
60
|
+
highlight=False)
|
|
61
|
+
self._ANSI = ANSI
|
|
62
|
+
self._lines_cache = (0, [""]) # (write-pos, split-lines) — memoize the split
|
|
63
|
+
self._offset = 0 # index of the top visible line
|
|
64
|
+
self._view_h = 20 # viewport height (updated from render_info)
|
|
65
|
+
self._follow = True # stick to the tail unless the user scrolled up
|
|
66
|
+
self._sel = None # (abs_start, abs_end) during a drag → live highlight
|
|
67
|
+
self._edge_drag = 0 # +1/-1 while dragging at the bottom/top edge, else 0
|
|
68
|
+
self._edge_ticks = 0 # consecutive edge_tick()s since the last drag MOUSE_MOVE
|
|
69
|
+
self._plain_cache = (0, [""]) # (write-pos, ANSI-stripped lines) for select/highlight
|
|
70
|
+
self.copy_flash = "" # transient toolbar note after a copy
|
|
71
|
+
self._flash_until = 0.0
|
|
72
|
+
|
|
73
|
+
# The mouse-selection control lives in selection.py (file-ceiling
|
|
74
|
+
# headroom) — a factory closed over this pane, not a shared class.
|
|
75
|
+
from webbee.selection import make_select_control
|
|
76
|
+
_SelectControl = make_select_control(pane, FormattedTextControl, MouseEventType, MouseButton)
|
|
77
|
+
self.control = _SelectControl(text=self._formatted, focusable=False, show_cursor=False)
|
|
78
|
+
self.window = Window(content=self.control, wrap_lines=False, always_hide_cursor=True)
|
|
79
|
+
|
|
80
|
+
# ---- virtualized render ---------------------------------------------
|
|
81
|
+
def _all_lines(self):
|
|
82
|
+
# Cache keyed on the stream WRITE POSITION (O(1)); a hit returns the
|
|
83
|
+
# list as-is, a miss reads only the DELTA and extends it IN PLACE —
|
|
84
|
+
# never a full getvalue()+re-split (that was O(session) per print).
|
|
85
|
+
pos = self._io.tell()
|
|
86
|
+
cpos, lines = self._lines_cache
|
|
87
|
+
if cpos == pos:
|
|
88
|
+
return lines
|
|
89
|
+
if isinstance(cpos, int) and 0 <= cpos < pos:
|
|
90
|
+
self._io.seek(cpos)
|
|
91
|
+
delta = self._io.read() # leaves position at EOF (== pos)
|
|
92
|
+
parts = delta.split("\n")
|
|
93
|
+
lines[-1] += parts[0]
|
|
94
|
+
lines.extend(parts[1:])
|
|
95
|
+
else:
|
|
96
|
+
lines = self._io.getvalue().split("\n")
|
|
97
|
+
self._lines_cache = (pos, lines)
|
|
98
|
+
return lines
|
|
99
|
+
|
|
100
|
+
def _plain_lines(self):
|
|
101
|
+
import re
|
|
102
|
+
pos = self._io.tell()
|
|
103
|
+
cpos, lines = self._plain_cache
|
|
104
|
+
if cpos == pos:
|
|
105
|
+
return lines
|
|
106
|
+
if isinstance(cpos, int) and 0 <= cpos < pos:
|
|
107
|
+
self._io.seek(cpos)
|
|
108
|
+
delta = re.sub(r"\x1b\[[0-9;]*m", "", self._io.read())
|
|
109
|
+
parts = delta.split("\n")
|
|
110
|
+
lines[-1] += parts[0]
|
|
111
|
+
lines.extend(parts[1:])
|
|
112
|
+
else:
|
|
113
|
+
lines = re.sub(r"\x1b\[[0-9;]*m", "", self._io.getvalue()).split("\n")
|
|
114
|
+
self._plain_cache = (pos, lines)
|
|
115
|
+
return lines
|
|
116
|
+
|
|
117
|
+
def _norm_sel(self):
|
|
118
|
+
(a, b) = self._sel
|
|
119
|
+
return (a, b) if a <= b else (b, a)
|
|
120
|
+
|
|
121
|
+
def _formatted(self):
|
|
122
|
+
ri = self.window.render_info
|
|
123
|
+
if ri is not None and ri.window_height:
|
|
124
|
+
self._view_h = ri.window_height
|
|
125
|
+
lines = self._all_lines()
|
|
126
|
+
h = max(1, self._view_h)
|
|
127
|
+
off = max(0, min(self._offset, max(0, len(lines) - h)))
|
|
128
|
+
self._offset = off
|
|
129
|
+
visible = lines[off:off + h]
|
|
130
|
+
if self._sel is None:
|
|
131
|
+
return self._ANSI("\n".join(visible))
|
|
132
|
+
# A drag is in progress — overlay reverse-video on the selected columns.
|
|
133
|
+
(y1, x1), (y2, x2) = self._norm_sel()
|
|
134
|
+
plain = self._plain_lines()
|
|
135
|
+
out = []
|
|
136
|
+
for vi, aln in enumerate(range(off, off + len(visible))):
|
|
137
|
+
if y1 <= aln <= y2 and aln < len(plain):
|
|
138
|
+
ln = plain[aln]
|
|
139
|
+
a = max(0, min(x1 if aln == y1 else 0, len(ln)))
|
|
140
|
+
b = max(-1, min(x2 if aln == y2 else len(ln) - 1, len(ln) - 1))
|
|
141
|
+
out.append(ln[:a] + "\x1b[7m" + ln[a:b + 1] + "\x1b[0m" + ln[b + 1:]
|
|
142
|
+
if b >= a else ln)
|
|
143
|
+
else:
|
|
144
|
+
out.append(visible[vi])
|
|
145
|
+
return self._ANSI("\n".join(out))
|
|
146
|
+
|
|
147
|
+
def scroll(self, delta: int) -> None:
|
|
148
|
+
lines = self._all_lines()
|
|
149
|
+
max_off = max(0, len(lines) - max(1, self._view_h))
|
|
150
|
+
self._offset = max(0, min(self._offset + delta, max_off))
|
|
151
|
+
self._follow = self._offset >= max_off # re-arm tail-follow once back at bottom
|
|
152
|
+
self._invalidate()
|
|
153
|
+
|
|
154
|
+
def edge_tick(self) -> None:
|
|
155
|
+
"""Called by tui's `_ticker` every 0.25s: while the pointer sits at a
|
|
156
|
+
viewport edge during a drag, keep scrolling and keep growing the
|
|
157
|
+
selection — no MOUSE_MOVE arrives while the mouse is stationary, so
|
|
158
|
+
without this the drag would freeze at the edge instead of eating
|
|
159
|
+
past the screen. No-op unless a drag is armed AND still active.
|
|
160
|
+
|
|
161
|
+
Runaway guard: `_edge_ticks` counts consecutive ticks since the last
|
|
162
|
+
fresh drag MOUSE_MOVE (reset there, in selection.py) — a pointer
|
|
163
|
+
genuinely parked at the edge for ~10s (40 ticks) stops the auto-scroll
|
|
164
|
+
(the selection itself STAYS armed; a MOUSE_DOWN or forward_mouse
|
|
165
|
+
hygiene reset is what actually clears it) rather than eating the
|
|
166
|
+
whole scrollback hands-free forever."""
|
|
167
|
+
if self._edge_drag == 0 or self.control._down_abs is None:
|
|
168
|
+
return
|
|
169
|
+
self._edge_ticks += 1
|
|
170
|
+
if self._edge_ticks > 40:
|
|
171
|
+
self._edge_drag = 0
|
|
172
|
+
return
|
|
173
|
+
self.scroll(3 * self._edge_drag)
|
|
174
|
+
start, end = self._sel
|
|
175
|
+
end_x = end[1] # keep the column from the current _sel end
|
|
176
|
+
row = self._offset + self._view_h - 1 if self._edge_drag > 0 else self._offset
|
|
177
|
+
self._sel = (start, (row, end_x))
|
|
178
|
+
self._invalidate()
|
|
179
|
+
|
|
180
|
+
def notify(self) -> None:
|
|
181
|
+
"""After each sink print: follow the tail unless the user scrolled up."""
|
|
182
|
+
self._trim()
|
|
183
|
+
lines = self._all_lines()
|
|
184
|
+
max_off = max(0, len(lines) - max(1, self._view_h))
|
|
185
|
+
self._offset = max_off if self._follow else min(self._offset, max_off)
|
|
186
|
+
self._invalidate()
|
|
187
|
+
|
|
188
|
+
def _record_at_line(self, line: int) -> int:
|
|
189
|
+
"""Record owning absolute content line `line` (reflow.py prefix sum
|
|
190
|
+
over the RING's own spans — `line` is offset by `_ring_base_lines`
|
|
191
|
+
first, since the ring's record 0 starts only after the base)."""
|
|
192
|
+
from webbee.reflow import record_at_line
|
|
193
|
+
return record_at_line(self._record_lines, max(0, line - self._ring_base_lines))
|
|
194
|
+
|
|
195
|
+
def reflow(self, new_width: int) -> None:
|
|
196
|
+
"""Replay the retained ring at a new width (true reflow — old ANSI
|
|
197
|
+
can't re-wrap), anchored by RECORD not line index: a re-wrap changes
|
|
198
|
+
how many lines a record spans, so only the record stays stable.
|
|
199
|
+
Lines BEFORE the ring's first record (`_ring_base_lines` — deque
|
|
200
|
+
eviction + trims) are never in the ring, so they can't be replayed:
|
|
201
|
+
they're carried over VERBATIM at their OLD (already-baked) width —
|
|
202
|
+
the one concession this makes — while the ring itself genuinely
|
|
203
|
+
re-wraps at `new_width`."""
|
|
204
|
+
from rich.console import Console
|
|
205
|
+
from webbee.reflow import anchor_offset
|
|
206
|
+
if new_width == self.console.width or new_width < 10:
|
|
207
|
+
return
|
|
208
|
+
follow = self._follow
|
|
209
|
+
base = self._ring_base_lines
|
|
210
|
+
old_offset = self._offset
|
|
211
|
+
pre_ring = (not follow) and old_offset < base # top-visible line was pre-ring content
|
|
212
|
+
top_record = 0 if follow else self._record_at_line(old_offset)
|
|
213
|
+
pre = self._all_lines()[:base] if base else [] # baked ANSI at the OLD width
|
|
214
|
+
self._sel = None # a mid-drag resize aborts the drag honestly
|
|
215
|
+
self._edge_drag = 0
|
|
216
|
+
self.control._down = None
|
|
217
|
+
self.control._down_abs = None
|
|
218
|
+
self.console.width = new_width
|
|
219
|
+
self._reset_buffer()
|
|
220
|
+
if pre:
|
|
221
|
+
self._io.write("\n".join(pre) + "\n") # pre-ring scrollback survives, unwrapped
|
|
222
|
+
self._ring_base_lines = base # the COUNT never changes, only the ring re-wraps
|
|
223
|
+
spans: list = []
|
|
224
|
+
self._replaying = True
|
|
225
|
+
try:
|
|
226
|
+
for objects, kw in list(self._records):
|
|
227
|
+
before = len(self._all_lines())
|
|
228
|
+
Console.print(self.console, *objects, **kw) # bypass the recording override
|
|
229
|
+
spans.append(len(self._all_lines()) - before)
|
|
230
|
+
finally:
|
|
231
|
+
self._replaying = False
|
|
232
|
+
self._record_lines = spans
|
|
233
|
+
max_off = max(0, len(self._all_lines()) - max(1, self._view_h))
|
|
234
|
+
if pre_ring:
|
|
235
|
+
self._offset = min(old_offset, max_off) # pre-ring lines don't re-wrap — no anchor math
|
|
236
|
+
else:
|
|
237
|
+
self._offset = anchor_offset(spans, top_record, max_off, follow, base)
|
|
238
|
+
self._follow = follow and self._offset >= max_off
|
|
239
|
+
self._invalidate()
|
|
240
|
+
|
|
241
|
+
def _invalidate(self) -> None:
|
|
242
|
+
try:
|
|
243
|
+
from prompt_toolkit.application import get_app_or_none
|
|
244
|
+
app = get_app_or_none()
|
|
245
|
+
if app is not None:
|
|
246
|
+
app.invalidate()
|
|
247
|
+
except Exception:
|
|
248
|
+
pass
|
|
249
|
+
|
|
250
|
+
def _trim(self, max_lines: int = 20000, keep: int = 15000) -> None:
|
|
251
|
+
# Hysteresis: only trim past max_lines, cut down to ~`keep`
|
|
252
|
+
# (amortized) — but NEVER split a ring record. The cut consumes the
|
|
253
|
+
# BASE (pre-ring content — already unreplayable, safe to drop
|
|
254
|
+
# outright) first, then only WHOLE leading ring records; the actual
|
|
255
|
+
# cut moves UP to the nearest record boundary, so a post-trim reflow
|
|
256
|
+
# can never resurrect a partially-trimmed record from the ring.
|
|
257
|
+
import io
|
|
258
|
+
lines = self._all_lines()
|
|
259
|
+
if len(lines) <= max_lines:
|
|
260
|
+
return
|
|
261
|
+
dropped = len(lines) - keep
|
|
262
|
+
base = self._ring_base_lines
|
|
263
|
+
take = min(base, dropped) # consume the base first
|
|
264
|
+
from webbee.reflow import records_to_drop
|
|
265
|
+
n_drop, lines_covered = records_to_drop(self._record_lines, dropped - take)
|
|
266
|
+
actual_dropped = take + lines_covered # the REAL cut — may be < dropped
|
|
267
|
+
s = "\n".join(lines[actual_dropped:])
|
|
268
|
+
self._io = io.StringIO()
|
|
269
|
+
self._io.write(s)
|
|
270
|
+
self.console.file = self._io
|
|
271
|
+
self._lines_cache = (0, [""])
|
|
272
|
+
self._plain_cache = (0, [""])
|
|
273
|
+
self._ring_base_lines = base - take
|
|
274
|
+
self._offset = max(0, self._offset - actual_dropped) # viewport anchored to the same content
|
|
275
|
+
if self.control._down_abs is not None: # an armed drag stays on the same CONTENT
|
|
276
|
+
y, x = self.control._down_abs
|
|
277
|
+
self.control._down_abs = (max(0, y - actual_dropped), x)
|
|
278
|
+
if self._sel is not None:
|
|
279
|
+
(y1, x1), (y2, x2) = self._sel
|
|
280
|
+
self._sel = ((max(0, y1 - actual_dropped), x1), (max(0, y2 - actual_dropped), x2))
|
|
281
|
+
for _ in range(n_drop):
|
|
282
|
+
self._records.popleft()
|
|
283
|
+
del self._record_lines[:n_drop]
|
|
284
|
+
|
|
285
|
+
def _reset_buffer(self) -> None:
|
|
286
|
+
"""`/clear` and `reflow()` both wipe the transcript to empty — the
|
|
287
|
+
pane owns the alt screen, so an emptied buffer IS the cleared state.
|
|
288
|
+
`reflow()` rebuilds `_record_lines` (and restores `_ring_base_lines`
|
|
289
|
+
— the COUNT survives a reflow, only `/clear` truly zeroes it) right
|
|
290
|
+
after calling this."""
|
|
291
|
+
import io
|
|
292
|
+
self._io = io.StringIO()
|
|
293
|
+
self.console.file = self._io
|
|
294
|
+
self._lines_cache = (0, [""])
|
|
295
|
+
self._plain_cache = (0, [""])
|
|
296
|
+
self._record_lines = []
|
|
297
|
+
self._ring_base_lines = 0
|
|
298
|
+
self._offset = 0
|
|
299
|
+
|
|
300
|
+
def dump(self) -> str:
|
|
301
|
+
"""Full session transcript (ANSI), printed to real stdout on exit."""
|
|
302
|
+
return self._io.getvalue()
|
|
303
|
+
|
|
304
|
+
# ---- copy-on-select --------------------------------------------------
|
|
305
|
+
def _selected_text(self, start, end) -> str:
|
|
306
|
+
"""Plain text (ANSI stripped) covered by a drag. start/end are
|
|
307
|
+
ABSOLUTE (line, col) pairs already resolved by the caller — no offset conversion happens here."""
|
|
308
|
+
lines = self._plain_lines()
|
|
309
|
+
p1, p2 = (start, end) if start <= end else (end, start)
|
|
310
|
+
(y1, x1), (y2, x2) = p1, p2
|
|
311
|
+
n = len(lines)
|
|
312
|
+
if not (0 <= y1 < n):
|
|
313
|
+
return ""
|
|
314
|
+
y2 = min(y2, n - 1)
|
|
315
|
+
if y1 == y2:
|
|
316
|
+
return lines[y1][x1:x2 + 1]
|
|
317
|
+
out = [lines[y1][x1:]]
|
|
318
|
+
out.extend(lines[y1 + 1:y2])
|
|
319
|
+
out.append(lines[y2][:x2 + 1])
|
|
320
|
+
return "\n".join(out)
|
|
321
|
+
|
|
322
|
+
def _copy_selection(self, start_abs, end_abs) -> None:
|
|
323
|
+
# start_abs/end_abs: ABSOLUTE (line, col) pairs — see _selected_text.
|
|
324
|
+
import time as _t
|
|
325
|
+
from webbee.clipboard import copy_to_clipboard
|
|
326
|
+
text = self._selected_text(start_abs, end_abs)
|
|
327
|
+
if not text.strip():
|
|
328
|
+
return
|
|
329
|
+
self.copy_flash = copy_to_clipboard(text) # local clipboard first, OSC 52 fallback
|
|
330
|
+
self._flash_until = _t.monotonic() + 1.8
|
|
331
|
+
self.notify()
|
|
332
|
+
|
|
333
|
+
def flash(self) -> str:
|
|
334
|
+
"""The transient 'copied' note, while still fresh (else empty)."""
|
|
335
|
+
import time as _t
|
|
336
|
+
return self.copy_flash if _t.monotonic() < self._flash_until else ""
|
|
337
|
+
|
|
338
|
+
# ---- W2 Task 8: selection capture past the pane's own Window --------
|
|
339
|
+
def forward_mouse(self, ev) -> bool:
|
|
340
|
+
"""Public seam neighbor windows call FIRST (via `tui._forwarding` /
|
|
341
|
+
the queue+todo panels' `forward=` param) so a drag armed inside this
|
|
342
|
+
pane can still be extended/completed once the pointer — and thus
|
|
343
|
+
prompt_toolkit's per-position mouse routing — has moved onto them.
|
|
344
|
+
True = consumed (a drag was armed); False = untouched, caller falls
|
|
345
|
+
through to its own handling. Full behavior in selection.forward_mouse."""
|
|
346
|
+
from webbee.selection import forward_mouse as _forward_mouse
|
|
347
|
+
return _forward_mouse(self, ev)
|