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