webbee 0.3.1__tar.gz → 0.3.3__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 (75) hide show
  1. {webbee-0.3.1 → webbee-0.3.3}/CHANGELOG.md +19 -0
  2. {webbee-0.3.1 → webbee-0.3.3}/PKG-INFO +1 -1
  3. {webbee-0.3.1 → webbee-0.3.3}/pyproject.toml +1 -1
  4. webbee-0.3.3/src/webbee/__init__.py +1 -0
  5. {webbee-0.3.1 → webbee-0.3.3}/src/webbee/render.py +33 -11
  6. {webbee-0.3.1 → webbee-0.3.3}/src/webbee/tui.py +54 -4
  7. {webbee-0.3.1 → webbee-0.3.3}/tests/test_render.py +87 -0
  8. {webbee-0.3.1 → webbee-0.3.3}/tests/test_tui.py +85 -0
  9. webbee-0.3.1/src/webbee/__init__.py +0 -1
  10. {webbee-0.3.1 → webbee-0.3.3}/.github/workflows/publish.yml +0 -0
  11. {webbee-0.3.1 → webbee-0.3.3}/.gitignore +0 -0
  12. {webbee-0.3.1 → webbee-0.3.3}/LICENSE +0 -0
  13. {webbee-0.3.1 → webbee-0.3.3}/README.md +0 -0
  14. {webbee-0.3.1 → webbee-0.3.3}/install.sh +0 -0
  15. {webbee-0.3.1 → webbee-0.3.3}/src/webbee/account.py +0 -0
  16. {webbee-0.3.1 → webbee-0.3.3}/src/webbee/banner_art.py +0 -0
  17. {webbee-0.3.1 → webbee-0.3.3}/src/webbee/checkpoints.py +0 -0
  18. {webbee-0.3.1 → webbee-0.3.3}/src/webbee/cli.py +0 -0
  19. {webbee-0.3.1 → webbee-0.3.3}/src/webbee/clipboard.py +0 -0
  20. {webbee-0.3.1 → webbee-0.3.3}/src/webbee/commands.py +0 -0
  21. {webbee-0.3.1 → webbee-0.3.3}/src/webbee/config.py +0 -0
  22. {webbee-0.3.1 → webbee-0.3.3}/src/webbee/details.py +0 -0
  23. {webbee-0.3.1 → webbee-0.3.3}/src/webbee/events.py +0 -0
  24. {webbee-0.3.1 → webbee-0.3.3}/src/webbee/frames.py +0 -0
  25. {webbee-0.3.1 → webbee-0.3.3}/src/webbee/intel/__init__.py +0 -0
  26. {webbee-0.3.1 → webbee-0.3.3}/src/webbee/intel/chunker.py +0 -0
  27. {webbee-0.3.1 → webbee-0.3.3}/src/webbee/intel/embed.py +0 -0
  28. {webbee-0.3.1 → webbee-0.3.3}/src/webbee/intel/graph.py +0 -0
  29. {webbee-0.3.1 → webbee-0.3.3}/src/webbee/intel/indexer.py +0 -0
  30. {webbee-0.3.1 → webbee-0.3.3}/src/webbee/intel/models.py +0 -0
  31. {webbee-0.3.1 → webbee-0.3.3}/src/webbee/intel/query.py +0 -0
  32. {webbee-0.3.1 → webbee-0.3.3}/src/webbee/intel/service.py +0 -0
  33. {webbee-0.3.1 → webbee-0.3.3}/src/webbee/intel/store.py +0 -0
  34. {webbee-0.3.1 → webbee-0.3.3}/src/webbee/intel/vectors.py +0 -0
  35. {webbee-0.3.1 → webbee-0.3.3}/src/webbee/intel/watch.py +0 -0
  36. {webbee-0.3.1 → webbee-0.3.3}/src/webbee/output_pane.py +0 -0
  37. {webbee-0.3.1 → webbee-0.3.3}/src/webbee/repl.py +0 -0
  38. {webbee-0.3.1 → webbee-0.3.3}/src/webbee/repo.py +0 -0
  39. {webbee-0.3.1 → webbee-0.3.3}/src/webbee/session.py +0 -0
  40. {webbee-0.3.1 → webbee-0.3.3}/src/webbee/sessions.py +0 -0
  41. {webbee-0.3.1 → webbee-0.3.3}/src/webbee/stream.py +0 -0
  42. {webbee-0.3.1 → webbee-0.3.3}/src/webbee/tools.py +0 -0
  43. {webbee-0.3.1 → webbee-0.3.3}/src/webbee/update.py +0 -0
  44. {webbee-0.3.1 → webbee-0.3.3}/tests/__init__.py +0 -0
  45. {webbee-0.3.1 → webbee-0.3.3}/tests/test_account.py +0 -0
  46. {webbee-0.3.1 → webbee-0.3.3}/tests/test_checkpoints.py +0 -0
  47. {webbee-0.3.1 → webbee-0.3.3}/tests/test_cli.py +0 -0
  48. {webbee-0.3.1 → webbee-0.3.3}/tests/test_clipboard.py +0 -0
  49. {webbee-0.3.1 → webbee-0.3.3}/tests/test_commands.py +0 -0
  50. {webbee-0.3.1 → webbee-0.3.3}/tests/test_config.py +0 -0
  51. {webbee-0.3.1 → webbee-0.3.3}/tests/test_cpc_contract_stable.py +0 -0
  52. {webbee-0.3.1 → webbee-0.3.3}/tests/test_details.py +0 -0
  53. {webbee-0.3.1 → webbee-0.3.3}/tests/test_events.py +0 -0
  54. {webbee-0.3.1 → webbee-0.3.3}/tests/test_freeze_fix.py +0 -0
  55. {webbee-0.3.1 → webbee-0.3.3}/tests/test_intel_chunker.py +0 -0
  56. {webbee-0.3.1 → webbee-0.3.3}/tests/test_intel_embed.py +0 -0
  57. {webbee-0.3.1 → webbee-0.3.3}/tests/test_intel_graph.py +0 -0
  58. {webbee-0.3.1 → webbee-0.3.3}/tests/test_intel_indexer.py +0 -0
  59. {webbee-0.3.1 → webbee-0.3.3}/tests/test_intel_no_numpy.py +0 -0
  60. {webbee-0.3.1 → webbee-0.3.3}/tests/test_intel_query.py +0 -0
  61. {webbee-0.3.1 → webbee-0.3.3}/tests/test_intel_service.py +0 -0
  62. {webbee-0.3.1 → webbee-0.3.3}/tests/test_intel_store.py +0 -0
  63. {webbee-0.3.1 → webbee-0.3.3}/tests/test_intel_vectors.py +0 -0
  64. {webbee-0.3.1 → webbee-0.3.3}/tests/test_intel_watch.py +0 -0
  65. {webbee-0.3.1 → webbee-0.3.3}/tests/test_marathon.py +0 -0
  66. {webbee-0.3.1 → webbee-0.3.3}/tests/test_packaging.py +0 -0
  67. {webbee-0.3.1 → webbee-0.3.3}/tests/test_repl.py +0 -0
  68. {webbee-0.3.1 → webbee-0.3.3}/tests/test_repo.py +0 -0
  69. {webbee-0.3.1 → webbee-0.3.3}/tests/test_session.py +0 -0
  70. {webbee-0.3.1 → webbee-0.3.3}/tests/test_sessions.py +0 -0
  71. {webbee-0.3.1 → webbee-0.3.3}/tests/test_stream.py +0 -0
  72. {webbee-0.3.1 → webbee-0.3.3}/tests/test_tools.py +0 -0
  73. {webbee-0.3.1 → webbee-0.3.3}/tests/test_tui_hardening.py +0 -0
  74. {webbee-0.3.1 → webbee-0.3.3}/tests/test_update.py +0 -0
  75. {webbee-0.3.1 → webbee-0.3.3}/tests/test_version.py +0 -0
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.3
4
+
5
+ - Mouse-garbage / dead-keyboard fix (Linux, occasionally macOS): the dock now
6
+ uses button-event mouse tracking instead of any-event — bare mouse movement
7
+ no longer floods the terminal with reports that desynced the input parser,
8
+ typed `35;6;42M…` fragments into the input box and fired phantom Escape
9
+ presses that silently stopped the running turn. Scroll, click and
10
+ drag-to-copy work as before. Leftover report fragments are scrubbed from
11
+ the input, and a phantom Escape during a flood cleans the input instead of
12
+ killing the turn.
13
+ - Untrusted text (tool output, relayed notes/summaries) is stripped of raw
14
+ escape/control bytes before rendering, so it can never flip terminal modes.
15
+
16
+ ## 0.3.2
17
+
18
+ - Wrapped lines keep their left gutter: a long note / progress / thinking /
19
+ echoed-message line used to continue flush against the screen edge; every
20
+ visual line now aligns to the same 2-column transcript gutter.
21
+
3
22
  ## 0.3.1
