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.
Files changed (95) hide show
  1. {webbee-0.3.17 → webbee-0.3.18}/CHANGELOG.md +14 -0
  2. {webbee-0.3.17 → webbee-0.3.18}/PKG-INFO +1 -1
  3. {webbee-0.3.17 → webbee-0.3.18}/pyproject.toml +1 -1
  4. webbee-0.3.18/src/webbee/__init__.py +1 -0
  5. webbee-0.3.18/src/webbee/output_pane.py +347 -0
  6. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/queue_panel.py +41 -18
  7. webbee-0.3.18/src/webbee/reflow.py +53 -0
  8. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/render.py +37 -24
  9. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/repl.py +2 -3
  10. webbee-0.3.18/src/webbee/selection.py +155 -0
  11. webbee-0.3.18/src/webbee/sizing.py +40 -0
  12. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/todo_panel.py +23 -14
  13. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/tui.py +139 -34
  14. webbee-0.3.18/tests/test_reflow.py +85 -0
  15. {webbee-0.3.17 → webbee-0.3.18}/tests/test_render.py +28 -0
  16. webbee-0.3.18/tests/test_sizing.py +30 -0
  17. {webbee-0.3.17 → webbee-0.3.18}/tests/test_todo_panel.py +52 -0
  18. {webbee-0.3.17 → webbee-0.3.18}/tests/test_tui.py +1187 -10
  19. webbee-0.3.17/src/webbee/__init__.py +0 -1
  20. webbee-0.3.17/src/webbee/output_pane.py +0 -238
  21. {webbee-0.3.17 → webbee-0.3.18}/.github/workflows/publish.yml +0 -0
  22. {webbee-0.3.17 → webbee-0.3.18}/.gitignore +0 -0
  23. {webbee-0.3.17 → webbee-0.3.18}/LICENSE +0 -0
  24. {webbee-0.3.17 → webbee-0.3.18}/README.md +0 -0
  25. {webbee-0.3.17 → webbee-0.3.18}/install.sh +0 -0
  26. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/account.py +0 -0
  27. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/banner_art.py +0 -0
  28. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/boot.py +0 -0
  29. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/checkpoints.py +0 -0
  30. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/cli.py +0 -0
  31. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/clipboard.py +0 -0
  32. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/coding_context.py +0 -0
  33. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/commands.py +0 -0
  34. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/config.py +0 -0
  35. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/consent.py +0 -0
  36. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/details.py +0 -0
  37. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/events.py +0 -0
  38. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/frames.py +0 -0
  39. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/http.py +0 -0
  40. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/intel/__init__.py +0 -0
  41. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/intel/chunker.py +0 -0
  42. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/intel/embed.py +0 -0
  43. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/intel/graph.py +0 -0
  44. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/intel/indexer.py +0 -0
  45. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/intel/models.py +0 -0
  46. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/intel/query.py +0 -0
  47. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/intel/service.py +0 -0
  48. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/intel/store.py +0 -0
  49. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/intel/vectors.py +0 -0
  50. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/intel/watch.py +0 -0
  51. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/remote.py +0 -0
  52. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/repo.py +0 -0
  53. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/session.py +0 -0
  54. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/sessions.py +0 -0
  55. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/steer.py +0 -0
  56. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/stream.py +0 -0
  57. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/thread.py +0 -0
  58. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/tokens.py +0 -0
  59. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/tools.py +0 -0
  60. {webbee-0.3.17 → webbee-0.3.18}/src/webbee/update.py +0 -0
  61. {webbee-0.3.17 → webbee-0.3.18}/tests/__init__.py +0 -0
  62. {webbee-0.3.17 → webbee-0.3.18}/tests/test_account.py +0 -0
  63. {webbee-0.3.17 → webbee-0.3.18}/tests/test_checkpoints.py +0 -0
  64. {webbee-0.3.17 → webbee-0.3.18}/tests/test_cli.py +0 -0
  65. {webbee-0.3.17 → webbee-0.3.18}/tests/test_clipboard.py +0 -0
  66. {webbee-0.3.17 → webbee-0.3.18}/tests/test_commands.py +0 -0
  67. {webbee-0.3.17 → webbee-0.3.18}/tests/test_config.py +0 -0
  68. {webbee-0.3.17 → webbee-0.3.18}/tests/test_cpc_contract_stable.py +0 -0
  69. {webbee-0.3.17 → webbee-0.3.18}/tests/test_details.py +0 -0
  70. {webbee-0.3.17 → webbee-0.3.18}/tests/test_events.py +0 -0
  71. {webbee-0.3.17 → webbee-0.3.18}/tests/test_freeze_fix.py +0 -0
  72. {webbee-0.3.17 → webbee-0.3.18}/tests/test_intel_chunker.py +0 -0
  73. {webbee-0.3.17 → webbee-0.3.18}/tests/test_intel_embed.py +0 -0
  74. {webbee-0.3.17 → webbee-0.3.18}/tests/test_intel_graph.py +0 -0
  75. {webbee-0.3.17 → webbee-0.3.18}/tests/test_intel_indexer.py +0 -0
  76. {webbee-0.3.17 → webbee-0.3.18}/tests/test_intel_no_numpy.py +0 -0
  77. {webbee-0.3.17 → webbee-0.3.18}/tests/test_intel_query.py +0 -0
  78. {webbee-0.3.17 → webbee-0.3.18}/tests/test_intel_service.py +0 -0
  79. {webbee-0.3.17 → webbee-0.3.18}/tests/test_intel_store.py +0 -0
  80. {webbee-0.3.17 → webbee-0.3.18}/tests/test_intel_vectors.py +0 -0
  81. {webbee-0.3.17 → webbee-0.3.18}/tests/test_intel_watch.py +0 -0
  82. {webbee-0.3.17 → webbee-0.3.18}/tests/test_marathon.py +0 -0
  83. {webbee-0.3.17 → webbee-0.3.18}/tests/test_packaging.py +0 -0
  84. {webbee-0.3.17 → webbee-0.3.18}/tests/test_repl.py +0 -0
  85. {webbee-0.3.17 → webbee-0.3.18}/tests/test_repo.py +0 -0
  86. {webbee-0.3.17 → webbee-0.3.18}/tests/test_session.py +0 -0
  87. {webbee-0.3.17 → webbee-0.3.18}/tests/test_sessions.py +0 -0
  88. {webbee-0.3.17 → webbee-0.3.18}/tests/test_steer.py +0 -0
  89. {webbee-0.3.17 → webbee-0.3.18}/tests/test_stream.py +0 -0
  90. {webbee-0.3.17 → webbee-0.3.18}/tests/test_thread.py +0 -0
  91. {webbee-0.3.17 → webbee-0.3.18}/tests/test_tokens.py +0 -0
  92. {webbee-0.3.17 → webbee-0.3.18}/tests/test_tools.py +0 -0
  93. {webbee-0.3.17 → webbee-0.3.18}/tests/test_tui_hardening.py +0 -0
  94. {webbee-0.3.17 → webbee-0.3.18}/tests/test_update.py +0 -0
  95. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webbee
3
- Version: 0.3.17
3
+ Version: 0.3.18
4
4
  Summary: Webbee 🐝 — the Imperal Cloud coding agent in your terminal
5
5
  Project-URL: Homepage, https://imperal.io
6
6
  Project-URL: Documentation, https://docs.imperal.io
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "webbee"
3
- version = "0.3.17"
3
+ version = "0.3.18"
4
4
  description = "Webbee 🐝 — the Imperal Cloud coding agent in your terminal"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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 QP_MAX_ITEMS (each of the
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. The cap keeps the
34
- output pane dominant on small terminals; the toolbar's `⋯N queued`
35
- segment stays the truth-teller for the full depth."""
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, QP_MAX_ITEMS) + (1 if r > QP_MAX_ITEMS else 0)
46
+ rows += min(r, max_items) + (1 if r > max_items else 0)
45
47
  if n:
46
- rows += min(n, QP_MAX_ITEMS) + (1 if n > QP_MAX_ITEMS else 0)
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 > QP_MAX_ITEMS (the OLDEST hide)
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) - QP_MAX_ITEMS)
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 - QP_MAX_ITEMS)
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