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.
- {webbee-0.3.2 → webbee-0.3.3}/CHANGELOG.md +13 -0
- {webbee-0.3.2 → webbee-0.3.3}/PKG-INFO +1 -1
- {webbee-0.3.2 → webbee-0.3.3}/pyproject.toml +1 -1
- webbee-0.3.3/src/webbee/__init__.py +1 -0
- {webbee-0.3.2 → webbee-0.3.3}/src/webbee/render.py +30 -10
- {webbee-0.3.2 → webbee-0.3.3}/src/webbee/tui.py +54 -4
- {webbee-0.3.2 → webbee-0.3.3}/tests/test_render.py +42 -0
- {webbee-0.3.2 → webbee-0.3.3}/tests/test_tui.py +85 -0
- webbee-0.3.2/src/webbee/__init__.py +0 -1
- {webbee-0.3.2 → webbee-0.3.3}/.github/workflows/publish.yml +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/.gitignore +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/LICENSE +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/README.md +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/install.sh +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/src/webbee/account.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/src/webbee/banner_art.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/src/webbee/checkpoints.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/src/webbee/cli.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/src/webbee/clipboard.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/src/webbee/commands.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/src/webbee/config.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/src/webbee/details.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/src/webbee/events.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/src/webbee/frames.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/src/webbee/intel/__init__.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/src/webbee/intel/chunker.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/src/webbee/intel/embed.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/src/webbee/intel/graph.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/src/webbee/intel/indexer.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/src/webbee/intel/models.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/src/webbee/intel/query.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/src/webbee/intel/service.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/src/webbee/intel/store.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/src/webbee/intel/vectors.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/src/webbee/intel/watch.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/src/webbee/output_pane.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/src/webbee/repl.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/src/webbee/repo.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/src/webbee/session.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/src/webbee/sessions.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/src/webbee/stream.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/src/webbee/tools.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/src/webbee/update.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/tests/__init__.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/tests/test_account.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/tests/test_checkpoints.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/tests/test_cli.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/tests/test_clipboard.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/tests/test_commands.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/tests/test_config.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/tests/test_cpc_contract_stable.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/tests/test_details.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/tests/test_events.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/tests/test_freeze_fix.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/tests/test_intel_chunker.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/tests/test_intel_embed.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/tests/test_intel_graph.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/tests/test_intel_indexer.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/tests/test_intel_no_numpy.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/tests/test_intel_query.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/tests/test_intel_service.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/tests/test_intel_store.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/tests/test_intel_vectors.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/tests/test_intel_watch.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/tests/test_marathon.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/tests/test_packaging.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/tests/test_repl.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/tests/test_repo.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/tests/test_session.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/tests/test_sessions.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/tests/test_stream.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/tests/test_tools.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/tests/test_tui_hardening.py +0 -0
- {webbee-0.3.2 → webbee-0.3.3}/tests/test_update.py +0 -0
- {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 /
|
|
@@ -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
|
-
(
|
|
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 (
|
|
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
|
|
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
|