4
23
 
5
24
  - Auto-checkpointing no longer disables itself after a single transient
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webbee
3
- Version: 0.3.1
3
+ Version: 0.3.3
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.1"
3
+ version = "0.3.3"
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.3"
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import re
2
3
  import time
3
4
 
4
5
  from rich.console import Console
@@ -20,6 +21,24 @@ def _pad(renderable):
20
21
  lines up with the 2-space chrome (' 🐝 Webbee', ' 13.9s · …', the ❯ bar)."""
21
22
  return Padding(renderable, (0, 0, 0, _GUTTER))
22
23
 
24
+
25
+ # Untrusted content (tool output, kernel-relayed text) must never carry raw
26
+ # escape/control bytes into the terminal: a \x1b[?1003h inside a printed tool
27
+ # summary silently flips the terminal into any-event mouse tracking (the
28
+ # mouse-garbage bug's evil twin); OSC can retitle/exfiltrate. Rich's Text
29
+ # passes raw \x1b through untouched, so we strip at the sink: CSI and OSC
30
+ # sequences whole, then every remaining C0 control byte except \n and \t.
31
+ _CTRL = re.compile(
32
+ r"\x1b\[[0-9;?]*[ -/]*[@-~]" # CSI (incl. private modes)
33
+ r"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?" # OSC (BEL/ST or unterminated)
34
+ r"|[\x00-\x08\x0b-\x1f\x7f]" # other C0 + DEL (keeps \t \n)
35
+ )
36
+
37
+
38
+ def _clean(s) -> str:
39
+ """Strip escape sequences + control bytes from untrusted display text."""
40
+ return _CTRL.sub("", str(s or ""))
41
+
23
42
  _ICON = {"read_file": "📖", "grep": "🔎", "glob": "🗂️", "write_file": "✎",
24
43
  "edit_file": "🔧", "bash": "⚡"}
25
44
  _BEE = "yellow" # bee-yellow brand accent — logo / 🐝 / notes / busy dot ONLY
@@ -173,6 +192,7 @@ class RichSink:
173
192
 
174
193
  def end_turn(self, final_text: str) -> None:
175
194
  self._busy = False
195
+ final_text = _clean(final_text)
176
196
  if final_text:
177
197
  self.console.print() # separation before the focus block
178
198
  self.console.print(Text(" 🐝 Webbee", style=f"bold {_BEE}"))
@@ -189,15 +209,17 @@ class RichSink:
189
209
  self._nudge()
190
210
 
191
211
  def note(self, message: str) -> None:
192
- self.console.print(Text(" " + message, style=_BEE))
212
+ # _pad (not a " " prefix) so a wrapped line keeps the gutter — a bare
213
+ # prefix indents only the first visual line and continuations hug the
214
+ # screen edge.
215
+ self.console.print(_pad(Text(_clean(message), style=_BEE)))
193
216
  self._nudge()
194
217
 
195
218
  def user_echo(self, text: str) -> None:
196
219
  """Commit the just-sent user message as its own clearly-readable line
