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.
Files changed (99) hide show
  1. {webbee-0.3.16 → webbee-0.3.18}/CHANGELOG.md +48 -0
  2. {webbee-0.3.16 → webbee-0.3.18}/PKG-INFO +1 -1
  3. {webbee-0.3.16 → 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/http.py +11 -0
  6. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/intel/store.py +9 -2
  7. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/intel/vectors.py +9 -0
  8. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/intel/watch.py +9 -1
  9. webbee-0.3.18/src/webbee/output_pane.py +347 -0
  10. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/queue_panel.py +81 -23
  11. webbee-0.3.18/src/webbee/reflow.py +53 -0
  12. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/render.py +93 -30
  13. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/repl.py +86 -20
  14. webbee-0.3.18/src/webbee/selection.py +155 -0
  15. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/session.py +148 -25
  16. webbee-0.3.18/src/webbee/sizing.py +40 -0
  17. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/steer.py +33 -4
  18. webbee-0.3.18/src/webbee/stream.py +115 -0
  19. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/thread.py +36 -22
  20. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/todo_panel.py +35 -13
  21. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/tokens.py +14 -0
  22. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/tools.py +10 -1
  23. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/tui.py +255 -53
  24. {webbee-0.3.16 → webbee-0.3.18}/tests/test_intel_store.py +21 -0
  25. webbee-0.3.18/tests/test_intel_vectors.py +51 -0
  26. {webbee-0.3.16 → webbee-0.3.18}/tests/test_intel_watch.py +12 -0
  27. {webbee-0.3.16 → webbee-0.3.18}/tests/test_marathon.py +5 -2
  28. webbee-0.3.18/tests/test_reflow.py +85 -0
  29. {webbee-0.3.16 → webbee-0.3.18}/tests/test_render.py +144 -0
  30. {webbee-0.3.16 → webbee-0.3.18}/tests/test_repl.py +133 -1
  31. {webbee-0.3.16 → webbee-0.3.18}/tests/test_session.py +437 -15
  32. webbee-0.3.18/tests/test_sizing.py +30 -0
  33. {webbee-0.3.16 → webbee-0.3.18}/tests/test_steer.py +48 -0
  34. webbee-0.3.18/tests/test_stream.py +389 -0
  35. {webbee-0.3.16 → webbee-0.3.18}/tests/test_thread.py +69 -0
  36. {webbee-0.3.16 → webbee-0.3.18}/tests/test_todo_panel.py +76 -0
  37. {webbee-0.3.16 → webbee-0.3.18}/tests/test_tokens.py +30 -0
  38. {webbee-0.3.16 → webbee-0.3.18}/tests/test_tools.py +27 -0
  39. webbee-0.3.18/tests/test_tui.py +2936 -0
  40. webbee-0.3.16/src/webbee/__init__.py +0 -1
  41. webbee-0.3.16/src/webbee/output_pane.py +0 -204
  42. webbee-0.3.16/src/webbee/stream.py +0 -58
  43. webbee-0.3.16/tests/test_intel_vectors.py +0 -26
  44. webbee-0.3.16/tests/test_stream.py +0 -152
  45. webbee-0.3.16/tests/test_tui.py +0 -1397
  46. {webbee-0.3.16 → webbee-0.3.18}/.github/workflows/publish.yml +0 -0
  47. {webbee-0.3.16 → webbee-0.3.18}/.gitignore +0 -0
  48. {webbee-0.3.16 → webbee-0.3.18}/LICENSE +0 -0
  49. {webbee-0.3.16 → webbee-0.3.18}/README.md +0 -0
  50. {webbee-0.3.16 → webbee-0.3.18}/install.sh +0 -0
  51. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/account.py +0 -0
  52. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/banner_art.py +0 -0
  53. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/boot.py +0 -0
  54. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/checkpoints.py +0 -0
  55. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/cli.py +0 -0
  56. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/clipboard.py +0 -0
  57. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/coding_context.py +0 -0
  58. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/commands.py +0 -0
  59. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/config.py +0 -0
  60. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/consent.py +0 -0
  61. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/details.py +0 -0
  62. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/events.py +0 -0
  63. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/frames.py +0 -0
  64. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/intel/__init__.py +0 -0
  65. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/intel/chunker.py +0 -0
  66. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/intel/embed.py +0 -0
  67. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/intel/graph.py +0 -0
  68. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/intel/indexer.py +0 -0
  69. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/intel/models.py +0 -0
  70. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/intel/query.py +0 -0
  71. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/intel/service.py +0 -0
  72. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/remote.py +0 -0
  73. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/repo.py +0 -0
  74. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/sessions.py +0 -0
  75. {webbee-0.3.16 → webbee-0.3.18}/src/webbee/update.py +0 -0
  76. {webbee-0.3.16 → webbee-0.3.18}/tests/__init__.py +0 -0
  77. {webbee-0.3.16 → webbee-0.3.18}/tests/test_account.py +0 -0
  78. {webbee-0.3.16 → webbee-0.3.18}/tests/test_checkpoints.py +0 -0
  79. {webbee-0.3.16 → webbee-0.3.18}/tests/test_cli.py +0 -0
  80. {webbee-0.3.16 → webbee-0.3.18}/tests/test_clipboard.py +0 -0
  81. {webbee-0.3.16 → webbee-0.3.18}/tests/test_commands.py +0 -0
  82. {webbee-0.3.16 → webbee-0.3.18}/tests/test_config.py +0 -0
  83. {webbee-0.3.16 → webbee-0.3.18}/tests/test_cpc_contract_stable.py +0 -0
  84. {webbee-0.3.16 → webbee-0.3.18}/tests/test_details.py +0 -0
  85. {webbee-0.3.16 → webbee-0.3.18}/tests/test_events.py +0 -0
  86. {webbee-0.3.16 → webbee-0.3.18}/tests/test_freeze_fix.py +0 -0
  87. {webbee-0.3.16 → webbee-0.3.18}/tests/test_intel_chunker.py +0 -0
  88. {webbee-0.3.16 → webbee-0.3.18}/tests/test_intel_embed.py +0 -0
  89. {webbee-0.3.16 → webbee-0.3.18}/tests/test_intel_graph.py +0 -0
  90. {webbee-0.3.16 → webbee-0.3.18}/tests/test_intel_indexer.py +0 -0
  91. {webbee-0.3.16 → webbee-0.3.18}/tests/test_intel_no_numpy.py +0 -0
  92. {webbee-0.3.16 → webbee-0.3.18}/tests/test_intel_query.py +0 -0
  93. {webbee-0.3.16 → webbee-0.3.18}/tests/test_intel_service.py +0 -0
  94. {webbee-0.3.16 → webbee-0.3.18}/tests/test_packaging.py +0 -0
  95. {webbee-0.3.16 → webbee-0.3.18}/tests/test_repo.py +0 -0
  96. {webbee-0.3.16 → webbee-0.3.18}/tests/test_sessions.py +0 -0
  97. {webbee-0.3.16 → webbee-0.3.18}/tests/test_tui_hardening.py +0 -0
  98. {webbee-0.3.16 → webbee-0.3.18}/tests/test_update.py +0 -0
  99. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webbee
3
- Version: 0.3.16
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.16"
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,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
- mat = np.load(os.path.join(d, "embeddings.npy"))
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
- return ids, mat.astype(np.float32)
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 "/.git/" in path or "/node_modules/" in path:
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)