webbee 0.3.2__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.2 → webbee-0.3.3}/CHANGELOG.md +13 -0
  2. {webbee-0.3.2 → webbee-0.3.3}/PKG-INFO +1 -1
  3. {webbee-0.3.2 → webbee-0.3.3}/pyproject.toml +1 -1
  4. webbee-0.3.3/src/webbee/__init__.py +1 -0
  5. {webbee-0.3.2 → webbee-0.3.3}/src/webbee/render.py +30 -10
  6. {webbee-0.3.2 → webbee-0.3.3}/src/webbee/tui.py +54 -4
  7. {webbee-0.3.2 → webbee-0.3.3}/tests/test_render.py +42 -0
  8. {webbee-0.3.2 → webbee-0.3.3}/tests/test_tui.py +85 -0
  9. webbee-0.3.2/src/webbee/__init__.py +0 -1
  10. {webbee-0.3.2 → webbee-0.3.3}/.github/workflows/publish.yml +0 -0
  11. {webbee-0.3.2 → webbee-0.3.3}/.gitignore +0 -0
  12. {webbee-0.3.2 → webbee-0.3.3}/LICENSE +0 -0
  13. {webbee-0.3.2 → webbee-0.3.3}/README.md +0 -0
  14. {webbee-0.3.2 → webbee-0.3.3}/install.sh +0 -0
  15. {webbee-0.3.2 → webbee-0.3.3}/src/webbee/account.py +0 -0
  16. {webbee-0.3.2 → webbee-0.3.3}/src/webbee/banner_art.py +0 -0
  17. {webbee-0.3.2 → webbee-0.3.3}/src/webbee/checkpoints.py +0 -0
  18. {webbee-0.3.2 → webbee-0.3.3}/src/webbee/cli.py +0 -0
  19. {webbee-0.3.2 → webbee-0.3.3}/src/webbee/clipboard.py +0 -0
  20. {webbee-0.3.2 → webbee-0.3.3}/src/webbee/commands.py +0 -0
  21. {webbee-0.3.2 → webbee-0.3.3}/src/webbee/config.py +0 -0
  22. {webbee-0.3.2 → webbee-0.3.3}/src/webbee/details.py +0 -0
  23. {webbee-0.3.2 → webbee-0.3.3}/src/webbee/events.py +0 -0
  24. {webbee-0.3.2 → webbee-0.3.3}/src/webbee/frames.py +0 -0
  25. {webbee-0.3.2 → webbee-0.3.3}/src/webbee/intel/__init__.py +0 -0
  26. {webbee-0.3.2 → webbee-0.3.3}/src/webbee/intel/chunker.py +0 -0
  27. {webbee-0.3.2 → webbee-0.3.3}/src/webbee/intel/embed.py +0 -0
  28. {webbee-0.3.2 → webbee-0.3.3}/src/webbee/intel/graph.py +0 -0
  29. {webbee-0.3.2 → webbee-0.3.3}/src/webbee/intel/indexer.py +0 -0
  30. {webbee-0.3.2 → webbee-0.3.3}/src/webbee/intel/models.py +0 -0
  31. {webbee-0.3.2 → webbee-0.3.3}/src/webbee/intel/query.py +0 -0
  32. {webbee-0.3.2 → webbee-0.3.3}/src/webbee/intel/service.py +0 -0
  33. {webbee-0.3.2 → webbee-0.3.3}/src/webbee/intel/store.py +0 -0
  34. {webbee-0.3.2 → webbee-0.3.3}/src/webbee/intel/vectors.py +0 -0
  35. {webbee-0.3.2 → webbee-0.3.3}/src/webbee/intel/watch.py +0 -0
  36. {webbee-0.3.2 → webbee-0.3.3}/src/webbee/output_pane.py +0 -0
  37. {webbee-0.3.2 → webbee-0.3.3}/src/webbee/repl.py +0 -0
  38. {webbee-0.3.2 → webbee-0.3.3}/src/webbee/repo.py +0 -0
  39. {webbee-0.3.2 → webbee-0.3.3}/src/webbee/session.py +0 -0
  40. {webbee-0.3.2 → webbee-0.3.3}/src/webbee/sessions.py +0 -0
  41. {webbee-0.3.2 → webbee-0.3.3}/src/webbee/stream.py +0 -0
  42. {webbee-0.3.2 → webbee-0.3.3}/src/webbee/tools.py +0 -0
  43. {webbee-0.3.2 → webbee-0.3.3}/src/webbee/update.py +0 -0
  44. {webbee-0.3.2 → webbee-0.3.3}/tests/__init__.py +0 -0
  45. {webbee-0.3.2 → webbee-0.3.3}/tests/test_account.py +0 -0
  46. {webbee-0.3.2 → webbee-0.3.3}/tests/test_checkpoints.py +0 -0
  47. {webbee-0.3.2 → webbee-0.3.3}/tests/test_cli.py +0 -0
  48. {webbee-0.3.2 → webbee-0.3.3}/tests/test_clipboard.py +0 -0
  49. {webbee-0.3.2 → webbee-0.3.3}/tests/test_commands.py +0 -0
  50. {webbee-0.3.2 → webbee-0.3.3}/tests/test_config.py +0 -0
  51. {webbee-0.3.2 → webbee-0.3.3}/tests/test_cpc_contract_stable.py +0 -0
  52. {webbee-0.3.2 → webbee-0.3.3}/tests/test_details.py +0 -0
  53. {webbee-0.3.2 → webbee-0.3.3}/tests/test_events.py +0 -0
  54. {webbee-0.3.2 → webbee-0.3.3}/tests/test_freeze_fix.py +0 -0
  55. {webbee-0.3.2 → webbee-0.3.3}/tests/test_intel_chunker.py +0 -0
  56. {webbee-0.3.2 → webbee-0.3.3}/tests/test_intel_embed.py +0 -0
  57. {webbee-0.3.2 → webbee-0.3.3}/tests/test_intel_graph.py +0 -0
  58. {webbee-0.3.2 → webbee-0.3.3}/tests/test_intel_indexer.py +0 -0
  59. {webbee-0.3.2 → webbee-0.3.3}/tests/test_intel_no_numpy.py +0 -0
  60. {webbee-0.3.2 → webbee-0.3.3}/tests/test_intel_query.py +0 -0
  61. {webbee-0.3.2 → webbee-0.3.3}/tests/test_intel_service.py +0 -0
  62. {webbee-0.3.2 → webbee-0.3.3}/tests/test_intel_store.py +0 -0
  63. {webbee-0.3.2 → webbee-0.3.3}/tests/test_intel_vectors.py +0 -0
  64. {webbee-0.3.2 → webbee-0.3.3}/tests/test_intel_watch.py +0 -0
  65. {webbee-0.3.2 → webbee-0.3.3}/tests/test_marathon.py +0 -0
  66. {webbee-0.3.2 → webbee-0.3.3}/tests/test_packaging.py +0 -0
  67. {webbee-0.3.2 → webbee-0.3.3}/tests/test_repl.py +0 -0
  68. {webbee-0.3.2 → webbee-0.3.3}/tests/test_repo.py +0 -0
  69. {webbee-0.3.2 → webbee-0.3.3}/tests/test_session.py +0 -0
  70. {webbee-0.3.2 → webbee-0.3.3}/tests/test_sessions.py +0 -0
  71. {webbee-0.3.2 → webbee-0.3.3}/tests/test_stream.py +0 -0
  72. {webbee-0.3.2 → webbee-0.3.3}/tests/test_tools.py +0 -0
  73. {webbee-0.3.2 → webbee-0.3.3}/tests/test_tui_hardening.py +0 -0
  74. {webbee-0.3.2 → webbee-0.3.3}/tests/test_update.py +0 -0
  75. {webbee-0.3.2 → webbee-0.3.3}/tests/test_version.py +0 -0