197
220
  with a background bar (NOT boxed like the live input) so it stands out
198
221
  as 'what I sent' in the scrollback."""
199
- self.console.print(Text.assemble(
200
- (" ", ""), (" ❯ " + text + " ", "bold white on grey30")))
222
+ self.console.print(_pad(Text(" ❯ " + _clean(text) + " ", style="bold white on grey30")))
201
223
  self._nudge()
202
224
 
203
225
  def clear(self) -> None:
@@ -234,11 +256,11 @@ class RichSink:
234
256
  mark = "✓" if ok else "✗"
235
257
  self.console.print(Text.assemble(
236
258
  (" " + icon + " ", "dim"),
237
- (_tool, "dim"),
238
- ((" " + arg[:40]) if arg else "", "dim"),
259
+ (_clean(_tool), "dim"),
260
+ ((" " + _clean(arg)[:40]) if arg else "", "dim"),
239
261
  (" ", ""),
240
262
  (mark + " ", "green" if ok else "red"),
241
- (str(summary)[:50], "dim"),
263
+ (_clean(summary)[:50], "dim"),
242
264
  ))
243
265
  self._pending = ("", "")
244
266
  self._nudge()
@@ -249,8 +271,8 @@ class RichSink:
249
271
  the reply comes through the pinned box via an asyncio.Future (no
250
272
  blocking input on the event loop); otherwise fall back to the injected
251
273
  sync reader (tests / non-tty)."""
