webbee 0.3.17__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.17 → webbee-0.3.18}/CHANGELOG.md +14 -0
- {webbee-0.3.17 → webbee-0.3.18}/PKG-INFO +1 -1
- {webbee-0.3.17 → webbee-0.3.18}/pyproject.toml +1 -1
- webbee-0.3.18/src/webbee/__init__.py +1 -0
- webbee-0.3.18/src/webbee/output_pane.py +347 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/queue_panel.py +41 -18
- webbee-0.3.18/src/webbee/reflow.py +53 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/render.py +37 -24
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/repl.py +2 -3
- webbee-0.3.18/src/webbee/selection.py +155 -0
- webbee-0.3.18/src/webbee/sizing.py +40 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/todo_panel.py +23 -14
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/tui.py +139 -34
- webbee-0.3.18/tests/test_reflow.py +85 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_render.py +28 -0
- webbee-0.3.18/tests/test_sizing.py +30 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_todo_panel.py +52 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_tui.py +1187 -10
- webbee-0.3.17/src/webbee/__init__.py +0 -1
- webbee-0.3.17/src/webbee/output_pane.py +0 -238
- {webbee-0.3.17 → webbee-0.3.18}/.github/workflows/publish.yml +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/.gitignore +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/LICENSE +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/README.md +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/install.sh +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/account.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/banner_art.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/boot.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/checkpoints.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/cli.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/clipboard.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/coding_context.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/commands.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/config.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/consent.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/details.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/events.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/frames.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/http.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/intel/__init__.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/intel/chunker.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/intel/embed.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/intel/graph.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/intel/indexer.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/intel/models.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/intel/query.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/intel/service.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/intel/store.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/intel/vectors.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/intel/watch.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/remote.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/repo.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/session.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/sessions.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/steer.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/stream.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/thread.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/tokens.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/tools.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/src/webbee/update.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/__init__.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_account.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_checkpoints.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_cli.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_clipboard.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_commands.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_config.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_cpc_contract_stable.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_details.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_events.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_freeze_fix.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_intel_chunker.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_intel_embed.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_intel_graph.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_intel_indexer.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_intel_no_numpy.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_intel_query.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_intel_service.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_intel_store.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_intel_vectors.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_intel_watch.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_marathon.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_packaging.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_repl.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_repo.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_session.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_sessions.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_steer.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_stream.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_thread.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_tokens.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_tools.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_tui_hardening.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_update.py +0 -0
- {webbee-0.3.17 → webbee-0.3.18}/tests/test_version.py +0 -0
|
@@ -1,5 +1,19 @@
|
|
|
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
|
+
|
|
3
17
|
## 0.3.17
|
|
4
18
|
|
|
5
19
|
Bulletproof core (W1): a marathon can no longer be killed by transport, the
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.18"
|
|
@@ -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)
|
|
@@ -24,15 +24,17 @@ def one_line(text: str, width: int) -> str:
|
|
|
24
24
|
return t
|
|
25
25
|
|
|
26
26
|
|
|
27
|
-
def queue_height(pending, remote=None, collapsed=False) -> int:
|
|
27
|
+
def queue_height(pending, remote=None, collapsed=False, max_items=QP_MAX_ITEMS) -> int:
|
|
28
28
|
"""PURE. Rows the panel needs: 1 header + one per SHOWN item + one
|
|
29
|
-
`… +K more` row when a queue is deeper than
|
|
29
|
+
`… +K more` row when a queue is deeper than `max_items` (each of the
|
|
30
30
|
two sections — remote rows and local rows — caps independently). 0 when
|
|
31
31
|
both are empty (the ConditionalContainer hides the panel then anyway).
|
|
32
32
|
`collapsed=True` (Task 11 click-to-collapse) always costs exactly 1 row
|
|
33
|
-
when there's data — screen space back on demand.
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
when there's data — screen space back on demand. `max_items` defaults to
|
|
34
|
+
QP_MAX_ITEMS but the dock overrides it with a LIVE, terminal-proportional
|
|
35
|
+
cap (sizing.panel_cap) so a huge screen shows more and a tiny one shows
|
|
36
|
+
less — the toolbar's `⋯N queued` segment stays the truth-teller for the
|
|
37
|
+
full depth either way."""
|
|
36
38
|
n = len(pending)
|
|
37
39
|
r = len(remote or ())
|
|
38
40
|
if not n and not r:
|
|
@@ -41,9 +43,9 @@ def queue_height(pending, remote=None, collapsed=False) -> int:
|
|
|
41
43
|
return 1
|
|
42
44
|
rows = 1
|
|
43
45
|
if r:
|
|
44
|
-
rows += min(r,
|
|
46
|
+
rows += min(r, max_items) + (1 if r > max_items else 0)
|
|
45
47
|
if n:
|
|
46
|
-
rows += min(n,
|
|
48
|
+
rows += min(n, max_items) + (1 if n > max_items else 0)
|
|
47
49
|
return rows
|
|
48
50
|
|
|
49
51
|
|
|
@@ -66,13 +68,22 @@ def pull_item(pending, buf, index: int):
|
|
|
66
68
|
return item
|
|
67
69
|
|
|
68
70
|
|
|
69
|
-
def _item_handler(pull, index: int):
|
|
71
|
+
def _item_handler(pull, index: int, forward=None):
|
|
70
72
|
"""One row's mouse handler: MOUSE_UP (a click, not a drag/press) pulls
|
|
71
73
|
THAT queued item into the input via `pull(index)`; every other event
|
|
72
74
|
falls through (NotImplemented) so wheel scroll etc. keep today's
|
|
73
|
-
behavior. Mirrors OutputPane._SelectControl's event discipline.
|
|
75
|
+
behavior. Mirrors OutputPane._SelectControl's event discipline.
|
|
76
|
+
|
|
77
|
+
`forward` (W2 Task 8, `OutputPane.forward_mouse`) gets FIRST refusal
|
|
78
|
+
when given: prompt_toolkit routes mouse events by pointer position, so a
|
|
79
|
+
drag armed on the pane above can end up releasing on this row — a real
|
|
80
|
+
click and a forwarded drag-release are indistinguishable to THIS row
|
|
81
|
+
except by asking the pane. Consumed (a drag was armed) ⇒ stop here,
|
|
82
|
+
pull is NOT called — the row must not also register as a click."""
|
|
74
83
|
def _h(mouse_event):
|
|
75
84
|
from prompt_toolkit.mouse_events import MouseEventType
|
|
85
|
+
if forward is not None and forward(mouse_event):
|
|
86
|
+
return None
|
|
76
87
|
if mouse_event.event_type == MouseEventType.MOUSE_UP:
|
|
77
88
|
pull(index)
|
|
78
89
|
return None
|
|
@@ -80,12 +91,15 @@ def _item_handler(pull, index: int):
|
|
|
80
91
|
return _h
|
|
81
92
|
|
|
82
93
|
|
|
83
|
-
def _toggle_handler(toggle):
|
|
94
|
+
def _toggle_handler(toggle, forward=None):
|
|
84
95
|
"""Header mouse handler (Task 11 click-to-collapse): MOUSE_UP toggles
|
|
85
96
|
collapse; everything else falls through (NotImplemented) so wheel scroll
|
|
86
|
-
keeps working — the exact event discipline of _item_handler.
|
|
97
|
+
keeps working — the exact event discipline of _item_handler. `forward`
|
|
98
|
+
(W2 Task 8) is the same first-refusal seam as _item_handler's."""
|
|
87
99
|
def _h(mouse_event):
|
|
88
100
|
from prompt_toolkit.mouse_events import MouseEventType
|
|
101
|
+
if forward is not None and forward(mouse_event):
|
|
102
|
+
return None
|
|
89
103
|
if mouse_event.event_type == MouseEventType.MOUSE_UP:
|
|
90
104
|
toggle()
|
|
91
105
|
return None
|
|
@@ -94,7 +108,8 @@ def _toggle_handler(toggle):
|
|
|
94
108
|
|
|
95
109
|
|
|
96
110
|
def queue_fragments(pending, pull=None, width: int = 0, remote=None,
|
|
97
|
-
collapsed=False, toggle=None
|
|
111
|
+
collapsed=False, toggle=None, max_items=QP_MAX_ITEMS,
|
|
112
|
+
forward=None):
|
|
98
113
|
"""PURE builder: the panel as prompt_toolkit formatted text, re-invoked
|
|
99
114
|
every redraw (same live mechanics as the toolbar) so every queue
|
|
100
115
|
add/edit/drain shows at once. Layout, top→bottom = drain order (FIFO —
|
|
@@ -103,7 +118,9 @@ def queue_fragments(pending, pull=None, width: int = 0, remote=None,
|
|
|
103
118
|
|
|
104
119
|
⋯ queued (N) · ↑ edit last · click to edit
|
|
105
120
|
[telegram] remote item ← remote rows ABOVE local (qp.remote)
|
|
106
|
-
… +K more ← only when N >
|
|
121
|
+
… +K more ← only when N > max_items (the OLDEST hide;
|
|
122
|
+
defaults to QP_MAX_ITEMS, overridden by the
|
|
123
|
+
dock with a live sizing.panel_cap(rows))
|
|
107
124
|
older item ← muted (qp.item)
|
|
108
125
|
newest item ← accent (qp.last)
|
|
109
126
|
|
|
@@ -122,7 +139,13 @@ def queue_fragments(pending, pull=None, width: int = 0, remote=None,
|
|
|
122
139
|
`collapsed` (Task 11 click-to-collapse) folds the whole panel down to
|
|
123
140
|
ONE header row ending `▸` (screen space back on demand); `▾` when
|
|
124
141
|
expanded. Both only render when `toggle` is given — the header then
|
|
125
|
-
carries a 3-tuple MOUSE_UP handler (see _toggle_handler) that flips it.
|
|
142
|
+
carries a 3-tuple MOUSE_UP handler (see _toggle_handler) that flips it.
|
|
143
|
+
|
|
144
|
+
`forward` (W2 Task 8) is forwarded into every _item_handler/_toggle_handler
|
|
145
|
+
built here — see their docstrings; it is the pane's
|
|
146
|
+
`OutputPane.forward_mouse`, given first refusal on every row/header click
|
|
147
|
+
so a drag armed on the pane above can still be extended/completed once
|
|
148
|
+
it releases on this panel."""
|
|
126
149
|
items = list(pending)
|
|
127
150
|
rem = [r for r in (remote or ()) if isinstance(r, dict)]
|
|
128
151
|
n = len(items)
|
|
@@ -131,13 +154,13 @@ def queue_fragments(pending, pull=None, width: int = 0, remote=None,
|
|
|
131
154
|
marker = "" if toggle is None else (" ▸" if collapsed else " ▾")
|
|
132
155
|
header = ("class:qp.header", f" ⋯ queued ({n + len(rem)}){marker}")
|
|
133
156
|
if toggle is not None:
|
|
134
|
-
header = header + (_toggle_handler(toggle),)
|
|
157
|
+
header = header + (_toggle_handler(toggle, forward),)
|
|
135
158
|
frags = [header]
|
|
136
159
|
if collapsed:
|
|
137
160
|
return frags
|
|
138
161
|
if n:
|
|
139
162
|
frags.append(("class:qp.item", " · ↑ edit last · click to edit"))
|
|
140
|
-
rstart = max(0, len(rem) -
|
|
163
|
+
rstart = max(0, len(rem) - max_items)
|
|
141
164
|
if rstart:
|
|
142
165
|
frags.append(("class:qp.remote", f"\n … +{rstart} more"))
|
|
143
166
|
for r in rem[rstart:]:
|
|
@@ -149,7 +172,7 @@ def queue_fragments(pending, pull=None, width: int = 0, remote=None,
|
|
|
149
172
|
row = "\n " + one_line(f"{mark}[{origin}] {r.get('text') or ''}",
|
|
150
173
|
width - 4 if width > 0 else 0)
|
|
151
174
|
frags.append(("class:qp.remote", row))
|
|
152
|
-
start = max(0, n -
|
|
175
|
+
start = max(0, n - max_items)
|
|
153
176
|
if start:
|
|
154
177
|
frags.append(("class:qp.item", f"\n … +{start} more"))
|
|
155
178
|
for i in range(start, n):
|
|
@@ -158,5 +181,5 @@ def queue_fragments(pending, pull=None, width: int = 0, remote=None,
|
|
|
158
181
|
if pull is None:
|
|
159
182
|
frags.append((style, row))
|
|
160
183
|
else:
|
|
161
|
-
frags.append((style, row, _item_handler(pull, i)))
|
|
184
|
+
frags.append((style, row, _item_handler(pull, i, forward)))
|
|
162
185
|
return frags
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Pure prefix-sum math behind OutputPane.reflow (W2 front-2: true width
|
|
2
|
+
reflow). A terminal-width change re-wraps every retained record, which
|
|
3
|
+
shuffles absolute line indices around — the only stable anchor across a
|
|
4
|
+
re-wrap is which RECORD produced the top-visible line, not the line index
|
|
5
|
+
itself. Kept as free functions (no OutputPane/Rich/prompt_toolkit import) so
|
|
6
|
+
the anchoring math is unit-testable without constructing a real pane."""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def record_at_line(record_lines: list[int], line: int) -> int:
|
|
11
|
+
"""Record index whose lines cover absolute content line `line`, via a
|
|
12
|
+
prefix sum over per-record line counts. Falls back to the last record
|
|
13
|
+
(or 0 for an empty ring) once `line` runs past every recorded span —
|
|
14
|
+
covers both a stale/out-of-range offset and the trailing placeholder
|
|
15
|
+
line that follows the final recorded newline."""
|
|
16
|
+
acc = 0
|
|
17
|
+
for idx, n in enumerate(record_lines):
|
|
18
|
+
acc += n
|
|
19
|
+
if line < acc:
|
|
20
|
+
return idx
|
|
21
|
+
return max(0, len(record_lines) - 1)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def anchor_offset(record_lines: list[int], top_record: int, max_off: int, follow: bool,
|
|
25
|
+
base: int = 0) -> int:
|
|
26
|
+
"""New `_offset` after a reflow. Tail-follow snaps straight to `max_off`
|
|
27
|
+
(line indices are meaningless post-rewrap, so there's nothing to anchor
|
|
28
|
+
to). Otherwise the anchor is the NEW start line of `top_record` — `base`
|
|
29
|
+
(the buffer lines preceding the ring's first record, unaffected by a
|
|
30
|
+
reflow replay) plus the sum of every earlier RING record's span —
|
|
31
|
+
clamped so it never overruns the freshly re-wrapped content. Only valid
|
|
32
|
+
when the top-visible line was inside the ring (`_offset >= base`); the
|
|
33
|
+
pre-ring case is the caller's own no-op branch (those lines never
|
|
34
|
+
re-wrap, so their line index never changes)."""
|
|
35
|
+
if follow:
|
|
36
|
+
return max_off
|
|
37
|
+
return min(base + sum(record_lines[:top_record]), max_off)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def records_to_drop(record_lines: list[int], dropped: int) -> tuple[int, int]:
|
|
41
|
+
"""How many WHOLE records from the front fit entirely inside the first
|
|
42
|
+
`dropped` lines of a trim, and how many lines they cover — the boundary
|
|
43
|
+
record (partial overlap) keeps its full span rather than being split, so
|
|
44
|
+
the caller's actual cut moves UP to the nearest record boundary instead
|
|
45
|
+
of slicing into one. Returns (n_records, lines_covered)."""
|
|
46
|
+
acc = 0
|
|
47
|
+
n_drop = 0
|
|
48
|
+
for n in record_lines:
|
|
49
|
+
if acc + n > dropped:
|
|
50
|
+
break
|
|
51
|
+
acc += n
|
|
52
|
+
n_drop += 1
|
|
53
|
+
return n_drop, acc
|