@@ -1,5 +1,18 @@
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
+
3
16
  ## 0.3.2
4
17
 
5
18
  - Wrapped lines keep their left gutter: a long note / progress / thinking /
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webbee
3
- Version: 0.3.2
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.2"
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}"))
@@ -192,14 +212,14 @@ class RichSink:
192
212
  # _pad (not a " " prefix) so a wrapped line keeps the gutter — a bare
193
213
  # prefix indents only the first visual line and continuations hug the
194
214
  # screen edge.
195
- self.console.print(_pad(Text(message, style=_BEE)))
215
+ self.console.print(_pad(Text(_clean(message), style=_BEE)))
196
216
  self._nudge()
197
217
 
198
218
  def user_echo(self, text: str) -> None:
199
219
  """Commit the just-sent user message as its own clearly-readable line
200
220
  with a background bar (NOT boxed like the live input) so it stands out
201
221
  as 'what I sent' in the scrollback."""
202
- self.console.print(_pad(Text(" ❯ " + text + " ", style="bold white on grey30")))
222
+ self.console.print(_pad(Text(" ❯ " + _clean(text) + " ", style="bold white on grey30")))
203
223
  self._nudge()
204
224
 
205
225
  def clear(self) -> None:
@@ -236,11 +256,11 @@ class RichSink:
236
256
  mark = "✓" if ok else "✗"