252
- label = f"{app_id}·{tool}" if app_id else tool
253
- sal = _salient_arg(args)
274
+ label = _clean(f"{app_id}·{tool}" if app_id else tool)
275
+ sal = _clean(_salient_arg(args))
254
276
  self.console.print(Text.assemble((" ? approve ", "yellow"), (label, "dim"),
255
277
  ((" " + sal[:60]) if sal else "", "dim")))
256
278
  fut = self._arm_consent(label, sal)
@@ -322,7 +344,7 @@ class RichSink:
322
344
  def _prev(p):
323
345
  p = p or {}
324
346
  note = f" (truncated, {p.get('total_bytes', 0)} bytes total)" if p.get("truncated") else ""
325
- return (str(p.get("preview", "") or "")[:2000]) + note
347
+ return (_clean(p.get("preview", ""))[:2000]) + note
326
348
 
327
349
  mark = "✓" if detail.get("ok") else "✗"
328
350
  head = (f"{mark} {detail.get('app_id', '')}·{detail.get('tool', '')} "
@@ -334,14 +356,14 @@ class RichSink:
334
356
 
335
357
  def progress(self, text: str) -> None:
336
358
  if text:
337
- self.console.print(Text(" " + text, style="dim italic"))
359
+ self.console.print(_pad(Text(_clean(text), style="dim italic")))
338
360
  self._nudge()
339
361
 
340
362
  def thinking(self, text: str) -> None:
341
363
  # System-driven reasoning as a distinct 💭 block — visually apart from the
342
364
  # dim `progress` line (which stays reserved for status like low-balance).
343
365
  if text:
344
- self.console.print(Text(" 💭 " + text, style="italic medium_purple3"))
366
+ self.console.print(_pad(Text("💭 " + _clean(text), style="italic medium_purple3")))
345
367
  self._nudge()
346
368
 
347
369
  def plan_blocked(self, tool: str) -> None:
@@ -6,6 +6,7 @@ pinned box when the input is empty and no turn is running. Pure helpers
6
6
  (next_mode/build_toolbar) are unit-tested; the Application is TTY/headless-
7
7
  smoke verified. Grounded in prompt_toolkit 3.0.52."""
8
8
  import asyncio
9
+ import re
9
10
 
10
11
  from webbee.output_pane import OutputPane # noqa: F401 — re-exported (webbee.tui.OutputPane)
11
12
  from webbee.render import _fmt_tokens
@@ -13,6 +14,46 @@ from webbee.render import _fmt_tokens
13
14
  _MODES = ("default", "plan", "autopilot")
14
15
  _SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" # braille frames — animated while a turn runs
15
16
 
17
+ # Leaked SGR mouse-report fragments ("<35;6;42M" / "35;6;42M"): under a
18
+ # mouse-move flood the vt100 parser splits sequences at read-chunk boundaries
19
+ # and the printable tail lands in the input buffer as literal text (live on
20
+ # Linux + occasionally macOS, 2026-07-12). Requires the full x;y;btn+M shape —
21
+ # ordinary "a;b;c" text never matches (a literal "35;6;42M" the user typed
22
+ # would be dropped too; accepted, it IS the residue shape).
23
+ _MOUSE_RESIDUE = re.compile(r"(?:\x1b\[)?<?\d{1,4};\d{1,4};\d{1,4}[Mm]")
24
+
25
+
26
+ def scrub_mouse_residue(text: str) -> str:
27
+ """PURE. Drop leaked mouse-report fragments; everything else unchanged."""
28
+ return _MOUSE_RESIDUE.sub("", text or "")
29
+
30
+
31
+ def configure_mouse_modes(output) -> None:
32
+ """Replace prompt_toolkit's ANY-EVENT mouse tracking (?1003 — every bare
33
+ mouse move fires a report) with BUTTON-EVENT tracking (?1002 — reports only
34
+ while a button is held). Wheel scroll, clicks and drag-select all still
35
+ work; the bare-move flood that desyncs the parser (phantom Escape + report
36
+ tails typed into the input) disappears at the source. No-op for outputs
37
+ without write_raw (non-vt100)."""
38
+ if not hasattr(output, "write_raw"):
39
+ return
40
+
41
+ def _enable():
42
+ output.write_raw("\x1b[?1000h") # clicks + wheel
43
+ output.write_raw("\x1b[?1002h") # motion ONLY while a button is held
44
+ output.write_raw("\x1b[?1015h") # urxvt encoding
45
+ output.write_raw("\x1b[?1006h") # SGR encoding
46
+
47
+ def _disable():
48
+ output.write_raw("\x1b[?1002l")
49
+ output.write_raw("\x1b[?1003l") # belt & braces: clear any-event too
50
+ output.write_raw("\x1b[?1000l")
51
+ output.write_raw("\x1b[?1015l")
52
+ output.write_raw("\x1b[?1006l")
53
+
54
+ output.enable_mouse_support = _enable
55
+ output.disable_mouse_support = _disable
56
+
16
57
 
17
58
  def next_mode(mode: str) -> str:
18
59
  try:
@@ -47,11 +88,19 @@ def build_toolbar(mode: str, tokens: int, credits: int, *, busy: bool = False,
47
88
  f" · {_fmt_tokens(tokens)} tok · {_fmt_tokens(credits)} credits · Shift + TAB: switch mode")]
48
89
 
49
90
 
50
- def _escape_action(sel: dict, turn: dict, is_busy, stop_turn, event) -> None:
91
+ def _escape_action(sel: dict, turn: dict, is_busy, stop_turn, event, buf=None) -> None:
51
92
  """Esc key binding (P5g). While a turn is running, STOP it — cancel the LOCAL
52
93
  turn task (what actually tears the turn down, same as Ctrl-C) AND ask the
53
- server to stop (so it stops spending). While idle, clear the step-selection."""
94
+ server to stop (so it stops spending). While idle, clear the step-selection.
95
+
96
+ Phantom-Esc guard (2026-07-12): a mouse-report flood splits sequences —
97
+ the ESC arrives as a lone Escape KEY and the tail lands in the input
98
+ buffer. Residue in the buffer ⇒ this Escape is almost certainly a split
99
+ report, not the user: clean the buffer and KEEP the turn running."""
54
100
  if is_busy():
101
+ if buf is not None and _MOUSE_RESIDUE.search(buf.text or ""):
102
+ buf.text = scrub_mouse_residue(buf.text)
103
+ return
55
104
  t = turn.get("task")
56
105
  if t is not None and not t.done():
57
106
  if stop_turn is not None:
@@ -110,7 +159,7 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
110
159
 
111
160
  @kb.add("enter")
112
161
  def _enter(event):
113
- text = buf.text
162
+ text = scrub_mouse_residue(buf.text) # never send leaked mouse reports
114
163
  buf.reset()
115
164
  if consent_pending():
116
165
  resolve_consent(text) # ICNLI: relay the raw reply verbatim
@@ -161,7 +210,7 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
161
210
 
162
211
  @kb.add("escape")
163
212
  def _step_clear(event):
164
- _escape_action(sel, turn, is_busy, stop_turn, event)
213
+ _escape_action(sel, turn, is_busy, stop_turn, event, buf=buf)
165
214
 
166
215
  @kb.add("pageup")
167
216
  def _pgup(event):
@@ -218,6 +267,7 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
218
267
  })
219
268
  app = Application(layout=Layout(root, focused_element=input_win), key_bindings=kb,
220
269
  full_screen=True, mouse_support=True, style=style)
270
+ configure_mouse_modes(app.output) # ?1002 button-event, never ?1003 any-event
221
271
 