237
257
  self.console.print(Text.assemble(
238
258
  (" " + icon + " ", "dim"),
239
- (_tool, "dim"),
240
- ((" " + arg[:40]) if arg else "", "dim"),
259
+ (_clean(_tool), "dim"),
260
+ ((" " + _clean(arg)[:40]) if arg else "", "dim"),
241
261
  (" ", ""),
242
262
  (mark + " ", "green" if ok else "red"),
243
- (str(summary)[:50], "dim"),
263
+ (_clean(summary)[:50], "dim"),
244
264
  ))
245
265
  self._pending = ("", "")
246
266
  self._nudge()
@@ -251,8 +271,8 @@ class RichSink:
251
271
  the reply comes through the pinned box via an asyncio.Future (no
252
272
  blocking input on the event loop); otherwise fall back to the injected
253
273
  sync reader (tests / non-tty)."""
254
- label = f"{app_id}·{tool}" if app_id else tool
255
- sal = _salient_arg(args)
274
+ label = _clean(f"{app_id}·{tool}" if app_id else tool)
275
+ sal = _clean(_salient_arg(args))
256
276
  self.console.print(Text.assemble((" ? approve ", "yellow"), (label, "dim"),
257
277
  ((" " + sal[:60]) if sal else "", "dim")))
258
278
  fut = self._arm_consent(label, sal)
@@ -324,7 +344,7 @@ class RichSink:
324
344
  def _prev(p):
325
345
  p = p or {}
326
346
  note = f" (truncated, {p.get('total_bytes', 0)} bytes total)" if p.get("truncated") else ""
327
- return (str(p.get("preview", "") or "")[:2000]) + note
347
+ return (_clean(p.get("preview", ""))[:2000]) + note
328
348
 
329
349
  mark = "✓" if detail.get("ok") else "✗"
330
350
  head = (f"{mark} {detail.get('app_id', '')}·{detail.get('tool', '')} "
@@ -336,14 +356,14 @@ class RichSink:
336
356
 
337
357
  def progress(self, text: str) -> None:
338
358
  if text:
339
- self.console.print(_pad(Text(text, style="dim italic")))
359
+ self.console.print(_pad(Text(_clean(text), style="dim italic")))
340
360
  self._nudge()
341
361
 
342
362
  def thinking(self, text: str) -> None:
343
363
  # System-driven reasoning as a distinct 💭 block — visually apart from the
344
364
  # dim `progress` line (which stays reserved for status like low-balance).
345
365
  if text:
346
- self.console.print(_pad(Text("💭 " + text, style="italic medium_purple3")))
366
+ self.console.print(_pad(Text("💭 " + _clean(text), style="italic medium_purple3")))
347
367
  self._nudge()
348
368
 
349
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
@@ -340,3 +340,45 @@ def test_user_echo_wraps_with_gutter():
340
340
  s.user_echo("please migrate the whole extension to the new backend path "
341
341
  "and make sure nothing regresses while you are at it")
342
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.2"
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