222
272
  async def _ticker():
223
273
  # animate the spinner + tick the elapsed clock while a turn runs
@@ -295,3 +295,90 @@ def test_plan_blocked_prints_english_hint():
295
295
  assert "notes.delete_note" in out
296
296
  assert "shift+tab" in out.lower()
297
297
  assert not NO_CYRILLIC.search(out)
298
+
299
+
300
+ # ---- hanging indent on wrapped chrome lines (0.3.2) ------------------------
301
+ # A long one-line note/progress/thinking/user-echo used to wrap flush against
302
+ # the left screen edge (the 2-space gutter lived only in the first line's
303
+ # prefix string). Every visual line must start at the same 2-col gutter.
304
+
305
+ def _narrow_sink(width=40):
306
+ return RichSink(console=Console(record=True, width=width, force_terminal=False),
307
+ live_enabled=False, input_fn=lambda p: "", clock=lambda: 0.0)
308
+
309
+
310
+ def _assert_gutter(out: str):
311
+ lines = [ln for ln in out.splitlines() if ln.strip()]
312
+ assert lines, "nothing rendered"
313
+ for ln in lines:
314
+ assert ln.startswith(" "), f"line lost the gutter: {ln!r}"
315
+
316
+
317
+ def test_note_wraps_with_gutter():
318
+ s = _narrow_sink()
319
+ s.note("Right now we need to move the Matomo extension onto the new "
320
+ "backend path cleanly and rerun the whole test suite afterwards")
321
+ _assert_gutter(s.console.export_text())
322
+
323
+
324
+ def test_progress_wraps_with_gutter():
325
+ s = _narrow_sink()
326
+ s.progress("switching every old analytics call over to the new prefix "
327
+ "without touching any business logic at all")
328
+ _assert_gutter(s.console.export_text())
329
+
330
+
331
+ def test_thinking_wraps_with_gutter():
332
+ s = _narrow_sink()
333
+ s.thinking("bulk replace failed on repeated lines so a scripted targeted "
334
+ "replacement across the tree is faster and safer here")
335
+ _assert_gutter(s.console.export_text())
336
+
337
+
338
+ def test_user_echo_wraps_with_gutter():
339
+ s = _narrow_sink()
340
+ s.user_echo("please migrate the whole extension to the new backend path "
341
+ "and make sure nothing regresses while you are at it")
342
+ _assert_gutter(s.console.export_text())
343
+
344
+
345
+ # ---- control-byte sanitization of untrusted content (0.3.3) -----------------
346
+ # Tool output / kernel-relayed text must never carry raw ESC sequences into the
347
+ # terminal — \x1b[?1003h in a printed summary would silently flip the user's
348
+ # terminal into any-event mouse tracking (the mouse-garbage bug's evil twin).
349
+
350
+ _INJ = "ok\x1b[?1003h\x1b]0;pwned\x07 done"
351
+
352
+
353
+ def test_note_strips_escape_sequences():
354
+ s = _sink()
355
+ s.note(_INJ)
356
+ out = s.console.export_text()
357
+ assert "\x1b[?1003h" not in out and "pwned" not in out
358
+ assert "ok" in out and "done" in out
359
+
360
+
361
+ def test_tool_result_strips_escape_sequences():
362
+ s = _sink()
363
+ s.tool_start("bash", {"command": "cat file"})
364
+ s.tool_result("bash", True, _INJ)
365
+ out = s.console.export_text()
366
+ assert "\x1b[?1003h" not in out
367
+ assert "ok" in out
368
+
369
+
370
+ def test_end_turn_strips_escape_sequences_keeps_newlines():
371
+ s = _sink()
372
+ s.begin_turn()
373
+ s.end_turn("line one\x1b[?1003h\n\nline two")
374
+ out = s.console.export_text()
375
+ assert "\x1b[?1003h" not in out
376
+ assert "line one" in out and "line two" in out
377
+
378
+
379
+ def test_progress_and_thinking_strip_escapes():
380
+ s = _sink()
381
+ s.progress(_INJ)
382
+ s.thinking(_INJ)
383
+ out = s.console.export_text()
384
+ assert "\x1b[?1003h" not in out and "pwned" not in out
@@ -327,3 +327,88 @@ def test_interrupt_with_no_running_task_is_a_noop():
327
327
 
328
328
  assert event.app.background_tasks == []
329
329
  assert stop_calls == []
330
+
331
+
332
+ # ---- mouse-report flood hardening (0.3.3) ----------------------------------
333
+ # Linux (occasionally macOS) terminals under prompt_toolkit's default
334
+ # mouse_support=True run ANY-EVENT tracking (?1003): every bare mouse move
335
+ # fires an SGR report. Under that flood the vt100 parser splits sequences at
336
+ # read-chunk boundaries — the ESC arrives as a phantom Escape KEY (which used
337
+ # to stop the running turn!) and the tail ("35;6;42M…") lands in the input
338
+ # buffer as literal text.
339
+
340
+ _GARBAGE = "roo35;6;42M<35;35;46M5;49;46M3M35;71;37M29M5;94;19M5;101;14M4M"
341
+
342
+
343
+ def test_scrub_mouse_residue_removes_leaked_reports():
344
+ from webbee.tui import scrub_mouse_residue
345
+ out = scrub_mouse_residue(_GARBAGE)
346
+ assert "42M" not in out and "<35" not in out
347
+ assert out.startswith("roo") # non-residue text survives
348
+
349
+
350
+ def test_scrub_mouse_residue_keeps_normal_text():
351
+ from webbee.tui import scrub_mouse_residue
352
+ for s in ("fix the tests", "a;b;c", "35;6", "надо дочинить endpoint'ы", ""):
353
+ assert scrub_mouse_residue(s) == s
354
+
355
+
356
+ class _FakeOutput:
357
+ def __init__(self):
358
+ self.raw = []
359
+
360
+ def write_raw(self, s):
361
+ self.raw.append(s)
362
+
363
+
364
+ def test_configure_mouse_modes_button_event_not_any_event():
365
+ from webbee.tui import configure_mouse_modes
366
+ out = _FakeOutput()
367
+ configure_mouse_modes(out)
368
+ out.enable_mouse_support()
369
+ joined = "".join(out.raw)
370
+ assert "\x1b[?1002h" in joined # drag/wheel/click tracking
371
+ assert "\x1b[?1003h" not in joined # NEVER any-event (bare-move flood)
372
+ out.raw.clear()
373
+ out.disable_mouse_support()
374
+ joined = "".join(out.raw)
375
+ assert "\x1b[?1002l" in joined and "\x1b[?1003l" in joined # belt & braces
376
+
377
+
378
+ def test_configure_mouse_modes_skips_outputs_without_write_raw():
379
+ from webbee.tui import configure_mouse_modes
380
+ class _Plain:
381
+ pass
382
+ p = _Plain()
383
+ configure_mouse_modes(p) # must not raise, must not add attrs
384
+ assert not hasattr(p, "enable_mouse_support")
385
+
386
+
387
+ class _FakeBuf:
388
+ def __init__(self, text=""):
389
+ self.text = text
390
+
391
+
392
+ def test_phantom_escape_with_residue_cleans_buffer_not_the_turn():
393
+ # A mouse-report flood in the input buffer means this Escape is almost
394
+ # certainly a SPLIT SEQUENCE, not the user — never kill the turn on it.
395
+ from webbee.tui import _escape_action
396
+
397
+ event = _FakeEvent()
398
+ task = _FakeTask()
399
+ turn = {"task": task}
400
+ buf = _FakeBuf("draft " + _GARBAGE)
401
+ _escape_action({"i": None}, turn, lambda: True, None, event, buf=buf)
402
+ assert task.cancelled == [] # the turn SURVIVES
403
+ assert "42M" not in buf.text # the residue is cleaned
404
+ assert buf.text.startswith("draft roo")
405
+
406
+
407
+ def test_real_escape_with_clean_buffer_still_stops_the_turn():
408
+ from webbee.tui import _escape_action
409
+
410
+ event = _FakeEvent()
411
+ task = _FakeTask()
412
+ turn = {"task": task}
413
+ _escape_action({"i": None}, turn, lambda: True, None, event, buf=_FakeBuf("draft reply"))
414
+ assert task.cancelled == [1] # normal Esc behavior unchanged
@@ -1 +0,0 @@
1
- __version__ = "0.3.1"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes